Compare commits

...

29 commits

Author SHA1 Message Date
Breadway
fd1189dc89
Merge pull request #7 from Breadway/dev
Some checks failed
CI / build-and-test (ubuntu-latest, stable) (push) Failing after 14s
CI / build-and-test (macos-latest, stable) (push) Has been cancelled
Document sync export/import and update snapshot layout
2026-05-16 22:18:41 +08:00
Breadway
23bb4f8977 docs: document sync export/import and updated snapshot layout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 22:17:26 +08:00
Breadway
4072f64fcb Commiting for bread sync 2026-05-16 19:44:19 +08:00
Breadway
20d6a8b6fa
Merge pull request #6 from Breadway/dev
Final Release of Version 1.0
2026-05-13 22:18:52 +08:00
Breadway
425b746780 Final Release of Version 1.0 2026-05-13 22:01:42 +08:00
Breadway
aa967fda8f
Merge pull request #5 from Breadway/dev
Enhance device normalization and classification with Lua support
2026-05-12 21:31:15 +08:00
Breadway
434fe1721c feat: enhance device normalization and classification
- Introduced a new mechanism in EventNormalizer to suppress duplicate events from child nodes of the same physical device.
- Removed the device classification logic from the normalizer and replaced it with a rule-based system using Lua scripts.
- Added support for user-defined device rules in Lua, allowing for flexible device naming based on various conditions.
- Updated the state engine to handle device rules and resolve device names before dispatching events.
- Modified the installation script to set up default configuration files for the daemon and Lua modules.
- Improved the handling of systemd user services to dynamically set the ExecStart path based on the installation directory.
2026-05-12 21:27:07 +08:00
Breadway
22f591e0e6 revert 2026-05-12 00:35:09 +08:00
Breadway
74a4082d10
Merge pull request #4 from Breadway/master
Merge pull request #3 from Breadway/dev
2026-05-12 00:27:16 +08:00
Breadway
9dcdc67b5e
Merge pull request #3 from Breadway/dev
Dev
2026-05-12 00:25:43 +08:00
copilot-swe-agent[bot]
762e6a6a59
docs: align daemon naming with package rename
Agent-Logs-Url: https://github.com/Breadway/bread/sessions/1d380004-8d78-4a1f-9fbb-0c8a487b2e14

Co-authored-by: Breadway <108389940+Breadway@users.noreply.github.com>
2026-05-11 16:25:35 +00:00
copilot-swe-agent[bot]
007478f82c
Merge origin/master into dev and resolve conflicts
Co-authored-by: Breadway <108389940+Breadway@users.noreply.github.com>
2026-05-11 16:23:31 +00:00
Breadway
364a35142e feat: add bread-sync module for snapshot and restore functionality
- Introduced `bread-sync` module with core functionalities for syncing system state via Git.
- Implemented `MachineProfile` struct for managing machine profiles, including methods for reading and writing profiles.
- Added package management support with snapshot capabilities for `pacman`, `pip`, `npm`, and `cargo`.
- Created comprehensive tests for sync operations, package parsing, and machine profile management.
- Enhanced `udev` adapter to include vendor and product IDs for scanned devices.
- Updated state engine to handle module clearing commands.
- Introduced Lua integration for accessing machine information and file system operations.
- Improved packaging documentation for Arch Linux and systemd service setup.
2026-05-12 00:20:45 +08:00
Breadway
251c586b6f revert 2026-05-11 22:51:32 +08:00
Breadway
d27323d2a2 Refactor UdevAdapter to remove udev monitor fallback and update PKGBUILD for consistent naming 2026-05-11 22:48:49 +08:00
Breadway
f05d6ba602 Update README and add documentation and examples for Bread automation 2026-05-11 21:54:43 +08:00
Breadway
45d5979252 Remove markdown 2026-05-11 20:58:31 +08:00
Breadway
61000d8811 Merge branch 'dev' of https://github.com/Breadway/bread into dev 2026-05-11 20:56:34 +08:00
Breadway
e5611567c2 Begin Implementing V2 features 2026-05-11 20:56:10 +08:00
Breadway
28df873b92
Merge pull request #2 from Breadway/dev
Dev
2026-05-11 20:30:59 +08:00
Breadway
f5cf547875
Merge branch 'master' into dev 2026-05-11 20:30:48 +08:00
Breadway
f0ef411697 Enhance installation process, update service paths, and improve device classification 2026-05-11 18:39:39 +08:00
Breadway
e339660084 Refactor subscription table logic and enhance Lua logging and debounce functionality 2026-05-11 16:57:22 +08:00
Breadway
7c29befc0d Enhance timestamp formatting and add reload watcher functionality 2026-05-11 16:40:49 +08:00
Breadway
65f81db562 unsure 2026-05-11 16:30:05 +08:00
Breadway
8de31cd1fc
Merge pull request #1 from Breadway/dev
Add lua runtime
2026-05-11 16:29:38 +08:00
Breadway
16f3765b65 Add lua runtime 2026-05-11 16:03:05 +08:00
Breadway
3614f628ae Merge branch 'master' of https://github.com/Breadway/bread 2026-05-11 15:50:36 +08:00
Breadway
bde30928a0
Update clone command to include .git extension 2026-05-11 12:28:49 +08:00
47 changed files with 12519 additions and 1256 deletions

42
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,42 @@
name: CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build-and-test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
rust: [stable]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@v1
with:
toolchain: ${{ matrix.rust }}
- name: Cargo cache
uses: Swatinem/rust-cache@v2
with:
workspaces: |
. -> target
- name: Build
run: cargo build --workspace --verbose
- name: Run tests
run: cargo test --workspace --verbose
- name: Build release
run: cargo build --workspace --release
- name: Package artifacts
run: |
mkdir -p dist
tar -czf dist/bread-${{ matrix.os }}.tgz target/release/breadd target/release/bread-cli
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: bread-${{ matrix.os }}
path: dist/*.tgz

37
.gitignore vendored
View file

@ -1,4 +1,39 @@
# Rust build artifacts
target/ target/
# Editor and IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# OS artifacts
.DS_Store
Thumbs.db
desktop.ini
# Environment and secrets
.env
.env.*
*.env
*.pem
*.key
*.p12
secrets/
# Log files
*.log
logs/
# Runtime files
*.sock
*.pid
# Internal project docs and spec files kept out of public history
Overview.md Overview.md
DAEMON.md DAEMON.md
.github/ LUA_RUNTIME.md
CLAUDE_SPEC.md
.claude
CLAUDE.md

1030
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,8 @@
members = [ members = [
"bread-shared", "bread-shared",
"breadd", "breadd",
"bread-cli" "bread-cli",
"bread-sync",
] ]
resolver = "2" resolver = "2"
@ -13,3 +14,8 @@ tokio = { version = "1.40", features = ["full"] }
anyhow = "1.0" anyhow = "1.0"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
git2 = "0.18"
dirs = "5.0"
chrono = { version = "0.4", features = ["serde"] }
tempfile = "3"
glob = "0.3"

927
Documentation.md Normal file
View file

@ -0,0 +1,927 @@
# Bread Documentation
## Contents
- [Overview](#overview)
- [Getting started](#getting-started)
- [Your first module](#your-first-module)
- [Run, reload, and watch](#run-reload-and-watch)
- [Modules: install and manage](#modules-install-and-manage)
- [Sync: snapshot and restore](#sync-snapshot-and-restore)
- [Debugging tips](#debugging-tips)
- [Dictionary: Lua API](#dictionary-lua-api)
- [Bluetooth](#bluetooth)
- [Dictionary: Built-in modules](#dictionary-built-in-modules)
- [Dictionary: Event reference](#dictionary-event-reference)
- [Dictionary: Runtime state schema](#dictionary-runtime-state-schema)
- [Dictionary: IPC protocol](#dictionary-ipc-protocol)
## Overview
Bread is a reactive automation fabric for Linux desktops. The daemon (`breadd`) normalizes external signals into semantic events, maintains runtime state, and dispatches events to Lua modules that implement automation.
- **Daemon** (`breadd`) — long-running Rust process; source of truth for runtime state
- **Lua runtime** — dedicated thread inside the daemon; automation logic lives here
- **CLI** (`bread`) — talks to the daemon over a Unix socket
Adapters currently supported: Hyprland compositor IPC, Linux udev/netlink, UPower/sysfs power, rtnetlink/sysfs network, and BlueZ Bluetooth.
If you are new to Bread, start with the quick walkthrough below, then jump to the full dictionary when you need exact API details.
## Getting started
### 1) Create a minimal config
- Daemon config: `~/.config/bread/breadd.toml` (all values optional)
- Lua entry point: `~/.config/bread/init.lua`
- Lua modules: `~/.config/bread/modules/`
### 2) Minimal `init.lua`
```lua
bread.on("bread.system.startup", function(event)
bread.profile.activate("default")
bread.log("bread started on " .. bread.machine.name())
end)
```
### 3) Start the daemon
```bash
systemctl --user start breadd
# Or directly:
breadd
```
### 4) Check that it's running
```bash
bread ping
bread doctor
```
## Your first module
Create a file at `~/.config/bread/modules/hello.lua`. It is discovered and loaded automatically after `init.lua`.
```lua
local M = bread.module({ name = "hello", version = "0.1.0" })
function M.on_load()
bread.log("hello from bread on " .. bread.machine.name())
bread.on("bread.device.*", function(event)
bread.log("device event: " .. event.event)
end)
end
return M
```
Key rules:
- Every module must call `bread.module` exactly once at the top level.
- Register subscriptions inside `M.on_load` so they are cleaned up properly on hot reload.
- Use `bread.log` early to verify handlers are firing.
## Run, reload, and watch
```bash
# Hot-reload the Lua runtime after editing config
bread reload
# Watch for file changes and reload automatically
bread reload --watch
```
If any module fails to load, `bread reload` prints the error with a full Lua stack trace. The daemon stays running — fix the file and reload again.
## Modules: install and manage
Modules are Lua packages installed to `~/.config/bread/modules/`. The CLI manages the install lifecycle.
```bash
# Install from GitHub (downloads and extracts the default branch tarball)
bread modules install github:someuser/bread-wifi
# Install from a local directory
bread modules install ~/src/my-module
# Install a specific ref
bread modules install github:someuser/bread-wifi@v1.2.0
# List installed modules and their daemon status
bread modules list
# Show full manifest for one module
bread modules info bread-wifi
# Re-install all GitHub-sourced modules (pick up upstream changes)
bread modules update
# Remove a module
bread modules remove bread-wifi
bread modules remove bread-wifi --yes # skip confirmation
```
Each installed module has a `bread.module.toml` manifest:
```toml
name = "wifi"
version = "1.0.0"
description = "WiFi management for Bread"
author = "someuser"
source = "github:someuser/bread-wifi"
installed_at = "2026-01-01T00:00:00Z"
```
## Sync: snapshot and restore
Bread sync snapshots your config, dotfiles, and installed packages into a local Git repository. Use `export`/`import` to move state between machines — no git remote required.
```bash
# First-time setup (remote optional)
bread sync init
bread sync init --remote git@github.com:you/bread-config.git
# Commit local snapshot
bread sync push
bread sync push --message "before reinstall"
# Apply snapshot to this machine
bread sync pull
# Also reinstall packages from snapshot
bread sync pull --install-packages
# See what has changed
bread sync status
bread sync diff
# List known machines
bread sync machines
```
### Portable export/import
`export` creates a self-contained snapshot directory or `.tar.gz` — no git auth needed.
```bash
# Create a portable snapshot (defaults to ./bread-export-<machine>-<date>.tar.gz)
bread sync export
# Export to a specific path
bread sync export --output ~/backups/bread.tar.gz
bread sync export --output /mnt/usb/bread-snapshot/ # directory
# Apply a snapshot on another machine
bread sync import bread-export-hermes-2026-05-16.tar.gz
bread sync import /mnt/usb/bread-snapshot/
# Also install packages from the snapshot
bread sync import bread-export.tar.gz --install-packages
# Skip cloning git repos back to their original locations
bread sync import bread-export.tar.gz --no-clone-repos
# Skip confirmation prompt
bread sync import bread-export.tar.gz --yes
```
Each export snapshot includes:
| Directory | Contents |
|-----------|----------|
| `bread/` | `~/.config/bread/` (your Bread config) |
| `configs/` | Common app configs (hypr, nvim, kitty, waybar, fish, etc.) |
| `dotfiles/` | Individual files: `.gitconfig`, `.zshrc`, `.zprofile`, `.zshenv`, SSH config, etc. |
| `local-bin/` | `~/.local/bin/` scripts (non-symlink, <512 KB) |
| `local-fonts/` | `~/.local/share/fonts/` |
| `systemd/` | `~/.config/systemd/user/` units |
| `system/` | System files: udev rules, modprobe, sysctl, NetworkManager, bluetooth (root-only paths skipped unless run with sudo) |
| `packages/` | Package lists (pacman.txt, pip.txt, cargo.txt, npm.txt) |
| `machines/` | Per-machine profile with tags and last-sync time |
| `manifest.toml` | Path map for exact restoration on import |
| `restore.sh` | Shell script for manual restore (system files shown as sudo commands) |
**Git repository tracking:** at export time, all git repositories with remotes found under `~`, `~/Projects`, `~/Documents`, and `~/.config` are auto-committed and pushed. Their remote URLs and branches are recorded in the manifest. On import, `--no-clone-repos` suppresses cloning them back.
Configure sync in `~/.config/bread/sync.toml`:
```toml
[remote]
url = "git@github.com:you/bread-config.git"
branch = "main"
[machine]
name = "hermes"
tags = ["laptop", "battery"]
[packages]
enabled = true
managers = ["pacman", "pip", "cargo"]
[delegates]
include = ["~/.config/nvim", "~/.config/waybar"]
exclude = ["**/.git", "**/*.cache"]
```
## Debugging tips
- Run `bread events` to see live normalized events.
- Run `bread state` to see full runtime state as JSON.
- Run `bread doctor` to check adapter and module health.
- Log event payloads with `bread.log(tostring(event.data))`.
- Use `RUST_LOG=debug breadd` for verbose daemon output.
---
## Dictionary: Lua API
Every API is exposed through the `bread` global table.
### Module declaration
Every module must call `bread.module` exactly once at the top level.
```lua
local M = bread.module({
name = "my.module",
version = "0.1.0",
after = { "bread.devices" }, -- optional: load after this module
})
return M
```
If a module does not call `bread.module`, it fails to load and is marked as a load error.
### Events
#### `bread.on(pattern, fn) -> id`
Subscribe to matching events. Returns a numeric subscription ID.
```lua
local id = bread.on("bread.device.*", function(event)
-- event.event → the full event name string
-- event.data → table of event-specific fields
-- event.source → adapter that produced it ("Udev", "Hyprland", etc.)
bread.log(event.event)
end)
```
#### `bread.once(pattern, fn) -> id`
Subscribe once. The handler is removed after the first match.
#### `bread.filter(pattern, fn, opts) -> id`
Subscribe with a predicate. `opts` must contain a `filter` function:
```lua
bread.filter("bread.device.*", function(event)
bread.exec("xset r rate 200 40")
end, {
filter = function(event)
return event.data and event.data.class == "keyboard"
end,
})
```
#### `bread.off(id)`
Unsubscribe an event handler or state watch by ID.
#### `bread.emit(event, data)`
Emit a custom event into the system pipeline. Useful for cross-module communication.
#### `bread.wait(pattern, opts) -> event | nil`
Coroutine-only helper that suspends until a matching event arrives.
```lua
bread.spawn(function()
local event = bread.wait("bread.device.dock.connected", { timeout = 5000 })
if event then
bread.log("dock arrived")
end
end)
```
#### `bread.spawn(fn)`
Spawn a coroutine and surface errors if it fails. Required for using `bread.wait`.
### State
#### `bread.state.get(path)`
Read a state subtree by dotted path.
```lua
local monitors = bread.state.get("monitors")
local online = bread.state.get("network.online")
```
#### Typed shorthands
```lua
bread.state.monitors()
bread.state.active_workspace()
bread.state.active_window()
bread.state.devices()
bread.state.power()
bread.state.network()
bread.state.profile()
```
#### `bread.state.watch(path, fn) -> id`
Watch a state path for changes. The callback receives `(new_value, old_value)`.
```lua
bread.state.watch("power.ac_connected", function(new_val, old_val)
if new_val then
bread.notify("AC connected")
end
end)
```
### Profiles
#### `bread.profile.activate(name)`
Activate a named profile. Emits `bread.profile.activated` over IPC.
### Execution
#### `bread.exec(cmd)`
Run a shell command. Fire-and-forget (async, does not block Lua).
### Notifications
#### `bread.notify(message, opts)`
Send a desktop notification via `notify-send`.
Options:
| Key | Type | Default |
|-----|------|---------|
| `title` | string | `"bread"` |
| `urgency` | string | from config |
| `timeout` | ms | from config |
| `icon` | string | none |
Calling `bread.notify` emits `bread.notify.sent` with `{ title, message, urgency }`.
### Timers
#### `bread.after(delay_ms, fn) -> id`
Run once after a delay.
#### `bread.every(interval_ms, fn) -> id`
Run on a repeating interval.
#### `bread.cancel(id)`
Cancel a timer created by `after` or `every`. Timers are also cancelled automatically on reload.
### Utilities
#### `bread.debounce(delay_ms, fn) -> wrapped_fn`
Returns a wrapper that fires only after `delay_ms` of quiet time.
```lua
local fn = bread.debounce(200, function(event)
reconfigure_monitors()
end)
bread.on("bread.monitor.**", fn)
```
#### `bread.log(msg)` / `bread.warn(msg)` / `bread.error(msg)`
Logging helpers. Accept any Lua value (coerced via `tostring`).
### Machine and filesystem
#### `bread.machine.name() -> string`
Returns the machine name from `sync.toml`. Falls back to the system hostname if sync is not initialized.
#### `bread.machine.tags() -> string[]`
Returns the tags array from `sync.toml`, or `{}` if sync is not initialized.
#### `bread.machine.has_tag(tag) -> bool`
Returns true if the machine has the given tag.
#### `bread.fs.write(path, content)`
Write a file. Creates parent directories as needed. `~` is expanded.
#### `bread.fs.read(path) -> string | nil`
Read a file. Returns `nil` if the file does not exist. `~` is expanded.
#### `bread.fs.exists(path) -> bool`
Returns true if the path exists. `~` is expanded.
#### `bread.fs.expand(path) -> string`
Expand `~` to the home directory.
### Hyprland
The `bread.hyprland` namespace provides compositor bindings.
```lua
-- Dispatch a Hyprland command
bread.hyprland.dispatch("workspace", "2")
bread.hyprland.dispatch("exec", "kitty")
-- Set a keyword
bread.hyprland.keyword("monitor", "HDMI-A-1, 2560x1440, 0x0, 1")
-- Query compositor state (returns deserialized Lua tables)
local win = bread.hyprland.active_window()
local monitors = bread.hyprland.monitors()
local workspaces = bread.hyprland.workspaces()
local clients = bread.hyprland.clients()
-- Subscribe to raw Hyprland events (bypasses normalization)
bread.hyprland.on_raw("activewindow", function(raw)
-- raw payload includes: kind, raw (original string), data
end)
```
### Bluetooth
The `bread.bluetooth` namespace provides control over the local Bluetooth adapter and its paired devices via BlueZ D-Bus. All functions degrade gracefully when BlueZ is unavailable — control functions log a warning and return `nil`, query functions return `nil`.
#### `bread.bluetooth.power(enabled)`
Power the Bluetooth adapter on (`true`) or off (`false`). Fire-and-forget.
#### `bread.bluetooth.powered() -> bool | nil`
Returns the current power state of the adapter, or `nil` if unavailable.
```lua
if bread.bluetooth.powered() then
bread.log("Bluetooth is on")
end
```
#### `bread.bluetooth.connect(address)`
Connect to a paired device by MAC address. Fire-and-forget — the result is delivered as a `bread.device.connected` event when the connection succeeds.
```lua
bread.bluetooth.connect("AA:BB:CC:DD:EE:FF")
```
#### `bread.bluetooth.disconnect(address)`
Disconnect from a device by MAC address. Fire-and-forget — delivered as `bread.device.disconnected`.
#### `bread.bluetooth.scan(enabled)`
Start (`true`) or stop (`false`) device discovery.
#### `bread.bluetooth.devices() -> table | nil`
Returns all devices known to BlueZ as an array of tables. Returns `nil` if BlueZ is unavailable.
```lua
local devs = bread.bluetooth.devices()
if devs then
for _, dev in ipairs(devs) do
bread.log(dev.name .. " " .. dev.address
.. (dev.connected and " [connected]" or ""))
end
end
```
Each device table:
| Field | Type | Description |
|-------|------|-------------|
| `address` | string | Bluetooth MAC address, e.g. `"AA:BB:CC:DD:EE:FF"` |
| `name` | string | Device name from BlueZ (Alias or Name property) |
| `connected` | bool | Whether the device is currently connected |
| `paired` | bool | Whether the device is paired |
#### Example: auto-connect headphones on AC power
```lua
local M = bread.module({ name = "headphones", version = "1.0.0" })
local HEADPHONES = "AA:BB:CC:DD:EE:FF"
function M.on_load()
bread.state.watch("power.ac_connected", function(ac)
if ac then
bread.bluetooth.power(true)
bread.bluetooth.connect(HEADPHONES)
end
end)
end
return M
```
#### Example: turn off Bluetooth on battery
```lua
bread.state.watch("power.ac_connected", function(ac)
bread.bluetooth.power(ac)
end)
```
### Module lifecycle hooks
All hooks are optional.
```lua
function M.on_load()
-- Called after the module loads. Register subscriptions here.
end
function M.on_reload()
-- Called after a hot reload completes across all modules.
end
function M.on_unload()
-- Called before the Lua instance is dropped.
end
function M.on_error(err)
-- Called when a subscription handler in this module throws.
-- Return true to keep the subscription alive, false to cancel it.
return true
end
```
### Module storage
Survives hot reload; does not survive daemon restart.
```lua
M.store.set("last_profile", "docked")
local value = M.store.get("last_profile")
```
Storage is scoped per module and is not shared across modules.
---
## Dictionary: Built-in modules
Built-ins are loaded before user modules. Disable them via `[modules].disable` in the daemon config.
### `bread.monitors`
High-level declarative monitor event handlers.
```lua
local monitors = require("bread.monitors")
monitors.layout("dock", function()
bread.exec("~/.config/bread/scripts/layout-dock.sh")
end)
monitors.on({
when = "connected",
monitors = { "HDMI-A-1" },
run = monitors.apply("dock"),
})
```
| Function | Description |
|----------|-------------|
| `M.on(opts)` | Register a monitor workflow. `opts`: `when`, `monitors` (optional list), `run` (function or shell string) |
| `M.layout(name, fn)` | Register a named layout function |
| `M.apply(name) -> fn` | Returns a function that calls the named layout |
`when` is one of `connected`, `disconnected`, `changed`.
### `bread.devices`
Device connection rules with name-based matching. This module handles hardware hotplug events from USB devices, monitors, and other peripherals.
Device names are defined in `~/.config/bread/devices.lua` — the daemon resolves the name before dispatching events, so modules can match on stable user-defined names rather than raw hardware identifiers.
```lua
local devices = require("bread.devices")
devices.on({
when = "connected",
device = "keyboard",
run = function(event)
bread.exec("xset r rate 200 40")
end,
})
devices.on({
when = "connected",
device = "dock",
run = "~/.config/bread/scripts/dock-connected.sh"
})
devices.on({
when = "disconnected",
name = "CalDigit", -- pattern-matched against event.data.name
run = function(event)
bread.log("Dock disconnected: " .. event.data.name)
end,
})
```
#### Functions
| Function | Description |
|----------|-------------|
| `M.on(opts)` | Register a device rule. See options below. |
#### Device rule options
```lua
devices.on({
when = "connected", -- required: "connected" or "disconnected"
device = "keyboard", -- optional: device name from devices.lua
name = "Keychron", -- optional: substring matched against device name
run = function(event) ... end -- required: function or shell string
})
```
- `when` (required): One of `connected` or `disconnected`.
- `device` (optional): Device name as defined in `devices.lua`. If specified, the rule only fires for devices with that name.
- `name` (optional): Pattern that must be found in `event.data.name` (case-insensitive substring). Can be combined with `device` (both must match).
- `run` (required): Function or shell string to run when the rule matches.
The callback receives the full device event:
```lua
{
event = "bread.device.dock.connected",
data = {
id = "/sys/...",
device = "dock", -- name resolved from devices.lua
name = "CalDigit TS4", -- raw device name from udev
subsystem = "usb",
vendor_id = "0x35f5",
product_id = "0x0104",
raw = { ... } -- full udev properties
}
}
```
#### Example: Keyboard configuration on connect
```lua
devices.on({
when = "connected",
device = "keyboard",
run = function(event)
bread.log("Keyboard connected: " .. event.data.name)
bread.exec("xset r rate 200 40")
end,
})
```
#### Example: Dock-specific setup
```lua
-- devices.lua defines: { device = "dock", vendor_id = "35f5" }
devices.on({
when = "connected",
device = "dock",
run = function(event)
bread.log("Dock connected")
bread.exec("~/.config/bread/scripts/dock-connected.sh")
end,
})
devices.on({
when = "disconnected",
device = "dock",
run = function(event)
bread.log("Dock disconnected")
bread.exec("~/.config/bread/scripts/dock-disconnected.sh")
end,
})
```
### `bread.workspaces`
Workspace-to-monitor assignment and app pinning.
```lua
local workspaces = require("bread.workspaces")
workspaces.assign("1", "HDMI-A-1")
workspaces.pin({ app = "Firefox", workspace = "2" })
```
| Function | Description |
|----------|-------------|
| `M.assign(workspace, monitor)` | Assign a workspace to a monitor |
| `M.pin(opts)` | Pin an app class to a workspace. `opts`: `app`, `workspace` |
| `M.apply_assignments()` | Apply all registered assignments via Hyprland dispatch |
### `bread.binds`
Runtime keybind management via Hyprland.
```lua
local binds = require("bread.binds")
binds.add({
mods = { "SUPER" },
key = "Return",
dispatch = "exec",
args = "kitty",
})
```
| Function | Description |
|----------|-------------|
| `M.add(opts)` | Add a keybind. `opts`: `mods`, `key`, `dispatch`, `args` |
| `M.remove(key)` | Remove a keybind by key |
| `M.replace(key, opts)` | Remove and re-add a keybind |
---
## Dictionary: Event reference
Events are delivered as a `BreadEvent`:
```json
{
"event": "bread.device.dock.connected",
"timestamp": 1710000000000,
"source": "Udev",
"data": {}
}
```
### Pattern matching
| Pattern | Matches |
|---------|---------|
| `bread.device.dock.connected` | Exact match only |
| `bread.device.*` | One segment wildcard (does not cross `.`) |
| `bread.device.**` | Any depth under `bread.device` |
| `bread.monitor.?` | Single character within one segment |
### Normalized events
#### System
| Event | Data |
|-------|------|
| `bread.system.startup` | `{}` |
#### Devices (udev / Bluetooth)
| Event | Data |
|-------|------|
| `bread.device.connected` | `{ id, device, name, vendor, vendor_id, product_id, subsystem, raw }` |
| `bread.device.disconnected` | same |
| `bread.device.<device>.connected` | `{ id, device }` |
| `bread.device.<device>.disconnected` | `{ id, device }` |
`device` is the name resolved from `~/.config/bread/devices.lua`. Devices that match no rule use `"unknown"`. The generic `bread.device.connected` event carries the full payload including `raw` udev properties; the named companion event carries only `id` and `device`.
Both USB/udev devices and Bluetooth devices emit `bread.device.connected` / `bread.device.disconnected`. They can be distinguished by `event.data.subsystem`:
| `subsystem` | Source | Unique identifier field |
|-------------|--------|------------------------|
| `"usb"`, `"input"`, etc. | udev | `vendor_id` + `product_id` |
| `"bluetooth"` | BlueZ | `address` (MAC address) |
#### Bluetooth (BlueZ)
| Event | Data |
|-------|------|
| `bread.device.connected` | `{ id, device, name, address, subsystem: "bluetooth", raw }` |
| `bread.device.disconnected` | same |
| `bread.bluetooth.device.paired` | `{ id, name, address, subsystem: "bluetooth", raw }` |
| `bread.bluetooth.device.unpaired` | `{ id, address, subsystem: "bluetooth", raw }` |
`bread.bluetooth.device.paired` fires when BlueZ first learns about a device (new pairing or adapter restart). It does not mean the device is connected. `bread.device.connected` fires when the device profile actually connects.
`name` may be `"unknown"` on `bread.device.connected` events emitted from `PropertiesChanged` signals, since BlueZ only includes changed properties. It is always populated on `bread.bluetooth.device.paired` and on events from the initial enumeration at startup.
#### Hyprland
| Event | Data |
|-------|------|
| `bread.workspace.changed` | raw payload |
| `bread.workspace.created` | `{ workspace }` |
| `bread.workspace.destroyed` | `{ workspace }` |
| `bread.monitor.connected` | raw payload |
| `bread.monitor.disconnected` | raw payload |
| `bread.window.focus.changed` | raw payload |
| `bread.window.focused` | `{ address }` |
| `bread.window.opened` | `{ address, workspace, class, title }` |
| `bread.window.closed` | `{ address }` |
| `bread.window.moved` | `{ address, workspace }` |
| `bread.hyprland.event` | `{ kind, raw, data }` (unhandled kinds) |
#### Power
| Event | Data |
|-------|------|
| `bread.power.ac.connected` | `{ ac_connected, battery_percent }` |
| `bread.power.ac.disconnected` | `{ ac_connected, battery_percent }` |
| `bread.power.battery.low` | `{ battery_percent }` |
| `bread.power.battery.very_low` | `{ battery_percent }` |
| `bread.power.battery.critical` | `{ battery_percent }` |
| `bread.power.battery.full` | `{ battery_percent }` |
| `bread.power.changed` | `{ ac_connected, battery_percent }` |
#### Network
| Event | Data |
|-------|------|
| `bread.network.connected` | `{ online, interfaces }` |
| `bread.network.disconnected` | `{ online, interfaces }` |
#### System events
| Event | Data |
|-------|------|
| `bread.profile.activated` | `{ name }` |
| `bread.notify.sent` | `{ title, message, urgency }` |
| `bread.state.changed.<path>` | emitted by state watches |
---
## Dictionary: Runtime state schema
`bread state` and `bread.state.get("")` return the full `RuntimeState`:
```json
{
"monitors": [
{ "name": "HDMI-A-1", "connected": true, "resolution": null, "position": null }
],
"workspaces": [
{ "id": "1", "monitor": "HDMI-A-1" }
],
"active_workspace": "1",
"active_window": "0x...",
"devices": {
"connected": [
{
"id": "/sys/...",
"name": "CalDigit TS4",
"device": "dock",
"subsystem": "usb",
"vendor_id": "0x35f5",
"product_id": "0x0104"
}
]
},
"network": {
"interfaces": { "eth0": { "up": true } },
"online": true
},
"power": {
"ac_connected": true,
"battery_percent": 87,
"battery_low": false
},
"profile": {
"active": "default",
"history": [],
"profiles": {}
},
"modules": [
{
"name": "bread.monitors",
"status": "loaded",
"last_error": null,
"builtin": true,
"store": {}
}
]
}
```
`status` values: `loaded`, `load_error`, `not_found`, `degraded`, `disabled`.
---
## Dictionary: IPC protocol
The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. Messages are newline-delimited JSON.
Request:
```json
{ "id": "1", "method": "state.get", "params": { "key": "monitors" } }
```
Response:
```json
{ "id": "1", "result": [ { "name": "HDMI-A-1", "connected": true } ] }
```
Available methods:
| Method | Params | Description |
|--------|--------|-------------|
| `ping` | — | Connectivity check |
| `health` | — | Version, uptime, PID, adapter status |
| `state.get` | `key` (dotted path) | Read a value from `RuntimeState` |
| `state.dump` | — | Return the full `RuntimeState` as JSON |
| `modules.list` | — | List all loaded modules and their status |
| `modules.reload` | — | Hot-reload the Lua runtime |
| `profile.list` | — | List defined profiles |
| `profile.activate` | `name` | Switch active profile |
| `events.subscribe` | — | Upgrade to streaming mode; pushes events line by line |
| `events.replay` | `since_ms` | Replay buffered events from the last N ms |
| `emit` | `event`, `data` | Inject a synthetic event into the pipeline |
| `sync.status` | — | Return sync init state: `{ initialized, machine?, remote? }` |

187
Examples.md Normal file
View file

@ -0,0 +1,187 @@
# Bread Examples
These examples show how to translate existing Hyprland automation into Bread's event-driven Lua runtime.
Each snippet is designed to be drop-in friendly for a `~/.config/bread/modules/*.lua` file. Start with a new module file and `require` it from `~/.config/bread/init.lua`.
## Example 1: Porting keyboard_and_display_watcher.sh (system script)
Source inspiration: `~/.config/hypr/scripts/system/keyboard_and_display_watcher.sh`.
This example covers two parts that port cleanly to Bread:
- Start/stop the Redox layout viewer when the keyboard appears
- Start/stop a display sync service when an external monitor appears
```lua
-- ~/.config/bread/modules/redox_and_display.lua
local M = bread.module({ name = "redox_and_display", version = "1.0.0" })
local PREVIEW_CMD = "/home/breadway/redox-layout-viewer/target/release/redox-layout-viewer"
local APP_NAME = "redox-layout-vi"
local function start_viewer()
bread.exec("pgrep -f '" .. APP_NAME .. "' >/dev/null || " .. PREVIEW_CMD .. " >/dev/null 2>&1 &")
end
local function stop_viewer()
bread.exec("pkill -f '" .. APP_NAME .. "' >/dev/null 2>&1 || true")
end
local function is_redox(event)
-- Inspect event.data.raw once to find stable identifiers in your environment.
-- Typical udev fields include id_vendor, id_model, id_vendor_id, id_model_id, and name.
local raw = event.data and event.data.raw or {}
local name = tostring(raw.name or "")
local vendor = tostring(raw.id_vendor or "")
local model = tostring(raw.id_model or "")
return name:lower():find("redox", 1, true)
or vendor:lower():find("redox", 1, true)
or model:lower():find("redox", 1, true)
end
local external_monitors = 0
local function update_display_service()
if external_monitors > 0 then
bread.exec("systemctl --user start hypr-display-sync.service")
else
bread.exec("systemctl --user stop hypr-display-sync.service")
end
end
function M.on_load()
bread.on("bread.device.keyboard.connected", function(event)
if is_redox(event) then
start_viewer()
end
end)
bread.on("bread.device.keyboard.disconnected", function(event)
if is_redox(event) then
stop_viewer()
end
end)
bread.on("bread.monitor.connected", function(event)
local name = event.data and (event.data.name or event.data.raw) or ""
-- ignore internal panel (eDP-1) and count only externals
if not tostring(name):match("eDP%-1") then
external_monitors = external_monitors + 1
update_display_service()
end
end)
bread.on("bread.monitor.disconnected", function(event)
local name = event.data and (event.data.name or event.data.raw) or ""
if not tostring(name):match("eDP%-1") then
external_monitors = math.max(0, external_monitors - 1)
update_display_service()
end
end)
end
return M
```
Notes:
- Use `bread.log(event.data.raw)` once to see your exact udev fields for matching.
- This drops polling and relies on udev/Hyprland events.
## Example 2: Porting autostart.lua
Source inspiration: `~/.config/hypr/scripts/system/autostart.lua`.
```lua
-- ~/.config/bread/modules/autostart.lua
local M = bread.module({ name = "autostart", version = "1.0.0" })
local home = os.getenv("HOME") or "/home/breadway"
local startup_commands = {
"wal -R",
home .. "/colorshell/build/colorshell",
"awww-daemon",
"awww restore",
home .. "/.config/hypr/scripts/system/keyboard_and_display_watcher.sh",
home .. "/.config/hypr/watch_hypr_scripts.sh",
"systemctl --user daemon-reload",
"systemctl --user start hypr-display-sync.service",
"systemctl --user start hyprpolkitagent",
"dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP",
"/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1",
"flatpak run dev.deedles.Trayscale",
"wificonf init",
"pkill -f hyprpaper",
}
function M.on_load()
bread.once("bread.system.startup", function()
for _, cmd in ipairs(startup_commands) do
bread.exec(cmd)
end
end)
end
return M
```
## Example 3: Porting display/monitors.lua
Source inspiration: `~/.config/hypr/scripts/display/monitors.lua`.
This uses Bread events and Hyprland keywords to update monitor layout when external displays change.
```lua
-- ~/.config/bread/modules/monitors.lua
local M = bread.module({ name = "monitors", version = "1.0.0" })
local function apply_internal_mode(has_external)
local mode = has_external and "1920x1080@60" or "1920x1200@60"
bread.hyprland.keyword("monitor", "eDP-1, " .. mode .. ", 0x0, 1")
end
local function apply_external()
bread.hyprland.keyword("monitor", "DP-3, 1920x1080@60, auto, 1, mirror, eDP-1")
end
local externals = 0
local function update()
apply_internal_mode(externals > 0)
if externals > 0 then
apply_external()
end
end
function M.on_load()
bread.on("bread.monitor.connected", function(event)
local name = tostring((event.data and (event.data.name or event.data.raw)) or "")
if not name:match("eDP%-1") then
externals = externals + 1
update()
end
end)
bread.on("bread.monitor.disconnected", function(event)
local name = tostring((event.data and (event.data.name or event.data.raw)) or "")
if not name:match("eDP%-1") then
externals = math.max(0, externals - 1)
update()
end
end)
bread.once("bread.system.startup", function()
update()
end)
end
return M
```
## Tips for porting your own scripts
- Start by logging the event payload: `bread.log(event.data.raw)`
- Replace polling loops with event subscriptions
- Use `bread.exec` for shell commands and systemd operations
- Use `bread.state.watch` for data that already lives in the runtime state

View file

@ -1,527 +0,0 @@
# bread — Lua Runtime Architecture
### The Bread Scripting and Automation Layer
---
## Overview
The Lua runtime is the automation half of Bread. Where `breadd` maintains truth about the desktop, the Lua layer decides what to do about it.
Modules written in Lua subscribe to events, read state, execute shell commands, and activate profiles. The entire scripting surface is exposed through a single `bread.*` global API — stable, versioned, and designed to be hostile to accidents.
The runtime lives on a dedicated OS thread inside `breadd`. It is reachable from the async side only through a bounded message channel. Lua never touches sockets, sysfs, or compositor IPC directly. Everything flows through the daemon.
---
## Phase 1 — Runtime Core
These capabilities exist in the codebase today. Phase 1 is the foundation the Lua runtime is built on.
### Daemon Stability
`breadd` is a single long-running Rust process. It survives compositor restarts, module load errors, and Lua runtime panics. The daemon never terminates because a Lua file has a syntax error.
The Lua runtime thread is spawned once at startup:
```rust
std::thread::Builder::new()
.name("breadd-lua".to_string())
.spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to create lua runtime thread");
rt.block_on(async move {
let mut engine = LuaEngine::new(config, state_handle, emit_tx)?;
engine.reload_internal()?;
while let Some(msg) = rx.recv().await {
match msg { /* ... */ }
}
});
})?;
```
If the initial module load fails, the daemon enters degraded mode: no Lua handlers are active, but the daemon itself remains alive. IPC stays responsive and `bread reload` can be used to recover after the user fixes their config.
### Event Ingestion
Every signal that enters `breadd` from an external system flows through a strict pipeline before it reaches Lua:
```
External System (Hyprland / udev / power / network)
Adapter — raw ingestion, owns the connection
│ RawEvent
Normalizer — semantic interpretation
│ BreadEvent
State Engine — state update + fan-out
├──► RuntimeState (updated atomically)
└──► Subscription Dispatcher
│ BreadEvent (per subscriber)
Lua Runtime
(module handlers)
```
Raw events never reach Lua directly. Lua never observes a `RawEvent` — it only ever sees a normalized `BreadEvent` with a stable namespace string like `bread.device.dock.connected`.
### Subscriptions
Modules subscribe to events by pattern. The subscription table maps pattern strings to `(SubscriptionId, is_once)` pairs. The state engine evaluates each incoming `BreadEvent` against the table and dispatches to every matching subscriber.
```rust
pub struct SubscriptionId(pub u64);
```
The Lua side registers subscriptions via `bread.on` and `bread.once`. Each call allocates a monotonically increasing `SubscriptionId`, stores the callback in the Lua registry, and registers the pattern with the state engine:
```lua
bread.on("bread.device.dock.*", function(event)
bread.exec("~/.config/bread/scripts/dock.sh")
end)
bread.once("bread.system.startup", function(event)
bread.profile.activate("default")
end)
```
`bread.once` subscriptions are automatically cancelled after first delivery. The handler is removed from both the Lua registry and the subscription table.
### IPC
`breadd` exposes a Unix domain socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. The protocol is newline-delimited JSON. All IPC requests that affect the Lua runtime route through the `LuaMessage` channel — IPC never touches the Lua thread directly.
Relevant IPC methods:
| Method | Description |
|--------|-------------|
| `modules.list` | List loaded modules and their status |
| `modules.reload` | Trigger a hot reload of the Lua layer |
| `emit` | Inject a synthetic `BreadEvent` into the pipeline |
| `state.get` | Read a value from `RuntimeState` by key path |
| `state.dump` | Return the full `RuntimeState` as JSON |
The `emit` method is particularly useful for testing: it allows injecting arbitrary `BreadEvent`s without needing the real hardware event that would normally produce them.
### Hot Reload
Hot reload is a first-class feature. The daemon persists; the Lua layer restarts. No process restart required.
Reload sequence:
```
bread reload (CLI)
IPC: modules.reload
StateEngine: pause event dispatch to Lua
LuaRuntime: receive Reload message
├── cancel all active subscriptions
├── clear handler registry
├── drop Lua instance (all state cleared)
├── create fresh Lua instance
├── re-register bread.* API
├── re-evaluate init.lua and all modules
└── re-register subscriptions with SubscriptionTable
StateEngine: resume event dispatch
IPC: reload complete response
```
If any module fails to load during reload, the reload aborts and the daemon enters degraded mode. There is no rollback — the previous Lua state was dropped before the reload began. This is intentional for V1. A syntax error in a module produces a clear error message from `bread reload`, and the daemon stays alive.
### State Registry
The daemon maintains a live `RuntimeState` behind an `Arc<RwLock<RuntimeState>>`. It is the authoritative record of what is true about the desktop right now.
```rust
pub struct RuntimeState {
pub monitors: Vec<Monitor>,
pub workspaces: Vec<Workspace>,
pub active_workspace: Option<String>,
pub active_window: Option<String>,
pub devices: DeviceTopology,
pub network: NetworkState,
pub power: PowerState,
pub profile: ProfileState,
pub modules: Vec<ModuleStatus>,
}
```
Lua accesses this via `bread.state.get(path)`. The call takes a brief read lock, serializes the requested subtree to JSON, and converts it to a Lua value. Lua never holds the lock — the lock is dropped before control returns to the Lua callback:
```lua
local monitors = bread.state.get("monitors")
local power = bread.state.get("power")
local active = bread.state.get("active_workspace")
```
Dotted paths are supported for nested access:
```lua
local online = bread.state.get("network.online")
```
State is read-only from Lua. Lua cannot write to `RuntimeState` directly — it can only influence state indirectly by activating a profile or emitting an event that the state engine processes.
---
## Phase 2 — Lua Runtime
Phase 2 covers what is not yet built: the features required to make the Lua layer a complete, ergonomic automation platform.
### Module Loader
Currently, `breadd` loads modules by scanning `~/.config/bread/modules/` and executing every `.lua` file in sorted order. There is no concept of module identity, exports, or dependency declarations.
Phase 2 introduces a proper module system:
```
~/.config/bread/
├── init.lua ← entry point; declares module list
└── modules/
├── dock.lua
├── display.lua
├── power.lua
└── lib/
└── utils.lua ← shared library, loaded on require
```
**`bread.module` declaration** — each module declares itself at the top of the file:
```lua
local M = bread.module({
name = "dock",
version = "1.0.0",
after = { "display" }, -- load after display.lua
})
```
The runtime resolves the dependency graph and loads modules in topological order. Circular dependencies are detected at load time and reported as a load error on the offending module.
**`require` support** — modules in `lib/` are loadable via `require`:
```lua
local utils = require("bread.lib.utils")
```
The module loader intercepts `require` calls that begin with `bread.` and resolves them relative to `~/.config/bread/`. Standard Lua `require` semantics apply for everything else.
**Module status tracking** — each module's load state is reflected in `RuntimeState.modules` and visible via `bread doctor` and `modules.list`:
```rust
pub enum ModuleLoadState {
Loaded,
LoadError,
NotFound,
}
```
Phase 2 extends this with `Degraded` (loaded but encountered a runtime error since last reload) and `Disabled` (explicitly disabled in config).
### Lifecycle Hooks
Currently, modules have no way to run code at load time or cleanup code at unload time. Phase 2 adds four lifecycle hooks.
```lua
function M.on_load()
-- called once when the module is first loaded
-- register subscriptions, initialize module state
end
function M.on_reload()
-- called after a hot reload completes
-- re-apply any external side effects the module manages
end
function M.on_unload()
-- called before the Lua instance is dropped
-- cancel external resources, write state if needed
end
function M.on_error(err)
-- called when a subscription handler in this module throws
-- return true to keep the subscription, false to cancel it
end
```
The runtime calls hooks in a defined order:
- **Load**: `on_load` is called after the module file executes successfully, in dependency order.
- **Reload**: `on_unload` is called in reverse dependency order. After the new Lua instance is ready, `on_load` runs on every module. `on_reload` runs after all `on_load` calls complete.
- **Error**: `on_error` is called on the Lua thread immediately after a handler throws. If the module does not define `on_error`, the default behavior is to log the error and keep the subscription alive.
All hooks are optional. A module with no lifecycle hooks continues to work exactly as it does today.
### Event APIs
Phase 2 expands the event surface available to Lua modules.
**Pattern syntax** — the current subscription API matches event names against patterns using glob-style `*` wildcards. Phase 2 adds `**` for recursive matching and `?` for single-character wildcards:
```lua
bread.on("bread.device.*", handler) -- matches bread.device.dock.connected
bread.on("bread.device.**", handler) -- matches any depth under bread.device
bread.on("bread.monitor.?", handler) -- single-segment wildcard
```
**`bread.off`** — cancel a subscription by the ID returned from `bread.on`:
```lua
local id = bread.on("bread.power.*", handler)
-- later:
bread.off(id)
```
**`bread.wait`** — yield until a matching event arrives, with an optional timeout:
```lua
local event = bread.wait("bread.device.dock.connected", { timeout = 5000 })
if event then
-- dock arrived within 5 seconds
end
```
`bread.wait` is syntactic sugar over a `bread.once` subscription combined with a coroutine yield. It can only be used inside a coroutine context; calling it from a top-level module body is a load error.
**`bread.filter`** — attach a predicate to a subscription. The handler is only called when the predicate returns true:
```lua
bread.on("bread.device.*", handler, {
filter = function(event)
return event.data.class == "dock"
end
})
```
**Timers** — schedule callbacks without relying on an external timer process:
```lua
local id = bread.after(500, function()
-- called once, 500ms from now
end)
local id = bread.every(30000, function()
-- called every 30 seconds
end)
bread.cancel(id) -- cancel either kind
```
Timers are cancelled automatically on reload. A module does not need to track its own timer IDs for cleanup.
### State Access
Phase 2 extends `bread.state` from a read-only snapshot query into a richer interface.
**Typed helpers** — convenience wrappers for the most common state subtrees:
```lua
bread.state.monitors() -- Vec<Monitor>
bread.state.active_workspace() -- string | nil
bread.state.active_window() -- string | nil
bread.state.devices() -- Vec<Device>
bread.state.power() -- PowerState
bread.state.network() -- NetworkState
bread.state.profile() -- ProfileState
```
These are thin wrappers over `bread.state.get` — they add no locking overhead.
**Reactive state** — watch a state path for changes and receive a callback when it changes:
```lua
bread.state.watch("power.ac_connected", function(new_val, old_val)
if new_val then
bread.exec("notify-send 'AC connected'")
end
end)
```
State watches are implemented as synthetic subscriptions: the state engine compares the watched path before and after each `RuntimeState` update and synthesizes a `bread.state.changed.<path>` event when a difference is detected. From the Lua runtime's perspective, watches are ordinary subscriptions.
**Module-scoped storage** — a key-value store persisted across reloads (but not across daemon restarts):
```lua
M.store.set("last_profile", "docked")
local p = M.store.get("last_profile") -- "docked"
```
Storage is scoped per module. A module cannot read another module's store. The store is backed by a `HashMap<String, serde_json::Value>` in the `RuntimeState.modules` entry for that module, so it survives hot reload.
### Hyprland Bindings
Phase 2 exposes a `bread.hyprland` namespace for direct interaction with the Hyprland compositor. This is the only place in the Lua API that is compositor-specific; all other APIs are compositor-agnostic.
The bindings communicate over Hyprland's IPC request socket (`$HYPRLAND_INSTANCE_SIGNATURE/.socket.sock`), not the event socket. Calls are dispatched to a Tokio task on the async side and awaited transparently from Lua via coroutine suspension.
**Dispatch**
```lua
bread.hyprland.dispatch("workspace", "2")
bread.hyprland.dispatch("movetoworkspace", "2,address:0x...")
bread.hyprland.dispatch("exec", "kitty")
```
**Keyword**
```lua
local result = bread.hyprland.keyword("monitor", "HDMI-A-1, 2560x1440, 0x0, 1")
```
**Active window**
```lua
local win = bread.hyprland.active_window()
-- { address, title, class, workspace, monitor, ... }
```
**Monitor and workspace queries**
```lua
local monitors = bread.hyprland.monitors()
local workspaces = bread.hyprland.workspaces()
local clients = bread.hyprland.clients()
```
All calls return deserialized Lua tables matching Hyprland's JSON response shape. Errors from the compositor (malformed dispatch, unknown keyword) are surfaced as Lua errors catchable with `pcall`.
**Hyprland-specific events** — the existing `bread.monitor.*` and `bread.workspace.*` event namespaces already cover the most common Hyprland signals. The Phase 2 bindings add lower-level passthrough for events that do not yet have a normalized `BreadEvent` representation:
```lua
bread.hyprland.on_raw("activewindow", function(raw)
-- raw is the unparsed string from Hyprland's event socket
end)
```
Raw subscriptions bypass normalization. They are intended for power users and for features not yet covered by the normalized event namespace. Once a raw event pattern is common enough, it graduates to a stable `BreadEvent` and the raw subscription is deprecated.
---
## Lua ↔ Rust Boundary
All calls across the boundary go through `mlua`'s safe API. Rust functions registered as Lua globals return `mlua::Result` and handle their own error mapping. Panics inside registered Rust functions are caught by mlua and converted to Lua errors — they do not unwind into the Lua thread and they do not crash the daemon.
The `LuaMessage` enum is the only channel between the async Tokio runtime and the Lua thread:
```rust
pub enum LuaMessage {
Event {
subscription_id: SubscriptionId,
event: BreadEvent,
},
SubscriptionCancelled {
id: SubscriptionId,
},
Reload {
reply: oneshot::Sender<Result<(), String>>,
},
Shutdown,
}
```
Lua is not `Send`. The `LuaEngine` and the `Lua` instance live exclusively on the dedicated Lua OS thread. The async side communicates only by sending `LuaMessage` values through the channel — it never holds a reference to anything inside the Lua VM.
---
## Error Isolation
### Handler errors
Lua errors during event handler execution are caught with `pcall` at the Rust boundary:
```rust
fn handle_event(&self, id: SubscriptionId, event: BreadEvent) -> Result<()> {
let callback: Function = self.lua.registry_value(reg)?;
let event_value = self.lua.to_value(&event)?;
if let Err(err) = callback.call::<_, ()>(event_value) {
error!(subscription = id.0, error = %err, "lua callback failed");
}
Ok(())
}
```
The error is logged with the subscription ID and full Lua stack trace. The handler remains registered and will fire again on the next matching event. A persistently failing handler is the module's responsibility to cancel via `bread.off`.
Phase 2's `on_error` hook gives modules a structured way to respond to handler failures rather than relying solely on the daemon log.
### Module load errors
Errors during module load are fatal to that module but not to the daemon or to other modules. The failed module is marked `LoadError` in `RuntimeState.modules`. Remaining modules continue loading in dependency order; only modules that declared `after` the failed module are also skipped (their dependency is broken).
### Degraded mode
If the initial load or a hot reload fails such that no Lua instance is running, the daemon enters degraded mode:
- No Lua handlers are active.
- IPC remains fully operational.
- `bread reload` can be retried after the user fixes their config.
- `bread doctor` reports the load error with the full stack trace.
The daemon never requires a full restart to recover from a Lua error.
---
## `bread.*` API Surface Summary
### Phase 1 (implemented)
| Function | Description |
|----------|-------------|
| `bread.on(pattern, fn)` | Subscribe to a pattern; returns subscription ID |
| `bread.once(pattern, fn)` | Subscribe once; auto-cancelled after first delivery |
| `bread.emit(event, payload)` | Inject a synthetic `BreadEvent` |
| `bread.exec(cmd)` | Fire-and-forget shell command |
| `bread.state.get(path)` | Read a value from `RuntimeState` by dotted path |
| `bread.profile.activate(name)` | Activate a named profile |
### Phase 2 (planned)
| Function | Description |
|----------|-------------|
| `bread.off(id)` | Cancel a subscription by ID |
| `bread.wait(pattern, opts)` | Yield until a matching event arrives |
| `bread.filter(pattern, fn, opts)` | Subscribe with a predicate guard |
| `bread.after(ms, fn)` | One-shot timer |
| `bread.every(ms, fn)` | Repeating timer |
| `bread.cancel(id)` | Cancel a timer |
| `bread.state.watch(path, fn)` | React to state changes at a path |
| `bread.state.monitors()` | Typed shorthand for `bread.state.get("monitors")` |
| `bread.state.power()` | Typed shorthand for `bread.state.get("power")` |
| `bread.state.network()` | Typed shorthand for `bread.state.get("network")` |
| `bread.hyprland.dispatch(cmd, args)` | Send a Hyprland dispatch |
| `bread.hyprland.keyword(key, val)` | Set a Hyprland keyword |
| `bread.hyprland.active_window()` | Query the active window |
| `bread.hyprland.monitors()` | Query all monitors |
| `bread.hyprland.workspaces()` | Query all workspaces |
| `bread.hyprland.clients()` | Query all open clients |
| `bread.hyprland.on_raw(event, fn)` | Subscribe to a raw Hyprland event string |
| `bread.module(decl)` | Declare a module with name, version, and dependencies |
| `M.store.get(key)` | Read from module-scoped persistent storage |
| `M.store.set(key, val)` | Write to module-scoped persistent storage |
---
## Summary
The Lua runtime is where Bread becomes useful. The daemon provides a reliable, normalized view of the desktop; the Lua layer acts on it.
Phase 1 delivers the mechanical minimum: a stable thread, a working `bread.*` API, event subscriptions, state access, hot reload, and IPC. That foundation is in the codebase today.
Phase 2 builds the ergonomics: module identity, lifecycle hooks, reactive state, timers, richer event APIs, and Hyprland control bindings. Each Phase 2 feature is additive — nothing in Phase 1 needs to change to support it.
The boundary between Rust and Lua is intentionally narrow. The daemon knows nothing about what modules do. Modules know nothing about how events arrive. The `bread.*` API is the entire contract between them.

443
README.md
View file

@ -6,7 +6,7 @@ Bread is a modular desktop automation runtime built around a single idea: your d
Instead of scattering behavior across shell scripts, compositor configs, udev rules, and ad-hoc daemons, Bread centralizes runtime awareness into a coherent layer that can observe, interpret, and react to system state dynamically. Instead of scattering behavior across shell scripts, compositor configs, udev rules, and ad-hoc daemons, Bread centralizes runtime awareness into a coherent layer that can observe, interpret, and react to system state dynamically.
> **Status:** Early development. The daemon (`breadd`) is stable. The Lua automation API is under active development. > **Status:** Early development. The daemon (`breadd`) is stable. The Lua automation API is active and feature-complete for daily use.
--- ---
@ -22,14 +22,19 @@ Bread runs a long-lived daemon (`breadd`) that:
Your automation lives in Lua. You subscribe to events, read state, and call APIs: Your automation lives in Lua. You subscribe to events, read state, and call APIs:
```lua ```lua
bread.on("bread.device.dock.connected", function() local M = bread.module({ name = "dock", version = "1.0.0" })
bread.on("bread.device.dock.connected", function(event)
bread.profile.activate("desk") bread.profile.activate("desk")
bread.exec("waybar --config ~/.config/waybar/desk.jsonc") bread.exec("waybar --config ~/.config/waybar/desk.jsonc")
bread.notify("Dock connected", { urgency = "low" })
end) end)
bread.on("bread.device.dock.disconnected", function() bread.on("bread.device.dock.disconnected", function(event)
bread.profile.activate("default") bread.profile.activate("default")
end) end)
return M
``` ```
--- ---
@ -40,12 +45,13 @@ end)
breadd/ Rust daemon — event pipeline, state engine, IPC, adapter supervision breadd/ Rust daemon — event pipeline, state engine, IPC, adapter supervision
bread-cli/ CLI frontend — talks to breadd over a Unix socket bread-cli/ CLI frontend — talks to breadd over a Unix socket
bread-shared/ Shared types — RawEvent, BreadEvent, AdapterSource bread-shared/ Shared types — RawEvent, BreadEvent, AdapterSource
bread-sync/ Sync engine — snapshot and restore system state via a Git remote
packaging/ Arch PKGBUILD and systemd user service packaging/ Arch PKGBUILD and systemd user service
``` ```
The daemon is structured in four layers: The daemon is structured in four layers:
- **Adapters** — interface with Hyprland IPC, udev, power state, and network interfaces - **Adapters** — interface with Hyprland IPC, udev, power state, network interfaces, and Bluetooth (BlueZ)
- **Normalizer** — transforms raw adapter signals into semantic Bread events - **Normalizer** — transforms raw adapter signals into semantic Bread events
- **State engine** — maintains runtime state and dispatches events to subscribers - **State engine** — maintains runtime state and dispatches events to subscribers
- **Lua runtime** — loads your modules, registers handlers, executes automation - **Lua runtime** — loads your modules, registers handlers, executes automation
@ -62,6 +68,7 @@ The daemon is structured in four layers:
Optional but preferred: Optional but preferred:
- UPower (for battery events via D-Bus rather than sysfs polling) - UPower (for battery events via D-Bus rather than sysfs polling)
- rtnetlink (for network events; falls back to sysfs polling without it) - rtnetlink (for network events; falls back to sysfs polling without it)
- BlueZ (for Bluetooth device events and control)
--- ---
@ -70,18 +77,22 @@ Optional but preferred:
### From source ### From source
```bash ```bash
git clone https://github.com/Breadway/bread git clone https://github.com/Breadway/bread.git
cd bread cd bread
cargo build --release
``` ```
Binaries will be at `target/release/breadd` and `target/release/bread`. Run the install script — it builds, symlinks `breadd` and `bread` into `~/.local/bin` (override with `BIN_DIR=…`), installs the systemd user service, and starts the daemon:
Install them:
```bash ```bash
sudo install -Dm755 target/release/breadd /usr/local/bin/breadd bash scripts/install.sh
sudo install -Dm755 target/release/bread /usr/local/bin/bread ```
Or step by step (system-wide install):
```bash
cargo build --release
sudo install -Dm755 target/release/breadd /usr/bin/breadd
sudo install -Dm755 target/release/bread /usr/bin/bread
``` ```
### Arch Linux (PKGBUILD) ### Arch Linux (PKGBUILD)
@ -128,19 +139,28 @@ poll_interval_secs = 30
[adapters.network] [adapters.network]
enabled = true enabled = true
[adapters.bluetooth]
enabled = true
[events] [events]
dedup_window_ms = 100 dedup_window_ms = 100
[notifications]
default_timeout_ms = 5000
default_urgency = "normal"
notify_send_path = "notify-send"
[modules]
builtin = true # load built-in modules (monitors, devices, workspaces, binds)
disable = [] # list of built-in module names to disable
``` ```
Your automation lives in `~/.config/bread/init.lua`: Your automation lives in `~/.config/bread/init.lua`. Modules placed in `~/.config/bread/modules/` are auto-loaded after `init.lua`:
```lua ```lua
-- ~/.config/bread/init.lua -- ~/.config/bread/init.lua
require("modules.devices") bread.on("bread.system.startup", function(event)
require("modules.workspaces")
bread.on("bread.system.startup", function()
bread.profile.activate("default") bread.profile.activate("default")
end) end)
``` ```
@ -152,16 +172,160 @@ end)
All commands communicate with the running daemon over a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. All commands communicate with the running daemon over a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`.
```bash ```bash
bread reload # Hot-reload all Lua modules # Daemon
bread state # Dump full runtime state as JSON
bread events # Stream live normalized events
bread events --filter bread.device.* # Stream filtered events
bread modules # List loaded modules and status
bread profile-list # List defined profiles
bread profile-activate <name> # Activate a named profile
bread emit <event> --data '{}' # Manually fire an event (for testing)
bread ping # Check daemon connectivity bread ping # Check daemon connectivity
bread health # Daemon version, uptime, PID bread health # Daemon version, uptime, PID
bread doctor # Diagnose daemon and module health
# Lua runtime
bread reload # Hot-reload all Lua modules
bread reload --watch # Watch config dir and reload on changes
# State and events
bread state # Dump full runtime state as JSON
bread events # Stream live normalized events
bread events bread.device.* # Stream filtered events
bread events --since 60 # Replay events from the last 60 seconds
bread emit <event> # Manually fire an event (for testing)
# Profiles
bread profile-list # List defined profiles
bread profile-activate <name> # Activate a named profile
# Modules
bread modules list # List installed modules and daemon status
bread modules install github:user/repo # Install from GitHub
bread modules install /local/path # Install from a local directory
bread modules remove <name> # Remove an installed module
bread modules update [name] # Re-install one or all GitHub-sourced modules
bread modules info <name> # Show full manifest and daemon status
# Sync
bread sync init # Initialize sync for this machine (remote optional)
bread sync push # Commit local snapshot
bread sync push --message "note" # Commit with a custom message
bread sync pull # Apply local snapshot to this machine
bread sync pull --install-packages # Also install packages from snapshot
bread sync status # Show what has changed since last push
bread sync diff # Show file-level diff vs last commit
bread sync machines # List known machines from sync repo
bread sync export # Create a portable .tar.gz snapshot (no git auth)
bread sync export --output path # Export to a specific file or directory
bread sync import <path> # Apply a portable snapshot (.tar.gz or directory)
bread sync import <path> --install-packages # Also install packages
bread sync import <path> --no-clone-repos # Skip cloning git repos
```
---
## Module system
Modules are Lua files (or directories) installed to `~/.config/bread/modules/`. Each module must declare itself with `bread.module()` and have a `bread.module.toml` manifest.
### Installing modules
```bash
# From GitHub (downloads latest release tarball)
bread modules install github:someuser/bread-wifi
# From a local path
bread modules install ~/src/my-module
# From a specific ref
bread modules install github:someuser/bread-wifi@v1.2.0
```
### Writing a module
A module directory looks like:
```
~/.config/bread/modules/
└── wifi/
├── bread.module.toml ← required manifest
└── init.lua ← entry point
```
`bread.module.toml`:
```toml
name = "wifi"
version = "1.0.0"
description = "WiFi management for Bread"
author = "someuser"
source = "github:someuser/bread-wifi"
installed_at = "2026-01-01T00:00:00Z"
```
`init.lua`:
```lua
local M = bread.module({ name = "wifi", version = "1.0.0" })
bread.on("bread.network.connected", function(event)
bread.log("Network up: " .. (event.data.interface or "unknown"))
end)
return M
```
---
## Sync system
Bread sync snapshots your entire setup — Bread config, dotfiles, fonts, systemd units, package lists, and git repos — into a local Git repository. Use `export`/`import` to move state between machines without needing a git remote.
```bash
# First-time setup (remote is optional)
bread sync init
bread sync init --remote git@github.com:you/bread-config.git
# Commit a local snapshot
bread sync push
# Create a portable .tar.gz (no git auth required)
bread sync export
# On another machine: apply the snapshot
bread sync import bread-export-hermes-2026-05-16.tar.gz
# Also install packages on import
bread sync import bread-export.tar.gz --install-packages
```
Configure what gets synced in `~/.config/bread/sync.toml`:
```toml
[remote]
url = "git@github.com:you/bread-config.git" # optional
branch = "main"
[machine]
name = "hermes"
tags = ["laptop", "battery"]
[packages]
enabled = true
managers = ["pacman", "pip", "cargo"]
[delegates]
include = ["~/.config/nvim", "~/.config/waybar"]
exclude = ["**/.git", "**/*.cache"]
```
A portable export snapshot contains:
```
bread-export-hermes-2026-05-16/
├── bread/ ← ~/.config/bread/
├── configs/ ← hypr, nvim, kitty, waybar, fish, dunst, btop, …
├── dotfiles/ ← .gitconfig, .zshrc, .zprofile, .zshenv, ssh config, …
├── local-bin/ ← ~/.local/bin/ scripts
├── local-fonts/ ← ~/.local/share/fonts/
├── systemd/ ← ~/.config/systemd/user/ units
├── system/ ← udev rules, modprobe, sysctl (sudo required for some)
├── packages/ ← pacman.txt, pip.txt, cargo.txt, npm.txt
├── machines/ ← per-machine profiles
├── manifest.toml ← path map for exact restore
└── restore.sh ← shell script for manual restore
``` ```
--- ---
@ -175,9 +339,8 @@ Events follow the namespace convention `bread.<subsystem>.<noun>.<verb>`.
| `bread.system.startup` | Daemon fully initialized | | `bread.system.startup` | Daemon fully initialized |
| `bread.device.connected` | Any device attached | | `bread.device.connected` | Any device attached |
| `bread.device.disconnected` | Any device removed | | `bread.device.disconnected` | Any device removed |
| `bread.device.dock.connected` | Dock attached | | `bread.device.<device>.connected` | Named device attached (name from `devices.lua`) |
| `bread.device.dock.disconnected` | Dock removed | | `bread.device.<device>.disconnected` | Named device removed |
| `bread.device.keyboard.connected` | Keyboard attached |
| `bread.monitor.connected` | Display connected | | `bread.monitor.connected` | Display connected |
| `bread.monitor.disconnected` | Display disconnected | | `bread.monitor.disconnected` | Display disconnected |
| `bread.workspace.changed` | Active workspace changed | | `bread.workspace.changed` | Active workspace changed |
@ -192,36 +355,91 @@ Events follow the namespace convention `bread.<subsystem>.<noun>.<verb>`.
| `bread.power.battery.full` | Battery at 100% | | `bread.power.battery.full` | Battery at 100% |
| `bread.network.connected` | Network interface came online | | `bread.network.connected` | Network interface came online |
| `bread.network.disconnected` | Network interface went offline | | `bread.network.disconnected` | Network interface went offline |
| `bread.bluetooth.device.paired` | Bluetooth device paired / discovered |
| `bread.bluetooth.device.unpaired` | Bluetooth device removed from BlueZ |
| `bread.profile.activated` | Profile switched | | `bread.profile.activated` | Profile switched |
| `bread.notify.sent` | Desktop notification dispatched |
--- ---
## Lua API ## Lua API
### Modules
Every module file must declare itself. The declaration is used for dependency ordering and status tracking.
```lua
local M = bread.module({
name = "my-module",
version = "1.0.0",
after = { "bread.devices" }, -- load after this module
})
-- ... module body ...
return M
```
### Events ### Events
```lua ```lua
-- Subscribe to an event -- Subscribe to events; returns a subscription ID
bread.on("bread.monitor.connected", function(event) local id = bread.on("bread.monitor.connected", function(event)
print(event.data.name) -- event.event → "bread.monitor.connected"
-- event.data → table of event-specific fields
-- event.source → adapter that produced it
bread.log(event.event)
end) end)
-- Subscribe once, then auto-unsubscribe -- Unsubscribe by ID
bread.off(id)
-- Subscribe once, auto-unsubscribe after first delivery
bread.once("bread.system.startup", function(event) bread.once("bread.system.startup", function(event)
-- runs exactly once bread.profile.activate("default")
end) end)
-- Subscribe with a filter predicate. The predicate goes in an opts table.
bread.filter("bread.device.connected", function(event)
bread.exec("xset r rate 200 40")
end, {
filter = function(event)
return event.data.device == "keyboard"
end,
})
-- Emit a custom event (for cross-module communication) -- Emit a custom event (for cross-module communication)
bread.emit("mymodule.something", { key = "value" }) bread.emit("mymodule.something", { key = "value" })
``` ```
Pattern matching supports `*` (single segment), `**` (any depth), and `?` (single character):
```lua
bread.on("bread.device.*", handler) -- matches bread.device.dock.connected
bread.on("bread.device.**", handler) -- matches any depth under bread.device
```
### State ### State
```lua ```lua
-- Read a value from runtime state by dot-separated path -- Read from runtime state by dot-separated path
local monitors = bread.state.get("monitors") local monitors = bread.state.get("monitors")
local workspace = bread.state.get("active_workspace") local online = bread.state.get("network.online")
local power = bread.state.get("power")
-- Typed shorthands
local monitors = bread.state.monitors()
local workspace = bread.state.active_workspace()
local window = bread.state.active_window()
local devices = bread.state.devices()
local power = bread.state.power()
local network = bread.state.network()
local profile = bread.state.profile()
-- Watch a state path for changes
bread.state.watch("power.ac_connected", function(new_val, old_val)
if new_val then
bread.notify("AC connected")
end
end)
``` ```
### Profiles ### Profiles
@ -231,12 +449,140 @@ bread.profile.activate("desk")
bread.profile.activate("default") bread.profile.activate("default")
``` ```
### Execution ### Execution and notifications
```lua ```lua
-- Fire-and-forget: returns immediately, process runs in background -- Fire-and-forget shell command
bread.exec("kitty") bread.exec("kitty")
bread.exec("notify-send 'Dock connected'")
-- Desktop notification (uses notify-send)
bread.notify("Title", { urgency = "normal", timeout = 3000, icon = "dialog-info" })
bread.notify("Simple message") -- title defaults to "bread"
```
### Timers
```lua
-- Run once after a delay (ms)
local id = bread.after(500, function()
bread.exec("some-delayed-command")
end)
-- Run on a repeating interval (ms)
local id = bread.every(60000, function()
bread.log("tick")
end)
-- Cancel either kind
bread.cancel(id)
-- Debounce a rapidly-firing handler
local fn = bread.debounce(200, function(event)
reconfigure_monitors()
end)
bread.on("bread.monitor.*", fn)
```
### Wait (inside coroutines)
```lua
-- Yield until a matching event arrives
local event = bread.wait("bread.device.dock.connected", { timeout = 5000 })
if event then
-- dock arrived within 5 seconds
end
```
### Machine and filesystem
```lua
-- Machine identity (from sync.toml, falls back to hostname)
local name = bread.machine.name()
local tags = bread.machine.tags() -- array of strings
local ok = bread.machine.has_tag("laptop")
-- Filesystem helpers (~ is expanded)
bread.fs.write("~/.config/some/file", "content")
local content = bread.fs.read("~/.config/some/file") -- nil if not found
local exists = bread.fs.exists("~/some/path")
local abs = bread.fs.expand("~/some/path")
```
### Logging
```lua
bread.log("Module loaded") -- info level
bread.warn("Unexpected state") -- warn level
bread.error("Something failed") -- error level
```
### Hyprland bindings
```lua
-- Dispatch a Hyprland command
bread.hyprland.dispatch("workspace", "2")
bread.hyprland.dispatch("exec", "kitty")
-- Set a keyword
bread.hyprland.keyword("monitor", "HDMI-A-1, 2560x1440, 0x0, 1")
-- Query compositor state
local win = bread.hyprland.active_window()
local monitors = bread.hyprland.monitors()
local workspaces = bread.hyprland.workspaces()
local clients = bread.hyprland.clients()
-- Subscribe to raw Hyprland events (bypass normalization)
bread.hyprland.on_raw("activewindow", function(raw)
-- raw is the unparsed string from Hyprland's event socket
end)
```
### Bluetooth
The `bread.bluetooth` namespace provides BlueZ control. All operations degrade gracefully when Bluetooth hardware is unavailable.
```lua
-- Power the adapter on or off
bread.bluetooth.power(true)
bread.bluetooth.power(false)
-- Query current power state (returns true/false, or nil if unavailable)
local on = bread.bluetooth.powered()
-- Connect/disconnect a paired device by MAC address
-- Fire-and-forget; result arrives as bread.device.connected/disconnected
bread.bluetooth.connect("AA:BB:CC:DD:EE:FF")
bread.bluetooth.disconnect("AA:BB:CC:DD:EE:FF")
-- Start or stop device discovery
bread.bluetooth.scan(true)
bread.bluetooth.scan(false)
-- List all devices known to BlueZ
local devs = bread.bluetooth.devices()
-- Returns nil if BlueZ is unavailable, otherwise:
-- { { address, name, connected, paired }, ... }
```
Example — auto-connect headphones when Bluetooth powers on:
```lua
bread.state.watch("power.ac_connected", function(ac)
if ac then
bread.bluetooth.power(true)
bread.bluetooth.connect("AA:BB:CC:DD:EE:FF")
end
end)
```
### Module-scoped storage
Survives hot reload; does not survive daemon restart.
```lua
M.store.set("last_profile", "docked")
local p = M.store.get("last_profile") -- "docked"
``` ```
--- ---
@ -255,9 +601,24 @@ Response:
{ "id": "1", "result": [ { "name": "HDMI-A-1", "connected": true } ] } { "id": "1", "result": [ { "name": "HDMI-A-1", "connected": true } ] }
``` ```
Available methods: `ping`, `health`, `state.get`, `state.dump`, `modules.list`, `modules.reload`, `profile.list`, `profile.activate`, `events.subscribe`, `emit`. Available methods:
`events.subscribe` upgrades the connection to a streaming mode — the daemon pushes events line by line until the client disconnects. | Method | Description |
|--------|-------------|
| `ping` | Connectivity check |
| `health` | Version, uptime, PID, adapter status |
| `state.get` | Read a value from `RuntimeState` by dotted key path |
| `state.dump` | Return the full `RuntimeState` as JSON |
| `modules.list` | List all loaded modules and their status |
| `modules.reload` | Hot-reload the Lua runtime |
| `profile.list` | List defined profiles |
| `profile.activate` | Switch active profile |
| `events.subscribe` | Upgrade connection to streaming mode |
| `events.replay` | Replay buffered events from the last N ms |
| `emit` | Inject a synthetic event into the pipeline |
| `sync.status` | Return sync initialization state and machine info |
`events.subscribe` upgrades the connection to streaming mode — the daemon pushes events line by line until the client disconnects.
--- ---
@ -265,7 +626,7 @@ Available methods: `ping`, `health`, `state.get`, `state.dump`, `modules.list`,
Bread is early-stage software. Contributions, issues, and feedback are welcome. Bread is early-stage software. Contributions, issues, and feedback are welcome.
The daemon (`breadd`) is the most stable part of the codebase. The Lua API surface is where most active development is happening. The daemon (`breadd`) is the most stable part of the codebase. Active development is happening across the Lua API, module system, and sync subsystem.
--- ---

View file

@ -1,12 +1,30 @@
[package] [package]
name = "bread-cli" name = "bread-cli"
version = "0.1.0" version = "1.0.0"
edition = "2021" edition = "2021"
[[bin]]
name = "bread"
path = "src/main.rs"
[lib]
name = "bread_cli"
path = "src/lib.rs"
[dependencies] [dependencies]
bread-shared = { path = "../bread-shared" } bread-shared = { path = "../bread-shared" }
bread-sync = { path = "../bread-sync" }
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
tokio.workspace = true tokio.workspace = true
anyhow.workspace = true anyhow.workspace = true
chrono.workspace = true
dirs.workspace = true
clap = { version = "4.5", features = ["derive"] } clap = { version = "4.5", features = ["derive"] }
notify = "6.1"
libc = "0.2"
toml = "0.8"
reqwest = { version = "0.11", features = ["json"] }
flate2 = "1.0"
tar = "0.4"
tempfile.workspace = true

2
bread-cli/src/lib.rs Normal file
View file

@ -0,0 +1,2 @@
/// Module management (install, remove, list, update, info).
pub mod modules_mgmt;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,181 @@
use anyhow::{bail, Context, Result};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
/// Contents of `bread.module.toml`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModuleManifest {
pub name: String,
pub version: String,
pub description: String,
pub author: String,
pub source: String,
pub installed_at: String,
}
/// Parsed install source.
pub enum InstallSource {
GitHub {
user: String,
repo: String,
git_ref: Option<String>,
},
LocalPath(PathBuf),
}
/// Parse a source string into an `InstallSource`.
pub fn parse_source(source: &str) -> Result<InstallSource> {
if let Some(rest) = source.strip_prefix("github:") {
let (repo_part, ref_part) = rest
.split_once('@')
.map(|(r, v)| (r, Some(v.to_string())))
.unwrap_or((rest, None));
let (user, repo) = repo_part.split_once('/').ok_or_else(|| {
anyhow::anyhow!(
"bread: invalid github source '{}'. Expected 'github:user/repo[@ref]'",
source
)
})?;
Ok(InstallSource::GitHub {
user: user.to_string(),
repo: repo.to_string(),
git_ref: ref_part,
})
} else if source.starts_with('/')
|| source.starts_with("./")
|| source.starts_with("../")
|| source.starts_with('~')
{
let expanded = bread_sync::config::expand_path(source);
Ok(InstallSource::LocalPath(expanded))
} else {
bail!(
"bread: invalid module source '{}'. Use 'github:user/repo' or an absolute/relative path",
source
)
}
}
/// Install a module from a local directory into `modules_dir`.
/// `source_str` is the original source string recorded in the manifest.
pub fn install_from_local(
src: &Path,
source_str: &str,
modules_dir: &Path,
) -> Result<ModuleManifest> {
let manifest_path = src.join("bread.module.toml");
if !manifest_path.exists() {
bail!("bread: no bread.module.toml found in {}", src.display());
}
let raw = fs::read_to_string(&manifest_path)
.with_context(|| format!("failed to read {}", manifest_path.display()))?;
let mut manifest: ModuleManifest =
toml::from_str(&raw).context("failed to parse bread.module.toml")?;
manifest.source = source_str.to_string();
manifest.installed_at = Utc::now().to_rfc3339();
let dest = modules_dir.join(&manifest.name);
if dest.exists() {
fs::remove_dir_all(&dest)
.with_context(|| format!("failed to remove existing module at {}", dest.display()))?;
}
copy_dir(src, &dest)?;
// Rewrite the manifest with the updated fields.
let manifest_dest = dest.join("bread.module.toml");
let out = toml::to_string_pretty(&manifest).context("failed to serialize module manifest")?;
fs::write(&manifest_dest, out)
.with_context(|| format!("failed to write manifest to {}", manifest_dest.display()))?;
Ok(manifest)
}
/// Remove a module directory from `modules_dir`.
pub fn remove_module(name: &str, modules_dir: &Path) -> Result<()> {
let module_dir = modules_dir.join(name);
if !module_dir.exists() {
bail!("bread: module '{}' is not installed", name);
}
fs::remove_dir_all(&module_dir)
.with_context(|| format!("failed to remove {}", module_dir.display()))
}
/// List all installed modules in `modules_dir`.
pub fn list_modules(modules_dir: &Path) -> Result<Vec<ModuleManifest>> {
if !modules_dir.exists() {
return Ok(vec![]);
}
let mut out = Vec::new();
for entry in fs::read_dir(modules_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let manifest_path = path.join("bread.module.toml");
if manifest_path.exists() {
if let Ok(m) = read_manifest_file(&manifest_path) {
out.push(m);
}
}
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
/// Read a module manifest by name.
pub fn read_module_manifest(name: &str, modules_dir: &Path) -> Result<ModuleManifest> {
let manifest_path = modules_dir.join(name).join("bread.module.toml");
if !manifest_path.exists() {
bail!("bread: module '{}' is not installed", name);
}
read_manifest_file(&manifest_path)
}
/// Read and parse a `bread.module.toml` file.
pub fn read_manifest_file(path: &Path) -> Result<ModuleManifest> {
let raw =
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
toml::from_str(&raw).context("failed to parse module manifest")
}
/// Returns the default modules directory.
pub fn modules_dir() -> PathBuf {
if let Some(cfg) = dirs::config_dir() {
return cfg.join("bread").join("modules");
}
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
return PathBuf::from(xdg).join("bread").join("modules");
}
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home)
.join(".config")
.join("bread")
.join("modules");
}
PathBuf::from(".config/bread/modules")
}
fn copy_dir(src: &Path, dst: &Path) -> Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if src_path.is_dir() {
copy_dir(&src_path, &dst_path)?;
} else {
fs::copy(&src_path, &dst_path).with_context(|| {
format!(
"failed to copy {} to {}",
src_path.display(),
dst_path.display()
)
})?;
}
}
Ok(())
}

139
bread-cli/tests/modules.rs Normal file
View file

@ -0,0 +1,139 @@
use bread_cli::modules_mgmt;
use std::fs;
use tempfile::TempDir;
/// Helper: create a minimal valid module directory in `dir` with given name.
fn make_module_dir(dir: &std::path::Path, name: &str, version: &str) -> std::path::PathBuf {
let module_dir = dir.join(name);
fs::create_dir_all(&module_dir).unwrap();
let manifest = format!(
r#"name = "{name}"
version = "{version}"
description = "Test module"
author = "test"
source = "/tmp/test"
installed_at = ""
"#
);
fs::write(module_dir.join("bread.module.toml"), manifest).unwrap();
fs::write(module_dir.join("init.lua"), "-- test\n").unwrap();
module_dir
}
#[test]
fn install_from_local_succeeds_with_manifest() {
let src_tmp = TempDir::new().unwrap();
let modules_tmp = TempDir::new().unwrap();
make_module_dir(src_tmp.path(), "mymod", "1.2.3");
let src = src_tmp.path().join("mymod");
let result = modules_mgmt::install_from_local(&src, "test:mymod", modules_tmp.path());
assert!(result.is_ok(), "install failed: {:?}", result.err());
let manifest = result.unwrap();
assert_eq!(manifest.name, "mymod");
assert_eq!(manifest.version, "1.2.3");
// Module directory must exist in modules dir
assert!(modules_tmp.path().join("mymod").exists());
assert!(modules_tmp
.path()
.join("mymod")
.join("bread.module.toml")
.exists());
assert!(modules_tmp.path().join("mymod").join("init.lua").exists());
}
#[test]
fn install_from_local_fails_without_manifest() {
let src_tmp = TempDir::new().unwrap();
let modules_tmp = TempDir::new().unwrap();
// No bread.module.toml in src
let src = src_tmp.path();
fs::write(src.join("init.lua"), "-- no manifest\n").unwrap();
let result = modules_mgmt::install_from_local(src, "test:nomod", modules_tmp.path());
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("bread.module.toml"),
"expected error about bread.module.toml, got: {msg}"
);
}
#[test]
fn remove_deletes_module_directory() {
let modules_tmp = TempDir::new().unwrap();
make_module_dir(modules_tmp.path(), "delme", "0.1.0");
// Verify it exists before removal
assert!(modules_tmp.path().join("delme").exists());
let result = modules_mgmt::remove_module("delme", modules_tmp.path());
assert!(result.is_ok(), "remove failed: {:?}", result.err());
assert!(!modules_tmp.path().join("delme").exists());
}
#[test]
fn remove_nonexistent_errors() {
let modules_tmp = TempDir::new().unwrap();
let result = modules_mgmt::remove_module("ghost", modules_tmp.path());
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("ghost"),
"expected error mentioning module name, got: {msg}"
);
}
#[test]
fn list_reads_manifests_from_disk() {
let modules_tmp = TempDir::new().unwrap();
make_module_dir(modules_tmp.path(), "alpha", "1.0.0");
make_module_dir(modules_tmp.path(), "beta", "2.0.0");
// Add a non-module dir (no manifest) — should be ignored
fs::create_dir_all(modules_tmp.path().join("notamodule")).unwrap();
let modules = modules_mgmt::list_modules(modules_tmp.path()).unwrap();
assert_eq!(modules.len(), 2);
assert_eq!(modules[0].name, "alpha");
assert_eq!(modules[1].name, "beta");
}
#[test]
fn manifest_written_correctly_on_install() {
let src_tmp = TempDir::new().unwrap();
let modules_tmp = TempDir::new().unwrap();
make_module_dir(src_tmp.path(), "installtest", "3.0.0");
let src = src_tmp.path().join("installtest");
let manifest =
modules_mgmt::install_from_local(&src, "github:test/installtest", modules_tmp.path())
.unwrap();
// All required fields must be present and non-empty
assert_eq!(manifest.name, "installtest");
assert_eq!(manifest.version, "3.0.0");
assert!(!manifest.description.is_empty());
assert!(!manifest.author.is_empty());
assert_eq!(manifest.source, "github:test/installtest");
assert!(!manifest.installed_at.is_empty());
// installed_at must be valid RFC 3339
let parsed = chrono::DateTime::parse_from_rfc3339(&manifest.installed_at);
assert!(
parsed.is_ok(),
"installed_at '{}' is not valid RFC 3339",
manifest.installed_at
);
// Verify the on-disk manifest also has all fields
let on_disk = modules_mgmt::read_module_manifest("installtest", modules_tmp.path()).unwrap();
assert_eq!(on_disk.name, manifest.name);
assert_eq!(on_disk.installed_at, manifest.installed_at);
assert_eq!(on_disk.source, "github:test/installtest");
}

View file

@ -1,6 +1,6 @@
[package] [package]
name = "bread-shared" name = "bread-shared"
version = "0.1.0" version = "1.0.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

View file

@ -1,32 +1,73 @@
//! Shared types for the Bread automation fabric.
//!
//! This crate defines the canonical event types ([`RawEvent`], [`BreadEvent`])
//! and the [`AdapterSource`] enum that both the daemon (`breadd`) and CLI
//! (`bread-cli`) depend on. Keeping these types in a separate crate guarantees
//! that adapters, the state engine, IPC clients, and the Lua bindings all
//! agree on a single wire format.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Identifies which adapter produced an event.
///
/// The state engine uses this to choose a normalization strategy and the
/// IPC layer surfaces it so subscribers can filter by origin.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq, Hash)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq, Hash)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum AdapterSource { pub enum AdapterSource {
/// The Hyprland compositor IPC socket.
Hyprland, Hyprland,
/// The Linux udev / netlink subsystem.
Udev, Udev,
/// Power management (sysfs / UPower).
Power, Power,
/// Network state (rtnetlink / NetworkManager).
Network, Network,
/// Internal events synthesized by the daemon itself
/// (e.g. `bread.profile.activated`, `bread.state.changed.*`).
System, System,
/// BlueZ Bluetooth stack via D-Bus.
Bluetooth,
} }
/// An unnormalized event as emitted by an adapter.
///
/// Raw events carry the adapter's native payload verbatim. The
/// [`EventNormalizer`](../breadd/core/normalizer/struct.EventNormalizer.html)
/// in `breadd` transforms `RawEvent` into one or more [`BreadEvent`]s with
/// a semantic name and structured data.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RawEvent { pub struct RawEvent {
/// Which adapter produced this event.
pub source: AdapterSource, pub source: AdapterSource,
/// Adapter-specific event kind (e.g. `"workspace"`, `"add"`, `"battery"`).
pub kind: String, pub kind: String,
/// Adapter-specific JSON payload — not stable across versions.
pub payload: serde_json::Value, pub payload: serde_json::Value,
/// Unix epoch milliseconds when the event was observed.
pub timestamp: u64, pub timestamp: u64,
} }
/// A normalized event ready for dispatch to Lua subscribers and IPC consumers.
///
/// `BreadEvent` is the public, stable contract: event names use a dotted
/// namespace (e.g. `bread.device.dock.connected`) and the `data` payload
/// follows a documented shape per event family. See `Documentation.md` for
/// the full event catalogue.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BreadEvent { pub struct BreadEvent {
/// Dotted event name, e.g. `bread.workspace.changed`.
pub event: String, pub event: String,
/// Unix epoch milliseconds when the originating signal was observed.
pub timestamp: u64, pub timestamp: u64,
/// The adapter that produced the underlying raw event.
pub source: AdapterSource, pub source: AdapterSource,
/// Structured event data. The shape depends on the event family.
pub data: serde_json::Value, pub data: serde_json::Value,
} }
impl BreadEvent { impl BreadEvent {
/// Construct a new event with `timestamp` set to the current wall-clock.
pub fn new(event: impl Into<String>, source: AdapterSource, data: serde_json::Value) -> Self { pub fn new(event: impl Into<String>, source: AdapterSource, data: serde_json::Value) -> Self {
Self { Self {
event: event.into(), event: event.into(),
@ -37,9 +78,142 @@ impl BreadEvent {
} }
} }
/// Current Unix epoch in milliseconds.
///
/// Falls back to `0` if the system clock is before the epoch, which keeps
/// callers infallible. Used for `BreadEvent::timestamp` and replay cutoffs.
pub fn now_unix_ms() -> u64 { pub fn now_unix_ms() -> u64 {
std::time::SystemTime::now() std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default() .unwrap_or_default()
.as_millis() as u64 .as_millis() as u64
} }
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn adapter_source_serializes_as_snake_case() {
assert_eq!(
serde_json::to_string(&AdapterSource::Hyprland).unwrap(),
"\"hyprland\""
);
assert_eq!(
serde_json::to_string(&AdapterSource::Udev).unwrap(),
"\"udev\""
);
assert_eq!(
serde_json::to_string(&AdapterSource::Power).unwrap(),
"\"power\""
);
assert_eq!(
serde_json::to_string(&AdapterSource::Network).unwrap(),
"\"network\""
);
assert_eq!(
serde_json::to_string(&AdapterSource::System).unwrap(),
"\"system\""
);
assert_eq!(
serde_json::to_string(&AdapterSource::Bluetooth).unwrap(),
"\"bluetooth\""
);
}
#[test]
fn adapter_source_round_trips_through_json() {
for source in [
AdapterSource::Hyprland,
AdapterSource::Udev,
AdapterSource::Power,
AdapterSource::Network,
AdapterSource::System,
AdapterSource::Bluetooth,
] {
let s = serde_json::to_string(&source).unwrap();
let back: AdapterSource = serde_json::from_str(&s).unwrap();
assert_eq!(source, back);
}
}
#[test]
fn adapter_source_rejects_unknown_variant() {
let result: Result<AdapterSource, _> = serde_json::from_str("\"floppy\"");
assert!(result.is_err());
}
#[test]
fn bread_event_new_sets_current_timestamp() {
let before = now_unix_ms();
let event = BreadEvent::new("bread.test", AdapterSource::System, json!({}));
let after = now_unix_ms();
assert!(event.timestamp >= before);
assert!(event.timestamp <= after);
assert_eq!(event.event, "bread.test");
assert_eq!(event.source, AdapterSource::System);
}
#[test]
fn bread_event_new_accepts_owned_and_borrowed_names() {
let owned = BreadEvent::new(String::from("bread.a"), AdapterSource::System, json!(null));
let borrowed = BreadEvent::new("bread.b", AdapterSource::System, json!(null));
assert_eq!(owned.event, "bread.a");
assert_eq!(borrowed.event, "bread.b");
}
#[test]
fn bread_event_round_trips_through_json() {
let original = BreadEvent {
event: "bread.device.connected".to_string(),
timestamp: 1_700_000_000_000,
source: AdapterSource::Udev,
data: json!({ "id": "usb-1-1.4", "name": "Logitech" }),
};
let raw = serde_json::to_string(&original).unwrap();
let decoded: BreadEvent = serde_json::from_str(&raw).unwrap();
assert_eq!(decoded.event, original.event);
assert_eq!(decoded.timestamp, original.timestamp);
assert_eq!(decoded.source, original.source);
assert_eq!(decoded.data, original.data);
}
#[test]
fn raw_event_round_trips_through_json() {
let original = RawEvent {
source: AdapterSource::Hyprland,
kind: "workspace".to_string(),
payload: json!({ "data": "2" }),
timestamp: 42,
};
let raw = serde_json::to_string(&original).unwrap();
let decoded: RawEvent = serde_json::from_str(&raw).unwrap();
assert_eq!(decoded.kind, original.kind);
assert_eq!(decoded.timestamp, original.timestamp);
assert_eq!(decoded.source, original.source);
assert_eq!(decoded.payload, original.payload);
}
#[test]
fn now_unix_ms_is_monotonically_non_decreasing_across_calls() {
let a = now_unix_ms();
let b = now_unix_ms();
assert!(b >= a, "now_unix_ms went backwards: {a} -> {b}");
}
#[test]
fn adapter_source_is_hashable_and_eq() {
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(AdapterSource::Hyprland);
set.insert(AdapterSource::Hyprland);
set.insert(AdapterSource::Udev);
set.insert(AdapterSource::Bluetooth);
assert_eq!(set.len(), 3);
assert!(set.contains(&AdapterSource::Hyprland));
}
}

18
bread-sync/Cargo.toml Normal file
View file

@ -0,0 +1,18 @@
[package]
name = "bread-sync"
version = "1.0.0"
edition = "2021"
[dependencies]
serde.workspace = true
serde_json.workspace = true
anyhow.workspace = true
git2.workspace = true
dirs.workspace = true
chrono.workspace = true
glob.workspace = true
toml = "0.8"
libc = "0.2"
[dev-dependencies]
tempfile.workspace = true

88
bread-sync/README.md Normal file
View file

@ -0,0 +1,88 @@
# bread-sync
Sync engine for [Bread](../README.md) — snapshot and restore desktop state via a Git remote.
## Purpose
`bread-sync` provides the library backing `bread sync` commands. It handles:
- **Git operations** — clone, commit, push, pull, fetch, diff via `git2`
- **Config serialization** — read/write `sync.toml` (machine name, remote URL, delegates, packages)
- **Delegate file sync** — rsync-style directory copy with glob excludes
- **Package snapshots** — capture installed packages from pacman, pip, npm, cargo
- **Machine profiles** — per-machine TOML records with hostname, tags, and last-sync timestamp
## Public API
### `config`
```rust
SyncConfig::load(config_dir: &Path) -> Result<SyncConfig>
SyncConfig::save(&self, config_dir: &Path) -> Result<()>
SyncConfig::local_repo_path() -> PathBuf // ~/.local/share/bread/sync-repo/
bread_config_dir() -> PathBuf // ~/.config/bread/
expand_path(path: &str) -> PathBuf // expands ~/
```
### `git`
```rust
SyncRepo::init(path: &Path) -> Result<SyncRepo>
SyncRepo::open(path: &Path) -> Result<SyncRepo>
SyncRepo::clone_from(url: &str, path: &Path) -> Result<SyncRepo>
SyncRepo::open_or_clone(url: &str, path: &Path) -> Result<SyncRepo>
SyncRepo::commit(&self, message: &str) -> Result<Option<git2::Oid>> // None = nothing to commit
SyncRepo::push(&self, remote: &str, branch: &str) -> Result<()>
SyncRepo::pull(&self, remote: &str, branch: &str) -> Result<()> // fast-forward only
SyncRepo::fetch(&self, remote: &str, branch: &str) -> Result<()>
SyncRepo::is_clean(&self) -> Result<bool>
SyncRepo::local_changes(&self) -> Result<Vec<(char, String)>>
SyncRepo::remote_changes(&self, remote: &str, branch: &str) -> Result<Vec<(char, String)>>
SyncRepo::working_diff(&self) -> Result<String>
SyncRepo::remote_diff(&self, remote: &str, branch: &str) -> Result<String>
SyncRepo::set_remote(&self, name: &str, url: &str) -> Result<()>
SyncRepo::last_commit_time(&self) -> Option<DateTime<Local>>
```
### `delegates`
```rust
sync_dir(src: &Path, dst: &Path, exclude: &[String]) -> Result<()>
resolve_include_paths(includes: &[String]) -> Vec<(String, PathBuf)>
```
### `machine`
```rust
MachineProfile::new(name: String, tags: Vec<String>) -> MachineProfile
MachineProfile::write(&self, machines_dir: &Path) -> Result<()>
MachineProfile::read(machines_dir: &Path, name: &str) -> Result<MachineProfile>
MachineProfile::list(machines_dir: &Path) -> Result<Vec<MachineProfile>>
hostname() -> String
```
### `packages`
```rust
snapshot(manager: &str, dest: &Path) -> Result<bool> // false = manager not found (non-fatal)
parse_pacman(content: &str) -> Vec<String>
parse_pip(content: &str) -> Vec<String>
parse_npm(content: &str) -> Vec<String>
parse_cargo(content: &str) -> Vec<String>
```
## Sync repo layout
```
~/.local/share/bread/sync-repo/
├── bread/ ← snapshot of ~/.config/bread/
├── configs/
│ └── <basename>/ ← delegate paths
├── machines/
│ └── <name>.toml ← per-machine profiles
└── packages/
├── pacman.txt
├── pip.txt
├── npm.txt
└── cargo.txt
```

259
bread-sync/src/config.rs Normal file
View file

@ -0,0 +1,259 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
/// Configuration stored in `~/.config/bread/sync.toml`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncConfig {
pub remote: RemoteConfig,
pub machine: MachineConfig,
#[serde(default)]
pub packages: PackagesConfig,
#[serde(default)]
pub delegates: DelegatesConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoteConfig {
pub url: String,
#[serde(default = "default_branch")]
pub branch: String,
}
fn default_branch() -> String {
"main".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MachineConfig {
pub name: String,
#[serde(default)]
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackagesConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub managers: Vec<String>,
}
fn default_true() -> bool {
true
}
impl Default for PackagesConfig {
fn default() -> Self {
Self {
enabled: true,
managers: vec![
"pacman".to_string(),
"aur".to_string(),
"pip".to_string(),
"npm".to_string(),
"cargo".to_string(),
],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DelegatesConfig {
#[serde(default)]
pub include: Vec<String>,
#[serde(default)]
pub exclude: Vec<String>,
}
impl SyncConfig {
/// Load sync config from the given bread config directory.
pub fn load(config_dir: &Path) -> Result<Self> {
let path = config_dir.join("sync.toml");
let raw = fs::read_to_string(&path)
.with_context(|| "bread: sync not initialized. Run: bread sync init".to_string())?;
toml::from_str(&raw).context("failed to parse sync.toml")
}
/// Save sync config to the given bread config directory.
pub fn save(&self, config_dir: &Path) -> Result<()> {
let path = config_dir.join("sync.toml");
fs::create_dir_all(config_dir)?;
let raw = toml::to_string_pretty(self).context("failed to serialize sync config")?;
fs::write(&path, raw).with_context(|| format!("failed to write {}", path.display()))
}
/// Returns the local sync repo path (`~/.local/share/bread/sync-repo/`).
pub fn local_repo_path() -> PathBuf {
if let Some(data_dir) = dirs::data_dir() {
return data_dir.join("bread").join("sync-repo");
}
// Fallback using $HOME
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home)
.join(".local")
.join("share")
.join("bread")
.join("sync-repo");
}
PathBuf::from(".local/share/bread/sync-repo")
}
}
/// Returns the bread config directory (`~/.config/bread/`).
pub fn bread_config_dir() -> PathBuf {
if let Some(cfg) = dirs::config_dir() {
return cfg.join("bread");
}
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
return PathBuf::from(xdg).join("bread");
}
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home).join(".config").join("bread");
}
PathBuf::from(".config/bread")
}
/// Expand `~` to the home directory in a path string.
pub fn expand_path(path: &str) -> PathBuf {
if path == "~" {
if let Some(home) = dirs::home_dir() {
return home;
}
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home);
}
} else if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(rest);
}
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home).join(rest);
}
}
PathBuf::from(path)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn sample_config() -> SyncConfig {
SyncConfig {
remote: RemoteConfig {
url: "git@github.com:user/repo.git".to_string(),
branch: "main".to_string(),
},
machine: MachineConfig {
name: "host".to_string(),
tags: vec!["mobile".to_string()],
},
packages: PackagesConfig::default(),
delegates: DelegatesConfig::default(),
}
}
#[test]
fn save_and_load_round_trip() {
let tmp = TempDir::new().unwrap();
let cfg = sample_config();
cfg.save(tmp.path()).unwrap();
assert!(tmp.path().join("sync.toml").exists());
let loaded = SyncConfig::load(tmp.path()).unwrap();
assert_eq!(loaded.remote.url, cfg.remote.url);
assert_eq!(loaded.remote.branch, cfg.remote.branch);
assert_eq!(loaded.machine.name, cfg.machine.name);
assert_eq!(loaded.machine.tags, cfg.machine.tags);
}
#[test]
fn load_missing_config_returns_helpful_error() {
let tmp = TempDir::new().unwrap();
let err = SyncConfig::load(tmp.path()).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("sync not initialized") || msg.contains("bread sync init"),
"expected init hint, got: {msg}",
);
}
#[test]
fn load_invalid_toml_returns_parse_error() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("sync.toml"), "this is not [valid toml").unwrap();
let err = SyncConfig::load(tmp.path()).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.to_lowercase().contains("parse"), "got: {msg}");
}
#[test]
fn packages_config_default_includes_all_managers() {
let cfg = PackagesConfig::default();
assert!(cfg.enabled);
assert!(cfg.managers.contains(&"pacman".to_string()));
assert!(cfg.managers.contains(&"aur".to_string()));
assert!(cfg.managers.contains(&"pip".to_string()));
assert!(cfg.managers.contains(&"npm".to_string()));
assert!(cfg.managers.contains(&"cargo".to_string()));
}
#[test]
fn remote_branch_defaults_to_main_when_omitted() {
let raw = r#"
[remote]
url = "git@example.com:r.git"
[machine]
name = "host"
"#;
let cfg: SyncConfig = toml::from_str(raw).unwrap();
assert_eq!(cfg.remote.branch, "main");
}
#[test]
fn delegates_default_is_empty() {
let cfg = DelegatesConfig::default();
assert!(cfg.include.is_empty());
assert!(cfg.exclude.is_empty());
}
#[test]
fn local_repo_path_resolves_to_data_dir() {
let path = SyncConfig::local_repo_path();
// Must include the bread sync-repo segment at the end.
let suffix = path.iter().rev().take(2).collect::<Vec<_>>();
assert_eq!(
suffix,
vec![
std::ffi::OsStr::new("sync-repo"),
std::ffi::OsStr::new("bread")
]
);
}
#[test]
fn expand_path_passes_through_absolute_paths() {
assert_eq!(expand_path("/etc/bread"), PathBuf::from("/etc/bread"));
assert_eq!(expand_path("relative/path"), PathBuf::from("relative/path"));
}
#[test]
fn expand_path_expands_tilde_alone_to_home() {
let home = dirs::home_dir().or_else(|| std::env::var("HOME").ok().map(PathBuf::from));
if let Some(home) = home {
assert_eq!(expand_path("~"), home);
}
}
#[test]
fn expand_path_expands_tilde_prefix() {
let home = dirs::home_dir().or_else(|| std::env::var("HOME").ok().map(PathBuf::from));
if let Some(home) = home {
assert_eq!(expand_path("~/.config"), home.join(".config"));
}
}
}

247
bread-sync/src/delegates.rs Normal file
View file

@ -0,0 +1,247 @@
use anyhow::Result;
use glob::Pattern;
use std::fs;
use std::path::{Path, PathBuf};
use crate::config::expand_path;
/// Copy all files from `src` into `dst`, mirroring the directory tree.
/// Files present in `dst` but not in `src` are deleted (rsync-style).
/// Files matching any `exclude` glob are skipped.
pub fn sync_dir(src: &Path, dst: &Path, exclude: &[String]) -> Result<()> {
let patterns: Vec<Pattern> = exclude
.iter()
.filter_map(|g| Pattern::new(g).ok())
.collect();
fs::create_dir_all(dst)?;
sync_dir_inner(src, dst, src, &patterns)
}
fn sync_dir_inner(src: &Path, dst: &Path, root: &Path, patterns: &[Pattern]) -> Result<()> {
// Remove files in dst that don't exist in src.
if dst.exists() {
for entry in fs::read_dir(dst)? {
let entry = entry?;
let rel = entry
.path()
.strip_prefix(dst)
.unwrap_or(&entry.path())
.to_path_buf();
let src_counterpart = src.join(&rel);
if !src_counterpart.exists() {
let p = entry.path();
if p.is_dir() {
let _ = fs::remove_dir_all(&p);
} else {
let _ = fs::remove_file(&p);
}
}
}
}
if !src.exists() {
return Ok(());
}
for entry in fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let rel = src_path.strip_prefix(root).unwrap_or(&src_path);
if is_excluded(rel, root, patterns) {
continue;
}
let dst_path = dst.join(src_path.strip_prefix(src).unwrap_or(&src_path));
if src_path.is_dir() {
fs::create_dir_all(&dst_path)?;
sync_dir_inner(&src_path, &dst_path, root, patterns)?;
} else {
if let Some(parent) = dst_path.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
fn is_excluded(rel: &Path, _root: &Path, patterns: &[Pattern]) -> bool {
let rel_str = rel.to_string_lossy();
let file_name = rel
.file_name()
.map(|n| n.to_string_lossy())
.unwrap_or_default();
for pat in patterns {
// Match against full relative path or just filename
if pat.matches(&rel_str) || pat.matches(&file_name) {
return true;
}
// For directory-name patterns (e.g. "**/.git"), also check component names
if let Some(pat_str) = pat.as_str().strip_prefix("**/") {
for component in rel.components() {
if let std::path::Component::Normal(name) = component {
if Pattern::new(pat_str)
.map(|p| p.matches(&name.to_string_lossy()))
.unwrap_or(false)
{
return true;
}
}
}
}
}
false
}
/// Resolve delegate paths from the config (expanding `~`).
pub fn resolve_include_paths(includes: &[String]) -> Vec<(String, PathBuf)> {
includes
.iter()
.map(|s| {
let expanded = expand_path(s);
let basename = expanded
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| s.clone());
(basename, expanded)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn sync_dir_copies_nested_tree() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
fs::create_dir_all(src.path().join("a/b/c")).unwrap();
fs::write(src.path().join("a/b/c/leaf.txt"), "hello").unwrap();
fs::write(src.path().join("root.txt"), "root").unwrap();
sync_dir(src.path(), dst.path(), &[]).unwrap();
assert_eq!(
fs::read_to_string(dst.path().join("a/b/c/leaf.txt")).unwrap(),
"hello"
);
assert_eq!(
fs::read_to_string(dst.path().join("root.txt")).unwrap(),
"root"
);
}
#[test]
fn sync_dir_overwrites_existing_files() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
fs::write(src.path().join("f"), "new").unwrap();
fs::write(dst.path().join("f"), "old").unwrap();
sync_dir(src.path(), dst.path(), &[]).unwrap();
assert_eq!(fs::read_to_string(dst.path().join("f")).unwrap(), "new");
}
#[test]
fn sync_dir_removes_files_no_longer_in_src() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
fs::write(dst.path().join("orphan.txt"), "to remove").unwrap();
fs::write(src.path().join("keeper.txt"), "stay").unwrap();
sync_dir(src.path(), dst.path(), &[]).unwrap();
assert!(!dst.path().join("orphan.txt").exists());
assert!(dst.path().join("keeper.txt").exists());
}
#[test]
fn sync_dir_removes_directories_no_longer_in_src() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
fs::create_dir_all(dst.path().join("ghost-dir")).unwrap();
fs::write(dst.path().join("ghost-dir/x"), "").unwrap();
sync_dir(src.path(), dst.path(), &[]).unwrap();
assert!(!dst.path().join("ghost-dir").exists());
}
#[test]
fn sync_dir_exclude_filters_by_basename_pattern() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
fs::write(src.path().join("keep.lua"), "lua").unwrap();
fs::write(src.path().join("trash.cache"), "").unwrap();
sync_dir(src.path(), dst.path(), &["**/*.cache".to_string()]).unwrap();
assert!(dst.path().join("keep.lua").exists());
assert!(!dst.path().join("trash.cache").exists());
}
#[test]
fn sync_dir_exclude_filters_nested_directory_by_name() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
fs::create_dir_all(src.path().join(".git/objects")).unwrap();
fs::write(src.path().join(".git/objects/abc"), "").unwrap();
fs::write(src.path().join("init.lua"), "lua").unwrap();
sync_dir(src.path(), dst.path(), &["**/.git".to_string()]).unwrap();
assert!(dst.path().join("init.lua").exists());
assert!(!dst.path().join(".git").exists());
}
#[test]
fn sync_dir_creates_destination_if_missing() {
let src = TempDir::new().unwrap();
let dst_parent = TempDir::new().unwrap();
let dst = dst_parent.path().join("brand-new");
fs::write(src.path().join("hi"), "hi").unwrap();
sync_dir(src.path(), &dst, &[]).unwrap();
assert!(dst.join("hi").exists());
}
#[test]
fn sync_dir_empty_src_clears_dst() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
fs::write(dst.path().join("a"), "").unwrap();
fs::write(dst.path().join("b"), "").unwrap();
sync_dir(src.path(), dst.path(), &[]).unwrap();
let remaining: Vec<_> = fs::read_dir(dst.path()).unwrap().collect();
assert!(remaining.is_empty());
}
// ─── resolve_include_paths ────────────────────────────────────────────
#[test]
fn resolve_include_paths_uses_basename_as_key() {
let includes = vec!["/etc/foo/bar".to_string(), "/var/lib/quux".to_string()];
let resolved = resolve_include_paths(&includes);
assert_eq!(resolved.len(), 2);
assert_eq!(resolved[0].0, "bar");
assert_eq!(resolved[0].1, PathBuf::from("/etc/foo/bar"));
assert_eq!(resolved[1].0, "quux");
}
#[test]
fn resolve_include_paths_expands_tilde_in_source() {
let home = dirs::home_dir().or_else(|| std::env::var("HOME").ok().map(PathBuf::from));
if let Some(home) = home {
let resolved = resolve_include_paths(&["~/Documents".to_string()]);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].1, home.join("Documents"));
assert_eq!(resolved[0].0, "Documents");
}
}
}

850
bread-sync/src/export.rs Normal file
View file

@ -0,0 +1,850 @@
use anyhow::{Context, Result};
use chrono::Utc;
use git2::Repository;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use crate::config::{expand_path, SyncConfig};
use crate::delegates::sync_dir;
use crate::machine::{hostname, MachineProfile};
use crate::packages;
/// Maps a staged path back to the original absolute path on the source machine.
/// Drives the import — no hardcoded paths needed.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PathRecord {
/// Relative path within the export (e.g. "configs/hypr").
pub staging: String,
/// Original path with `~` (e.g. "~/.config/hypr").
pub original: String,
/// Whether this is a single file (false = directory).
#[serde(default)]
pub is_file: bool,
}
/// A git repository found on the machine, keyed by its remote URL.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitRepoRecord {
/// Path relative to $HOME (e.g. "Projects/bread").
pub path: String,
/// Remote URL (e.g. "https://github.com/Breadway/bread.git").
pub remote: String,
/// Branch that was checked out at export time.
pub branch: String,
}
/// Manifest stored in the export root as `manifest.toml`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExportManifest {
pub version: u32,
pub machine: String,
pub hostname: String,
pub exported_at: String,
/// Explicit staging→original path map for all captured items.
#[serde(default)]
pub path_map: Vec<PathRecord>,
/// High-level list of config dir names (for display).
pub configs: Vec<String>,
/// Git repos found on the source machine.
#[serde(default)]
pub repos: Vec<GitRepoRecord>,
pub system: bool,
pub packages: Vec<String>,
// Legacy fields kept for forward compat (ignored on import)
#[serde(default)]
pub bread: bool,
#[serde(default)]
pub dotfiles: Vec<String>,
#[serde(default)]
pub local_bin: Vec<String>,
#[serde(default)]
pub systemd_units: Vec<String>,
}
/// Config directories always included in the export (if they exist on disk).
static BUILTIN_CONFIGS: &[(&str, &str)] = &[
("hypr", "~/.config/hypr"),
("fish", "~/.config/fish"),
("kitty", "~/.config/kitty"),
("nvim", "~/.config/nvim"),
("ags", "~/.config/ags"),
("wofi", "~/.config/wofi"),
("waybar", "~/.config/waybar"),
("dunst", "~/.config/dunst"),
("mako", "~/.config/mako"),
("hyprlock", "~/.config/hyprlock"),
("hyprpaper", "~/.config/hyprpaper"),
("swaylock", "~/.config/swaylock"),
("wlogout", "~/.config/wlogout"),
("swappy", "~/.config/swappy"),
("btop", "~/.config/btop"),
("waypaper", "~/.config/waypaper"),
("wal", "~/.config/wal"),
("gtk-3.0", "~/.config/gtk-3.0"),
("gtk-4.0", "~/.config/gtk-4.0"),
("keyd", "~/.config/keyd"),
("autostart", "~/.config/autostart"),
];
/// Standalone dotfiles captured as individual files: (staging-name, source-path).
static BUILTIN_DOTFILES: &[(&str, &str)] = &[
(".gitconfig", "~/.gitconfig"),
("user-dirs.dirs", "~/.config/user-dirs.dirs"),
("mimeapps.list", "~/.config/mimeapps.list"),
("ssh_config", "~/.ssh/config"),
(".zshrc", "~/.zshrc"),
(".zprofile", "~/.zprofile"),
(".zshenv", "~/.zshenv"),
];
/// System-level directories. World-readable ones are copied directly;
/// root-only ones (networkmanager, bluetooth) require running with sudo.
static SYSTEM_PATHS: &[(&str, &str)] = &[
("udev", "/etc/udev/rules.d"),
("modprobe", "/etc/modprobe.d"),
("sysctl", "/etc/sysctl.d"),
("networkmanager", "/etc/NetworkManager/system-connections"),
("bluetooth", "/var/lib/bluetooth"),
];
/// Directories excluded from every recursive copy.
static DEFAULT_EXCLUDES: &[&str] = &[
"**/.git",
"**/*.cache",
"**/node_modules",
"**/@girs",
"**/__pycache__",
"fish_variables?*",
];
/// Directories skipped when searching for git repos.
static GIT_SKIP_DIRS: &[&str] = &[
".local", "Nextcloud", "target", "node_modules", "__pycache__",
".cache", "snap", "flatpak", "@girs", "Steam",
];
// ── stage_export ────────────────────────────────────────────────────────────
/// Build a self-contained snapshot directory at `staging`.
pub fn stage_export(
cfg_dir: &Path,
config: &SyncConfig,
staging: &Path,
) -> Result<ExportManifest> {
fs::create_dir_all(staging)?;
let excludes: Vec<String> = DEFAULT_EXCLUDES.iter().map(|s| s.to_string()).collect();
let mut path_map: Vec<PathRecord> = Vec::new();
let mut included_configs: Vec<String> = Vec::new();
// Helper: tilde-ify an absolute path for storage in the manifest.
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/root"));
let tilde = |p: &Path| -> String {
p.strip_prefix(&home)
.map(|rel| format!("~/{}", rel.display()))
.unwrap_or_else(|_| p.display().to_string())
};
// 1. Bread config → bread/
let bread_dest = staging.join("bread");
sync_dir(cfg_dir, &bread_dest, &excludes).context("failed to snapshot bread config")?;
path_map.push(PathRecord {
staging: "bread".to_string(),
original: tilde(cfg_dir),
is_file: false,
});
// 2. Built-in + delegate configs → configs/<name>/
let configs_dir = staging.join("configs");
for (name, raw_path) in BUILTIN_CONFIGS {
let src = expand_path(raw_path);
if src.exists() {
let dst = configs_dir.join(name);
sync_dir(&src, &dst, &excludes)
.with_context(|| format!("failed to snapshot {raw_path}"))?;
path_map.push(PathRecord {
staging: format!("configs/{name}"),
original: raw_path.to_string(),
is_file: false,
});
included_configs.push(name.to_string());
}
}
let delegate_paths = crate::delegates::resolve_include_paths(&config.delegates.include);
for (basename, src_path) in &delegate_paths {
if src_path.exists() && !included_configs.contains(basename) {
let dst = configs_dir.join(basename);
sync_dir(src_path, &dst, &config.delegates.exclude)
.with_context(|| format!("failed to snapshot delegate {}", src_path.display()))?;
path_map.push(PathRecord {
staging: format!("configs/{basename}"),
original: tilde(src_path),
is_file: false,
});
included_configs.push(basename.clone());
}
}
// 3. Dotfiles → dotfiles/
let dotfiles_dir = staging.join("dotfiles");
fs::create_dir_all(&dotfiles_dir)?;
for (dest_name, raw_path) in BUILTIN_DOTFILES {
let src = expand_path(raw_path);
if src.exists() {
fs::copy(&src, dotfiles_dir.join(dest_name))
.with_context(|| format!("failed to copy {raw_path}"))?;
path_map.push(PathRecord {
staging: format!("dotfiles/{dest_name}"),
original: raw_path.to_string(),
is_file: true,
});
}
}
// 4. ~/.local/bin custom scripts → local-bin/
// Skip symlinks (point to installed binaries) and files >512 KB (compiled artifacts).
let local_bin_src = expand_path("~/.local/bin");
let local_bin_dst = staging.join("local-bin");
if local_bin_src.exists() {
fs::create_dir_all(&local_bin_dst)?;
let mut any = false;
for entry in fs::read_dir(&local_bin_src).context("failed to read ~/.local/bin")? {
let entry = entry?;
let meta = entry.metadata()?;
if meta.file_type().is_symlink() || meta.len() > 512 * 1024 {
continue;
}
let path = entry.path();
if path.is_file() {
let name = path.file_name().unwrap().to_string_lossy().to_string();
fs::copy(&path, local_bin_dst.join(&name))?;
any = true;
}
}
if any {
path_map.push(PathRecord {
staging: "local-bin".to_string(),
original: "~/.local/bin".to_string(),
is_file: false,
});
}
}
// 5. ~/.local/share/fonts → local-fonts/
let fonts_src = expand_path("~/.local/share/fonts");
let fonts_dst = staging.join("local-fonts");
if fonts_src.exists() {
sync_dir(&fonts_src, &fonts_dst, &excludes)
.context("failed to snapshot fonts")?;
path_map.push(PathRecord {
staging: "local-fonts".to_string(),
original: "~/.local/share/fonts".to_string(),
is_file: false,
});
}
// 7. ~/.config/systemd/user → systemd/
let systemd_src = expand_path("~/.config/systemd/user");
let systemd_dst = staging.join("systemd");
if systemd_src.exists() {
sync_dir(&systemd_src, &systemd_dst, &excludes)
.context("failed to snapshot systemd user units")?;
path_map.push(PathRecord {
staging: "systemd".to_string(),
original: "~/.config/systemd/user".to_string(),
is_file: false,
});
}
// 8. System configs → system/ (read-only; restore needs sudo)
let system_dst = staging.join("system");
let mut has_system = false;
for (name, raw_path) in SYSTEM_PATHS {
let src = PathBuf::from(raw_path);
if !src.exists() {
continue;
}
match sync_dir(&src, &system_dst.join(name), &excludes) {
Ok(_) => has_system = true,
Err(e) => {
let msg = e.to_string();
if msg.contains("Permission denied") || msg.contains("permission denied") {
eprintln!(
"bread: warning: {raw_path} requires sudo to export (skipping — re-run with sudo to include)"
);
} else {
eprintln!("bread: warning: failed to snapshot {raw_path}: {e}");
}
}
}
}
// 9. Package snapshots → packages/
let packages_dir = staging.join("packages");
let mut included_managers: Vec<String> = Vec::new();
if config.packages.enabled {
for manager in &config.packages.managers {
let dest_file = packages_dir.join(format!("{manager}.txt"));
match packages::snapshot(manager, &dest_file) {
Ok(true) => included_managers.push(manager.clone()),
Ok(false) => {}
Err(e) => eprintln!(
"bread: warning: package snapshot for {manager} failed: {e}"
),
}
}
}
// 10. Machine profile → machines/
let machines_dir = staging.join("machines");
MachineProfile::new(config.machine.name.clone(), config.machine.tags.clone())
.write(&machines_dir)?;
// 11. Git repositories — find all repos with a remote, commit+push each
let nc_dirs = nextcloud_sync_dirs(&home);
if !nc_dirs.is_empty() {
let labels: Vec<_> = nc_dirs.iter()
.map(|p| p.strip_prefix(&home).map(|r| format!("~/{}", r.display())).unwrap_or_else(|_| p.display().to_string()))
.collect();
eprintln!("bread: skipping Nextcloud-tracked folders: {}", labels.join(", "));
}
let repos = find_git_repos(&home);
commit_and_push_repos(&repos, &home);
// 12. Manifest
let manifest = ExportManifest {
version: 2,
machine: config.machine.name.clone(),
hostname: hostname(),
exported_at: Utc::now().to_rfc3339(),
path_map,
configs: included_configs,
repos,
system: has_system,
packages: included_managers,
bread: true,
dotfiles: vec![],
local_bin: vec![],
systemd_units: vec![],
};
fs::write(
staging.join("manifest.toml"),
toml::to_string_pretty(&manifest).context("failed to serialize manifest")?,
)?;
// 11. restore.sh
let restore_path = staging.join("restore.sh");
fs::write(&restore_path, generate_restore_sh(&manifest))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&restore_path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&restore_path, perms)?;
}
Ok(manifest)
}
// ── apply_import ────────────────────────────────────────────────────────────
/// Apply a staged snapshot directory to this machine.
/// Returns a list of human-readable descriptions of what was applied.
pub fn apply_import(
staging: &Path,
cfg_dir: &Path,
install_packages: bool,
clone_repos: bool,
) -> Result<Vec<String>> {
let mut applied: Vec<String> = Vec::new();
// Read manifest to get the path map
let manifest_path = staging.join("manifest.toml");
let path_map: Vec<PathRecord> = if manifest_path.exists() {
let raw = fs::read_to_string(&manifest_path)?;
toml::from_str::<ExportManifest>(&raw)
.map(|m| m.path_map)
.unwrap_or_default()
} else {
vec![]
};
if !path_map.is_empty() {
// Manifest-driven restore: use path_map for exact original locations
for record in &path_map {
let src = staging.join(&record.staging);
if !src.exists() {
continue;
}
let dst = expand_path(&record.original);
if record.is_file {
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
// Secure directory permissions for SSH
if record.staging.contains("ssh_config") {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Some(p) = dst.parent() {
if let Ok(m) = fs::metadata(p) {
let mut perms = m.permissions();
perms.set_mode(0o700);
let _ = fs::set_permissions(p, perms);
}
}
}
}
fs::copy(&src, &dst)
.with_context(|| format!("failed to restore {}", record.original))?;
applied.push(record.original.clone());
} else {
sync_dir(&src, &dst, &[])
.with_context(|| format!("failed to restore {}", record.original))?;
applied.push(record.original.clone());
// Reload systemd if this was the systemd dir
if record.staging == "systemd" {
let _ = std::process::Command::new("systemctl")
.args(["--user", "daemon-reload"])
.status();
}
// Rebuild font cache after restoring fonts
if record.staging == "local-fonts" {
let _ = std::process::Command::new("fc-cache").arg("-f").status();
}
// Make local-bin scripts executable
if record.staging == "local-bin" {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(entries) = fs::read_dir(&dst) {
for entry in entries.filter_map(|e| e.ok()) {
if entry.path().is_file() {
if let Ok(m) = fs::metadata(entry.path()) {
let mut perms = m.permissions();
perms.set_mode(perms.mode() | 0o111);
let _ = fs::set_permissions(entry.path(), perms);
}
}
}
}
}
}
}
}
} else {
// Legacy fallback for v1 exports without path_map
let bread_src = staging.join("bread");
if bread_src.exists() {
sync_dir(&bread_src, cfg_dir, &[])?;
applied.push("~/.config/bread".to_string());
}
let configs_dir = staging.join("configs");
if configs_dir.exists() {
let config_home = expand_path("~/.config");
for entry in fs::read_dir(&configs_dir)?.filter_map(|e| e.ok()) {
let src = entry.path();
if src.is_dir() {
let name = src.file_name().unwrap().to_string_lossy().to_string();
sync_dir(&src, &config_home.join(&name), &[])?;
applied.push(format!("~/.config/{name}"));
}
}
}
}
// Package installs
if install_packages {
let packages_dir = staging.join("packages");
if packages_dir.exists() {
install_packages_from(&packages_dir)?;
applied.push("packages installed".to_string());
}
}
// Clone git repos
if clone_repos {
let manifest_path = staging.join("manifest.toml");
if manifest_path.exists() {
let raw = fs::read_to_string(&manifest_path)?;
if let Ok(manifest) = toml::from_str::<ExportManifest>(&raw) {
let home = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap_or_default()));
for repo in &manifest.repos {
let dest = home.join(&repo.path);
if dest.exists() {
applied.push(format!("skip (exists): ~/{}", repo.path));
continue;
}
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
eprint!(" cloning ~/{} ... ", repo.path);
let status = std::process::Command::new("git")
.args(["clone", "--branch", &repo.branch, &repo.remote])
.arg(&dest)
.status();
match status {
Ok(s) if s.success() => {
eprintln!("done");
applied.push(format!("cloned ~/{}", repo.path));
}
_ => {
eprintln!("failed");
applied.push(format!("clone failed: ~/{}", repo.path));
}
}
}
}
}
}
Ok(applied)
}
// ── commit_and_push_repos ───────────────────────────────────────────────────
fn commit_and_push_repos(repos: &[GitRepoRecord], home: &Path) {
if repos.is_empty() {
return;
}
eprintln!("bread: committing and pushing {} repo(s)...", repos.len());
for repo in repos {
let dir = home.join(&repo.path);
let dir_str = dir.to_string_lossy();
// Stage all changes
let add = std::process::Command::new("git")
.args(["-C", &dir_str, "add", "-A"])
.output();
if add.map(|o| !o.status.success()).unwrap_or(true) {
eprintln!(" ~/{}: git add failed, skipping", repo.path);
continue;
}
// Check if there's anything staged
let has_changes = std::process::Command::new("git")
.args(["-C", &dir_str, "diff", "--cached", "--quiet"])
.status()
.map(|s| !s.success())
.unwrap_or(false);
if has_changes {
let commit = std::process::Command::new("git")
.args(["-C", &dir_str, "commit", "-m", "Commiting for bread sync"])
.output();
match commit {
Ok(o) if o.status.success() => {}
Ok(o) => {
eprintln!(
" ~/{}: commit failed: {}",
repo.path,
String::from_utf8_lossy(&o.stderr).trim()
);
continue;
}
Err(e) => {
eprintln!(" ~/{}: commit failed: {}", repo.path, e);
continue;
}
}
}
// Push
eprint!(" ~/{}: pushing... ", repo.path);
let push = std::process::Command::new("git")
.args(["-C", &dir_str, "push"])
.output();
match push {
Ok(o) if o.status.success() => eprintln!("ok"),
Ok(o) => eprintln!(
"failed: {}",
String::from_utf8_lossy(&o.stderr).trim()
),
Err(e) => eprintln!("failed: {}", e),
}
}
}
// ── find_git_repos ──────────────────────────────────────────────────────────
/// Read ~/.config/Nextcloud/nextcloud.cfg and return all configured local sync roots.
/// Always includes ~/Nextcloud if it exists, even without a config file.
fn nextcloud_sync_dirs(home: &Path) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = Vec::new();
let cfg = home.join(".config/Nextcloud/nextcloud.cfg");
if let Ok(content) = fs::read_to_string(&cfg) {
for line in content.lines() {
if let Some(raw) = line.trim().strip_prefix("localPath=") {
let p = PathBuf::from(raw);
let p = if p.is_absolute() { p } else { home.join(p) };
if !dirs.contains(&p) {
dirs.push(p);
}
}
}
}
// Always treat ~/Nextcloud as off-limits if it exists
let default_nc = home.join("Nextcloud");
if default_nc.exists() && !dirs.contains(&default_nc) {
dirs.push(default_nc);
}
dirs
}
fn find_git_repos(home: &Path) -> Vec<GitRepoRecord> {
let nc_dirs = nextcloud_sync_dirs(home);
let mut repos: Vec<GitRepoRecord> = Vec::new();
// Home root at depth 1 only (e.g. ~/bread, ~/yay, ~/colorshell)
walk_repos(home, home, 0, 1, &mut repos, &nc_dirs);
// Deeper search in common project directories
for subdir in &["Projects", "Documents", "src", "dev", "code", "repos", "builds"] {
let p = home.join(subdir);
if p.exists() {
walk_repos(&p, home, 0, 3, &mut repos, &nc_dirs);
}
}
// .config at depth 1 (e.g. ~/.config/hypr, ~/.config/wificonf)
let config_dir = home.join(".config");
if config_dir.exists() {
walk_repos(&config_dir, home, 0, 1, &mut repos, &nc_dirs);
}
// Deduplicate by path, sort for determinism
repos.sort_by(|a, b| a.path.cmp(&b.path));
repos.dedup_by(|a, b| a.path == b.path);
repos
}
fn walk_repos(dir: &Path, home: &Path, depth: u32, max_depth: u32, repos: &mut Vec<GitRepoRecord>, nc_dirs: &[PathBuf]) {
// Skip anything inside a Nextcloud sync root
if nc_dirs.iter().any(|nc| dir.starts_with(nc)) {
return;
}
if dir.join(".git").exists() {
if let Ok(repo) = Repository::open(dir) {
let remote_url = repo
.find_remote("origin")
.ok()
.and_then(|r| r.url().map(str::to_string));
if let Some(remote) = remote_url {
let branch = repo
.head()
.ok()
.and_then(|h| h.shorthand().map(str::to_string))
.unwrap_or_else(|| "main".to_string());
let rel = dir
.strip_prefix(home)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| dir.to_string_lossy().to_string());
repos.push(GitRepoRecord { path: rel, remote, branch });
}
}
return; // don't recurse into git repos (skip submodules)
}
if depth >= max_depth {
return;
}
if let Ok(entries) = fs::read_dir(dir) {
let mut entries: Vec<_> = entries.filter_map(|e| e.ok()).collect();
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = path.file_name().unwrap_or_default().to_string_lossy();
if GIT_SKIP_DIRS.contains(&name.as_ref()) {
continue;
}
walk_repos(&path, home, depth + 1, max_depth, repos, nc_dirs);
}
}
}
// ── package install ─────────────────────────────────────────────────────────
fn install_packages_from(packages_dir: &Path) -> Result<()> {
let pacman_file = packages_dir.join("pacman.txt");
if pacman_file.exists() {
let pkgs = packages::parse_pacman(&fs::read_to_string(&pacman_file)?);
if !pkgs.is_empty() {
eprintln!("bread: installing {} pacman packages...", pkgs.len());
let _ = std::process::Command::new("sudo")
.args(["pacman", "-S", "--needed"])
.args(&pkgs)
.status();
}
}
let cargo_file = packages_dir.join("cargo.txt");
if cargo_file.exists() {
for pkg in packages::parse_cargo(&fs::read_to_string(&cargo_file)?) {
let _ = std::process::Command::new("cargo").args(["install", &pkg]).status();
}
}
let pip_file = packages_dir.join("pip.txt");
if pip_file.exists() {
let _ = std::process::Command::new("pip")
.args(["install", "--user", "-r"])
.arg(&pip_file)
.status();
}
let npm_file = packages_dir.join("npm.txt");
if npm_file.exists() {
for pkg in packages::parse_npm(&fs::read_to_string(&npm_file)?) {
let _ = std::process::Command::new("npm").args(["install", "-g", &pkg]).status();
}
}
Ok(())
}
// ── restore.sh ───────────────────────────────────────────────────────────────
fn generate_restore_sh(manifest: &ExportManifest) -> String {
let ts = &manifest.exported_at[..16];
let mut s = String::new();
s.push_str("#!/bin/bash\n");
s.push_str("set -e\n");
s.push_str("cd \"$(dirname \"$0\")\"\n");
s.push_str("RESTORE_DIR=\"$(pwd)\"\n\n");
s.push_str(&format!(
"echo \"Restoring bread snapshot for {} ({})\"\n\n",
manifest.machine, ts
));
// Config dirs and dotfiles from path_map
let dirs: Vec<&PathRecord> = manifest.path_map.iter().filter(|r| !r.is_file).collect();
let files: Vec<&PathRecord> = manifest.path_map.iter().filter(|r| r.is_file).collect();
if !dirs.is_empty() {
s.push_str("# configs and directories\n");
for r in &dirs {
let dst = &r.original;
let src = &r.staging;
s.push_str(&format!("if [ -e \"$RESTORE_DIR/{src}\" ]; then\n"));
s.push_str(&format!(" mkdir -p \"{dst}\"\n"));
s.push_str(&format!(" cp -r \"$RESTORE_DIR/{src}/.\" \"{dst}/\"\n"));
if r.staging == "systemd" {
s.push_str(" systemctl --user daemon-reload\n");
}
if r.staging == "local-bin" {
s.push_str(" chmod +x \"${dst}\"/*\n");
}
s.push_str(&format!(" echo \"[OK] {dst}\"\n"));
s.push_str("fi\n");
}
s.push('\n');
}
if !files.is_empty() {
s.push_str("# dotfiles\n");
for r in &files {
let dst = &r.original;
let src = &r.staging;
s.push_str(&format!("if [ -f \"$RESTORE_DIR/{src}\" ]; then\n"));
if r.staging.contains("ssh_config") {
s.push_str(" mkdir -p ~/.ssh && chmod 700 ~/.ssh\n");
}
// Expand ~ in destination for shell
let dst_shell = dst.replace('~', "$HOME");
s.push_str(&format!(" cp \"$RESTORE_DIR/{src}\" \"{dst_shell}\"\n"));
s.push_str(&format!(" echo \"[OK] {dst}\"\n"));
s.push_str("fi\n");
}
s.push('\n');
}
// Packages
if !manifest.packages.is_empty() {
s.push_str("echo \"\"\n");
s.push_str("echo \"--- Package restore commands (not run automatically) ---\"\n");
if manifest.packages.contains(&"pacman".to_string()) {
s.push_str("echo \" pacman: awk '{print \\$1}' \\\"$RESTORE_DIR/packages/pacman.txt\\\" | sudo pacman -S --needed -\"\n");
}
if manifest.packages.contains(&"cargo".to_string()) {
s.push_str("echo \" cargo: grep -v '^ ' \\\"$RESTORE_DIR/packages/cargo.txt\\\" | awk '{print \\$1}' | xargs -I{} cargo install {}\"\n");
}
if manifest.packages.contains(&"pip".to_string()) {
s.push_str("echo \" pip: pip install --user -r \\\"$RESTORE_DIR/packages/pip.txt\\\"\"\n");
}
if manifest.packages.contains(&"npm".to_string()) {
s.push_str("echo \" npm: awk -F/ '{print \\$NF}' \\\"$RESTORE_DIR/packages/npm.txt\\\" | xargs npm install -g\"\n");
}
s.push('\n');
}
// System files
if manifest.system {
s.push_str("echo \"\"\n");
s.push_str("echo \"--- System files (require sudo, not applied automatically) ---\"\n");
s.push_str("if [ -d \"$RESTORE_DIR/system/udev\" ]; then\n");
s.push_str(" echo \" udev: sudo cp \\\"$RESTORE_DIR/system/udev/\\\"* /etc/udev/rules.d/ && sudo udevadm control --reload-rules\"\n");
s.push_str("fi\n");
s.push_str("if [ -d \"$RESTORE_DIR/system/modprobe\" ]; then\n");
s.push_str(" echo \" modprobe: sudo cp \\\"$RESTORE_DIR/system/modprobe/\\\"* /etc/modprobe.d/\"\n");
s.push_str("fi\n");
s.push_str("if [ -d \"$RESTORE_DIR/system/sysctl\" ]; then\n");
s.push_str(" echo \" sysctl: sudo cp \\\"$RESTORE_DIR/system/sysctl/\\\"* /etc/sysctl.d/ && sudo sysctl --system\"\n");
s.push_str("fi\n");
s.push_str("if [ -d \"$RESTORE_DIR/system/networkmanager\" ]; then\n");
s.push_str(" echo \" networkmanager: sudo cp \\\"$RESTORE_DIR/system/networkmanager/\\\"* /etc/NetworkManager/system-connections/ && sudo chmod 600 /etc/NetworkManager/system-connections/* && sudo systemctl restart NetworkManager\"\n");
s.push_str("fi\n");
s.push_str("if [ -d \"$RESTORE_DIR/system/bluetooth\" ]; then\n");
s.push_str(" echo \" bluetooth: sudo cp -r \\\"$RESTORE_DIR/system/bluetooth/\\\"* /var/lib/bluetooth/ && sudo systemctl restart bluetooth\"\n");
s.push_str("fi\n\n");
}
// Git repos
if !manifest.repos.is_empty() {
s.push_str("echo \"\"\n");
s.push_str("echo \"--- Git repositories ---\"\n");
for repo in &manifest.repos {
let dest = format!("$HOME/{}", repo.path);
let branch = &repo.branch;
let remote = &repo.remote;
// Create parent dir and clone; skip if already present
let parent = std::path::Path::new(&repo.path)
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
if !parent.is_empty() {
s.push_str(&format!("mkdir -p \"$HOME/{parent}\"\n"));
}
s.push_str(&format!(
"if [ ! -d \"{dest}/.git\" ]; then\n"
));
s.push_str(&format!(
" git clone --branch {branch} {remote} \"{dest}\" && echo \"[OK] ~/{}\"\n",
repo.path
));
s.push_str(&format!(
"else\n echo \"[skip] ~/{} (already exists)\"\nfi\n",
repo.path
));
}
}
s
}

364
bread-sync/src/git.rs Normal file
View file

@ -0,0 +1,364 @@
use anyhow::{Context, Result};
use git2::{
build::CheckoutBuilder, Cred, FetchOptions, IndexAddOption, PushOptions, RemoteCallbacks,
Repository, Signature, StatusOptions,
};
use std::path::{Path, PathBuf};
/// Wraps a git2 repository with sync-specific operations.
pub struct SyncRepo {
repo: Repository,
pub path: PathBuf,
}
impl SyncRepo {
/// Open an existing repository at `path`.
pub fn open(path: &Path) -> Result<Self> {
let repo = Repository::open(path)
.with_context(|| format!("failed to open git repo at {}", path.display()))?;
Ok(Self {
repo,
path: path.to_path_buf(),
})
}
/// Clone `url` into `path`.
pub fn clone_from(url: &str, path: &Path) -> Result<Self> {
let fetch_opts = make_fetch_options();
let mut builder = git2::build::RepoBuilder::new();
builder.fetch_options(fetch_opts);
let repo = builder
.clone(url, path)
.with_context(|| format!("failed to clone {} into {}", url, path.display()))?;
Ok(Self {
repo,
path: path.to_path_buf(),
})
}
/// Open the repo at `path` if it exists; otherwise clone from `url`.
pub fn open_or_clone(url: &str, path: &Path) -> Result<Self> {
if path.exists() {
Self::open(path)
} else {
std::fs::create_dir_all(path)?;
Self::clone_from(url, path)
}
}
/// Initialize a new empty repository at `path` with `main` as the initial branch.
pub fn init(path: &Path) -> Result<Self> {
std::fs::create_dir_all(path)?;
let mut opts = git2::RepositoryInitOptions::new();
opts.initial_head("main");
let repo = Repository::init_opts(path, &opts)
.with_context(|| format!("failed to init git repo at {}", path.display()))?;
Ok(Self {
repo,
path: path.to_path_buf(),
})
}
/// Stage all changes (equivalent to `git add -A`).
pub fn stage_all(&self) -> Result<()> {
let mut index = self.repo.index().context("failed to get git index")?;
index
.add_all(["*"].iter(), IndexAddOption::DEFAULT, None)
.context("failed to stage changes")?;
index.write().context("failed to write git index")?;
Ok(())
}
/// Create a commit. Returns `None` if there are no staged changes.
pub fn commit(&self, message: &str) -> Result<Option<git2::Oid>> {
self.stage_all()?;
let mut index = self.repo.index()?;
let tree_id = index.write_tree()?;
// Check if tree matches current HEAD (nothing to commit)
if let Ok(head) = self.repo.head() {
if let Ok(head_commit) = head.peel_to_commit() {
if head_commit.tree_id() == tree_id {
return Ok(None);
}
}
}
let tree = self.repo.find_tree(tree_id)?;
let sig = Signature::now("Bread Sync", "bread@localhost")?;
let oid = match self.repo.head() {
Ok(head) => {
let parent = head.peel_to_commit()?;
self.repo
.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])?
}
Err(_) => {
// First commit — no parents
self.repo
.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])?
}
};
Ok(Some(oid))
}
/// Push `branch` to `remote_name`.
pub fn push(&self, remote_name: &str, branch: &str) -> Result<()> {
let mut remote = self
.repo
.find_remote(remote_name)
.with_context(|| format!("remote '{}' not found", remote_name))?;
let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
let mut push_opts = PushOptions::new();
let callbacks = make_callbacks();
push_opts.remote_callbacks(callbacks);
remote
.push(&[refspec.as_str()], Some(&mut push_opts))
.context("git push failed")?;
Ok(())
}
/// Fetch `branch` from `remote_name` without merging.
pub fn fetch(&self, remote_name: &str, branch: &str) -> Result<()> {
let mut remote = self
.repo
.find_remote(remote_name)
.with_context(|| format!("remote '{}' not found", remote_name))?;
let mut fetch_opts = make_fetch_options();
remote
.fetch(&[branch], Some(&mut fetch_opts), None)
.context("git fetch failed")?;
Ok(())
}
/// Fetch and fast-forward merge. Errors on non-fast-forward.
pub fn pull(&self, remote_name: &str, branch: &str) -> Result<()> {
self.fetch(remote_name, branch)?;
let fetch_head = self
.repo
.find_reference("FETCH_HEAD")
.context("FETCH_HEAD not found after fetch")?;
let fetch_commit = self
.repo
.reference_to_annotated_commit(&fetch_head)
.context("failed to get annotated commit from FETCH_HEAD")?;
let (analysis, _) = self
.repo
.merge_analysis(&[&fetch_commit])
.context("merge analysis failed")?;
if analysis.is_up_to_date() {
return Ok(());
}
if analysis.is_fast_forward() {
let target_id = fetch_commit.id();
let ref_name = format!("refs/heads/{branch}");
match self.repo.find_reference(&ref_name) {
Ok(mut r) => {
r.set_target(target_id, "fast-forward pull")?;
}
Err(_) => {
self.repo
.reference(&ref_name, target_id, true, "fast-forward pull")?;
}
}
self.repo.set_head(&ref_name)?;
self.repo
.checkout_head(Some(CheckoutBuilder::default().force()))
.context("checkout failed during pull")?;
Ok(())
} else {
anyhow::bail!(
"bread: sync conflict — resolve manually in {}",
self.path.display()
)
}
}
/// Returns true if working tree has no uncommitted changes.
pub fn is_clean(&self) -> Result<bool> {
Ok(self.local_changes()?.is_empty())
}
/// Returns list of (status_char, path) for working-tree changes vs HEAD.
pub fn local_changes(&self) -> Result<Vec<(char, String)>> {
let mut status_opts = StatusOptions::new();
status_opts
.include_untracked(true)
.recurse_untracked_dirs(true);
let statuses = self
.repo
.statuses(Some(&mut status_opts))
.context("failed to get git status")?;
let mut out = Vec::new();
for entry in statuses.iter() {
let s = entry.status();
let ch = if s.contains(git2::Status::INDEX_NEW) || s.contains(git2::Status::WT_NEW) {
'A'
} else if s.contains(git2::Status::INDEX_DELETED)
|| s.contains(git2::Status::WT_DELETED)
{
'D'
} else {
'M'
};
if let Some(path) = entry.path() {
out.push((ch, path.to_string()));
}
}
Ok(out)
}
/// Returns list of (status_char, path) for changes on remote not yet pulled.
pub fn remote_changes(&self, remote_name: &str, branch: &str) -> Result<Vec<(char, String)>> {
// We compare HEAD to remote/branch
let remote_ref = format!("refs/remotes/{remote_name}/{branch}");
let remote_oid = match self.repo.find_reference(&remote_ref) {
Ok(r) => r.peel_to_commit()?.id(),
Err(_) => return Ok(vec![]),
};
let head_commit = match self.repo.head() {
Ok(h) => h.peel_to_commit()?.id(),
Err(_) => return Ok(vec![]),
};
if head_commit == remote_oid {
return Ok(vec![]);
}
let head_tree = self.repo.find_commit(head_commit)?.tree()?;
let remote_tree = self.repo.find_commit(remote_oid)?.tree()?;
let diff = self
.repo
.diff_tree_to_tree(Some(&head_tree), Some(&remote_tree), None)
.context("failed to compute remote diff")?;
let mut out = Vec::new();
for delta in diff.deltas() {
let ch = match delta.status() {
git2::Delta::Added => 'A',
git2::Delta::Deleted => 'D',
_ => 'M',
};
if let Some(path) = delta.new_file().path() {
out.push((ch, path.to_string_lossy().to_string()));
}
}
Ok(out)
}
/// Return a unified diff string of working tree vs HEAD.
pub fn working_diff(&self) -> Result<String> {
let head_tree = match self.repo.head() {
Ok(h) => Some(h.peel_to_tree()?),
Err(_) => None,
};
let diff = self
.repo
.diff_tree_to_workdir_with_index(head_tree.as_ref(), None)
.context("failed to compute working diff")?;
let mut out = String::new();
diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
let prefix = match line.origin() {
'+' | '-' | ' ' => line.origin().to_string(),
_ => String::new(),
};
out.push_str(&prefix);
if let Ok(s) = std::str::from_utf8(line.content()) {
out.push_str(s);
}
true
})
.context("failed to format diff")?;
Ok(out)
}
/// Return a unified diff string between HEAD and remote branch HEAD.
pub fn remote_diff(&self, remote_name: &str, branch: &str) -> Result<String> {
let remote_ref = format!("refs/remotes/{remote_name}/{branch}");
let remote_oid = self
.repo
.find_reference(&remote_ref)
.and_then(|r| r.peel_to_commit())
.map(|c| c.id())
.ok();
let head_tree = match self.repo.head() {
Ok(h) => Some(h.peel_to_tree()?),
Err(_) => None,
};
let remote_tree = remote_oid
.and_then(|id| self.repo.find_commit(id).ok())
.and_then(|c| c.tree().ok());
let diff = self
.repo
.diff_tree_to_tree(head_tree.as_ref(), remote_tree.as_ref(), None)
.context("failed to compute remote diff")?;
let mut out = String::new();
diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
let prefix = match line.origin() {
'+' | '-' | ' ' => line.origin().to_string(),
_ => String::new(),
};
out.push_str(&prefix);
if let Ok(s) = std::str::from_utf8(line.content()) {
out.push_str(s);
}
true
})
.context("failed to format remote diff")?;
Ok(out)
}
/// Set a named remote.
pub fn set_remote(&self, name: &str, url: &str) -> Result<()> {
let _ = self.repo.remote_delete(name);
self.repo
.remote(name, url)
.with_context(|| format!("failed to set remote {name}"))?;
Ok(())
}
/// Return the timestamp of the last commit, or None if no commits.
pub fn last_commit_time(&self) -> Option<chrono::DateTime<chrono::Local>> {
let head = self.repo.head().ok()?;
let commit = head.peel_to_commit().ok()?;
let t = commit.time();
// git2::Time uses seconds-from-epoch and offset-in-minutes
let naive = chrono::DateTime::from_timestamp(t.seconds(), 0)?;
Some(naive.with_timezone(&chrono::Local))
}
}
fn make_callbacks<'a>() -> RemoteCallbacks<'a> {
let mut cb = RemoteCallbacks::new();
cb.credentials(|_url, username_from_url, allowed_types| {
if allowed_types.contains(git2::CredentialType::SSH_KEY) {
return Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"));
}
Cred::default()
});
cb
}
fn make_fetch_options<'a>() -> FetchOptions<'a> {
let mut opts = FetchOptions::new();
opts.remote_callbacks(make_callbacks());
opts
}

11
bread-sync/src/lib.rs Normal file
View file

@ -0,0 +1,11 @@
/// Bread sync: snapshot and restore system state via a Git remote.
pub mod config;
pub mod delegates;
pub mod export;
pub mod git;
pub mod machine;
pub mod packages;
pub use config::SyncConfig;
pub use export::{apply_import, stage_export, ExportManifest};
pub use git::SyncRepo;

167
bread-sync/src/machine.rs Normal file
View file

@ -0,0 +1,167 @@
use anyhow::{Context, Result};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
/// Machine profile stored in `machines/<name>.toml` in the sync repo.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MachineProfile {
pub name: String,
pub hostname: String,
pub tags: Vec<String>,
pub last_sync: String, // RFC 3339
}
impl MachineProfile {
/// Create a new profile for this machine.
pub fn new(name: String, tags: Vec<String>) -> Self {
Self {
hostname: hostname(),
name,
tags,
last_sync: Utc::now().to_rfc3339(),
}
}
/// Write this profile to `<machines_dir>/<name>.toml`.
pub fn write(&self, machines_dir: &Path) -> Result<()> {
fs::create_dir_all(machines_dir)?;
let path = machines_dir.join(format!("{}.toml", self.name));
let raw = toml::to_string_pretty(self).context("failed to serialize machine profile")?;
fs::write(&path, raw).with_context(|| format!("failed to write {}", path.display()))
}
/// Read a machine profile from `<machines_dir>/<name>.toml`.
pub fn read(machines_dir: &Path, name: &str) -> Result<Self> {
let path = machines_dir.join(format!("{name}.toml"));
let raw = fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
toml::from_str(&raw).context("failed to parse machine profile")
}
/// List all machine profiles in `machines_dir`.
pub fn list(machines_dir: &Path) -> Result<Vec<Self>> {
if !machines_dir.exists() {
return Ok(vec![]);
}
let mut out = Vec::new();
for entry in fs::read_dir(machines_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("toml") {
if let Ok(raw) = fs::read_to_string(&path) {
if let Ok(profile) = toml::from_str::<Self>(&raw) {
out.push(profile);
}
}
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
}
/// Return the system hostname.
pub fn hostname() -> String {
// Try gethostname via libc, fall back to environment variable.
let mut buf = [0u8; 256];
unsafe {
if libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len()) == 0 {
if let Ok(s) = std::ffi::CStr::from_ptr(buf.as_ptr() as *const libc::c_char).to_str() {
return s.to_string();
}
}
}
std::env::var("HOSTNAME")
.or_else(|_| std::env::var("HOST"))
.unwrap_or_else(|_| "unknown".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn write_creates_machines_dir_if_missing() {
let tmp = TempDir::new().unwrap();
let machines = tmp.path().join("does/not/exist/yet");
let profile = MachineProfile::new("host".to_string(), vec![]);
profile.write(&machines).unwrap();
assert!(machines.join("host.toml").exists());
}
#[test]
fn write_overwrites_existing_profile() {
let tmp = TempDir::new().unwrap();
let p1 = MachineProfile::new("host".to_string(), vec!["a".to_string()]);
p1.write(tmp.path()).unwrap();
let p2 = MachineProfile::new("host".to_string(), vec!["b".to_string(), "c".to_string()]);
p2.write(tmp.path()).unwrap();
let loaded = MachineProfile::read(tmp.path(), "host").unwrap();
assert_eq!(loaded.tags, vec!["b", "c"]);
}
#[test]
fn list_returns_empty_when_dir_missing() {
let tmp = TempDir::new().unwrap();
let missing = tmp.path().join("nope");
assert!(MachineProfile::list(&missing).unwrap().is_empty());
}
#[test]
fn list_returns_sorted_profiles_only_for_toml_files() {
let tmp = TempDir::new().unwrap();
MachineProfile::new("zebra".to_string(), vec![])
.write(tmp.path())
.unwrap();
MachineProfile::new("alpha".to_string(), vec![])
.write(tmp.path())
.unwrap();
MachineProfile::new("middle".to_string(), vec![])
.write(tmp.path())
.unwrap();
// Non-toml file should be ignored.
std::fs::write(tmp.path().join("notes.txt"), "ignored").unwrap();
let list = MachineProfile::list(tmp.path()).unwrap();
let names: Vec<&str> = list.iter().map(|m| m.name.as_str()).collect();
assert_eq!(names, vec!["alpha", "middle", "zebra"]);
}
#[test]
fn list_skips_invalid_toml_files_without_failing() {
let tmp = TempDir::new().unwrap();
MachineProfile::new("valid".to_string(), vec![])
.write(tmp.path())
.unwrap();
std::fs::write(tmp.path().join("garbage.toml"), "not valid [toml").unwrap();
let list = MachineProfile::list(tmp.path()).unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].name, "valid");
}
#[test]
fn read_returns_helpful_error_when_missing() {
let tmp = TempDir::new().unwrap();
let err = MachineProfile::read(tmp.path(), "ghost").unwrap_err();
assert!(err.to_string().contains("failed to read"));
}
#[test]
fn new_assigns_current_hostname_and_timestamp() {
let p = MachineProfile::new("h".to_string(), vec![]);
assert!(!p.hostname.is_empty());
assert!(chrono::DateTime::parse_from_rfc3339(&p.last_sync).is_ok());
}
#[test]
fn hostname_returns_non_empty_string() {
// Whether libc or env fallback fires, the result must be non-empty.
assert!(!hostname().is_empty());
}
}

257
bread-sync/src/packages.rs Normal file
View file

@ -0,0 +1,257 @@
use anyhow::Result;
use std::fs;
use std::path::Path;
use std::process::Command;
/// Snapshot a package manager's installed packages and write to `dest`.
/// Returns true if the snapshot was written, false if the package manager
/// is not installed (warns instead of failing).
pub fn snapshot(manager: &str, dest: &Path) -> Result<bool> {
let content = match manager {
"pacman" => run_pacman()?,
"aur" => run_aur()?,
"pip" => run_pip()?,
"npm" => run_npm()?,
"cargo" => run_cargo()?,
other => {
eprintln!("bread: unknown package manager '{}', skipping", other);
return Ok(false);
}
};
let Some(content) = content else {
eprintln!("bread: package manager '{}' not found, skipping", manager);
return Ok(false);
};
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
fs::write(dest, content)?;
Ok(true)
}
/// Parse a pacman snapshot (one "name version" per line, space-separated) and
/// return a list of package names.
pub fn parse_pacman(content: &str) -> Vec<String> {
content
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| l.split_whitespace().next().unwrap_or(l).to_string())
.collect()
}
/// Parse a pip freeze snapshot and return package names.
pub fn parse_pip(content: &str) -> Vec<String> {
content
.lines()
.filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
.map(|l| {
l.split("==")
.next()
.unwrap_or(l)
.split(">=")
.next()
.unwrap_or(l)
.trim()
.to_string()
})
.collect()
}
/// Parse npm global packages list (parseable format, one path per line).
pub fn parse_npm(content: &str) -> Vec<String> {
content
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| {
// `npm list -g --parseable` outputs paths like /usr/lib/node_modules/pkg
let name = Path::new(l)
.file_name()
.map(|n| n.to_string_lossy().to_string())?;
// Skip npm itself and the root node_modules
if name == "node_modules" {
return None;
}
Some(name)
})
.collect()
}
/// Parse cargo install list.
/// Format: "crate v1.2.3 (some-path):\n binary\n..."
pub fn parse_cargo(content: &str) -> Vec<String> {
content
.lines()
.filter(|l| !l.starts_with(' ') && !l.trim().is_empty())
.map(|l| l.split_whitespace().next().unwrap_or(l).to_string())
.collect()
}
fn run_aur() -> Result<Option<String>> {
match Command::new("pacman").arg("-Qm").output() {
Ok(out) if out.status.success() => {
Ok(Some(String::from_utf8_lossy(&out.stdout).to_string()))
}
Ok(_) => Ok(None),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
fn run_pacman() -> Result<Option<String>> {
match Command::new("pacman").arg("-Qe").output() {
Ok(out) if out.status.success() => {
Ok(Some(String::from_utf8_lossy(&out.stdout).to_string()))
}
Ok(_) => Ok(None),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
fn run_pip() -> Result<Option<String>> {
// Try pip3 first, then pip
for cmd in ["pip3", "pip"] {
match Command::new(cmd)
.args(["list", "--user", "--format=freeze"])
.output()
{
Ok(out) if out.status.success() => {
return Ok(Some(String::from_utf8_lossy(&out.stdout).to_string()))
}
Ok(_) => continue,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(e.into()),
}
}
Ok(None)
}
fn run_npm() -> Result<Option<String>> {
match Command::new("npm")
.args(["list", "-g", "--depth=0", "--parseable"])
.output()
{
Ok(out) if out.status.success() => {
Ok(Some(String::from_utf8_lossy(&out.stdout).to_string()))
}
Ok(_) => Ok(None),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
fn run_cargo() -> Result<Option<String>> {
match Command::new("cargo").args(["install", "--list"]).output() {
Ok(out) if out.status.success() => {
Ok(Some(String::from_utf8_lossy(&out.stdout).to_string()))
}
Ok(_) => Ok(None),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
// ─── parse_pacman ─────────────────────────────────────────────────────
#[test]
fn pacman_parses_each_line_to_first_field() {
let input = "firefox 128.0-1\ncurl 8.7.1-1\nrustup 1.27.1-1\n";
assert_eq!(parse_pacman(input), vec!["firefox", "curl", "rustup"]);
}
#[test]
fn pacman_skips_blank_lines() {
let input = "firefox 1\n\n \ncurl 2\n";
assert_eq!(parse_pacman(input), vec!["firefox", "curl"]);
}
#[test]
fn pacman_handles_empty_input() {
assert!(parse_pacman("").is_empty());
assert!(parse_pacman("\n\n\n").is_empty());
}
#[test]
fn pacman_handles_single_token_lines() {
// A line with no version still yields the package name.
assert_eq!(parse_pacman("firefox\n"), vec!["firefox"]);
}
// ─── parse_pip ────────────────────────────────────────────────────────
#[test]
fn pip_strips_eq_and_ge_specifiers() {
let input = "requests==2.32.3\nnumpy==2.0.1\nblack>=24.0\n";
assert_eq!(parse_pip(input), vec!["requests", "numpy", "black"]);
}
#[test]
fn pip_skips_comments_and_blank_lines() {
let input = "# editable install\n\nflake8==1.0\n# trailing\n";
assert_eq!(parse_pip(input), vec!["flake8"]);
}
#[test]
fn pip_handles_package_without_specifier() {
assert_eq!(parse_pip("requests\nblack\n"), vec!["requests", "black"]);
}
// ─── parse_npm ────────────────────────────────────────────────────────
#[test]
fn npm_extracts_basename_from_paths() {
let input = "/usr/lib/node_modules/npm\n/usr/lib/node_modules/typescript\n/usr/lib/node_modules/yarn\n";
let pkgs = parse_npm(input);
assert!(pkgs.contains(&"npm".to_string()));
assert!(pkgs.contains(&"typescript".to_string()));
assert!(pkgs.contains(&"yarn".to_string()));
}
#[test]
fn npm_skips_root_node_modules_entry() {
let input = "/usr/lib/node_modules\n/usr/lib/node_modules/typescript\n";
assert_eq!(parse_npm(input), vec!["typescript"]);
}
#[test]
fn npm_handles_empty_input() {
assert!(parse_npm("").is_empty());
}
// ─── parse_cargo ──────────────────────────────────────────────────────
#[test]
fn cargo_extracts_crate_names_from_install_list_output() {
let input = "bottom v0.9.6:\n btm\nripgrep v14.0.3:\n rg\nbat v0.24.0:\n bat\n";
assert_eq!(parse_cargo(input), vec!["bottom", "ripgrep", "bat"]);
}
#[test]
fn cargo_skips_binary_lines() {
// Indented lines are binaries inside a crate.
let input = "alpha v1.0.0:\n bin1\n bin2\nbeta v2.0.0:\n bin3\n";
assert_eq!(parse_cargo(input), vec!["alpha", "beta"]);
}
#[test]
fn cargo_handles_empty_input() {
assert!(parse_cargo("").is_empty());
}
// ─── snapshot dispatch ────────────────────────────────────────────────
#[test]
fn snapshot_unknown_manager_returns_false_without_writing() {
let tmp = tempfile::TempDir::new().unwrap();
let dest = tmp.path().join("out.txt");
let wrote = snapshot("definitely-not-a-pkg-mgr", &dest).unwrap();
assert!(!wrote);
assert!(!dest.exists());
}
}

482
bread-sync/tests/sync.rs Normal file
View file

@ -0,0 +1,482 @@
use bread_sync::{
config::{DelegatesConfig, MachineConfig, PackagesConfig, RemoteConfig, SyncConfig},
delegates, machine, packages, SyncRepo,
};
use std::fs;
use tempfile::TempDir;
fn make_bare_repo(path: &std::path::Path) -> git2::Repository {
let mut opts = git2::RepositoryInitOptions::new();
opts.bare(true);
opts.initial_head("main");
git2::Repository::init_opts(path, &opts).unwrap()
}
// Helper to create a git commit in a non-bare repo so we have initial state
fn init_repo_with_commit(path: &std::path::Path) -> SyncRepo {
let repo = SyncRepo::init(path).unwrap();
fs::write(path.join(".gitkeep"), "").unwrap();
repo.stage_all().unwrap();
repo.commit("initial commit").unwrap();
repo
}
#[test]
fn sync_init_creates_toml_with_required_fields() {
let tmp = TempDir::new().unwrap();
let config = SyncConfig {
remote: RemoteConfig {
url: "git@github.com:test/sync.git".to_string(),
branch: "main".to_string(),
},
machine: MachineConfig {
name: "testbox".to_string(),
tags: vec!["mobile".to_string()],
},
packages: PackagesConfig::default(),
delegates: DelegatesConfig::default(),
};
config.save(tmp.path()).unwrap();
let loaded = SyncConfig::load(tmp.path()).unwrap();
assert_eq!(loaded.remote.url, "git@github.com:test/sync.git");
assert_eq!(loaded.remote.branch, "main");
assert_eq!(loaded.machine.name, "testbox");
assert_eq!(loaded.machine.tags, vec!["mobile"]);
}
#[test]
fn sync_init_errors_if_already_initialized() {
let tmp = TempDir::new().unwrap();
let config = SyncConfig {
remote: RemoteConfig {
url: "git@github.com:test/sync.git".to_string(),
branch: "main".to_string(),
},
machine: MachineConfig {
name: "box".to_string(),
tags: vec![],
},
packages: PackagesConfig::default(),
delegates: DelegatesConfig::default(),
};
config.save(tmp.path()).unwrap();
// Second load should succeed (init itself must check for existence externally)
// We test that load works
let result = SyncConfig::load(tmp.path());
assert!(result.is_ok());
// sync.toml now exists — the CLI checks this before calling save
assert!(tmp.path().join("sync.toml").exists());
}
#[test]
fn sync_push_creates_correct_directory_structure() {
let repo_tmp = TempDir::new().unwrap();
let bare_tmp = TempDir::new().unwrap();
let bread_cfg_tmp = TempDir::new().unwrap();
// Create initial bare remote
let _bare = make_bare_repo(bare_tmp.path());
// Create local bread config
fs::write(bread_cfg_tmp.path().join("init.lua"), "-- init\n").unwrap();
// Init local sync repo
let repo = SyncRepo::init(repo_tmp.path()).unwrap();
repo.set_remote("origin", bare_tmp.path().to_str().unwrap())
.unwrap();
// Snapshot bread dir
let bread_dest = repo_tmp.path().join("bread");
delegates::sync_dir(bread_cfg_tmp.path(), &bread_dest, &[]).unwrap();
// Write machine profile
let machines_dir = repo_tmp.path().join("machines");
let profile = machine::MachineProfile::new("testbox".to_string(), vec![]);
profile.write(&machines_dir).unwrap();
// Commit and push
repo.commit("sync: testbox").unwrap();
repo.push("origin", "main").unwrap();
// Verify structure in local repo
assert!(repo_tmp.path().join("bread").exists());
assert!(repo_tmp.path().join("bread").join("init.lua").exists());
assert!(repo_tmp
.path()
.join("machines")
.join("testbox.toml")
.exists());
}
#[test]
fn sync_push_snapshots_bread_config() {
let repo_tmp = TempDir::new().unwrap();
let bare_tmp = TempDir::new().unwrap();
let bread_cfg_tmp = TempDir::new().unwrap();
make_bare_repo(bare_tmp.path());
// Create a more complex bread config
fs::create_dir_all(bread_cfg_tmp.path().join("modules/mymod")).unwrap();
fs::write(bread_cfg_tmp.path().join("init.lua"), "-- init").unwrap();
fs::write(
bread_cfg_tmp.path().join("modules/mymod/init.lua"),
"-- mymod",
)
.unwrap();
let repo = SyncRepo::init(repo_tmp.path()).unwrap();
repo.set_remote("origin", bare_tmp.path().to_str().unwrap())
.unwrap();
let bread_dest = repo_tmp.path().join("bread");
delegates::sync_dir(bread_cfg_tmp.path(), &bread_dest, &[]).unwrap();
repo.commit("sync: testbox").unwrap();
repo.push("origin", "main").unwrap();
// Verify files were copied
assert!(bread_dest.join("init.lua").exists());
assert!(bread_dest.join("modules/mymod/init.lua").exists());
let content = fs::read_to_string(bread_dest.join("init.lua")).unwrap();
assert_eq!(content, "-- init");
}
#[test]
fn sync_pull_copies_files_from_repo() {
let bare_tmp = TempDir::new().unwrap();
let local_tmp = TempDir::new().unwrap();
let apply_tmp = TempDir::new().unwrap();
make_bare_repo(bare_tmp.path());
// Create a local repo, add some files, push to bare
let repo = SyncRepo::init(local_tmp.path()).unwrap();
repo.set_remote("origin", bare_tmp.path().to_str().unwrap())
.unwrap();
let bread_dest = local_tmp.path().join("bread");
fs::create_dir_all(&bread_dest).unwrap();
fs::write(bread_dest.join("init.lua"), "-- from sync").unwrap();
repo.commit("sync: first push").unwrap();
repo.push("origin", "main").unwrap();
// Now clone the bare repo and pull
let clone_tmp = TempDir::new().unwrap();
let _cloned =
SyncRepo::clone_from(bare_tmp.path().to_str().unwrap(), clone_tmp.path()).unwrap();
// Apply bread/ to apply_tmp
let src = clone_tmp.path().join("bread");
if src.exists() {
delegates::sync_dir(&src, apply_tmp.path(), &[]).unwrap();
}
assert!(apply_tmp.path().join("init.lua").exists());
let content = fs::read_to_string(apply_tmp.path().join("init.lua")).unwrap();
assert_eq!(content, "-- from sync");
}
#[test]
fn package_manifest_pacman_parses_output_correctly() {
let input = "firefox 128.0-1\ncurl 8.7.1-1\nrustup 1.27.1-1\n";
let pkgs = packages::parse_pacman(input);
assert_eq!(pkgs, vec!["firefox", "curl", "rustup"]);
}
#[test]
fn package_manifest_pip_parses_output_correctly() {
let input = "requests==2.32.3\nnumpy==2.0.1\nblack>=24.0\n";
let pkgs = packages::parse_pip(input);
assert_eq!(pkgs, vec!["requests", "numpy", "black"]);
}
#[test]
fn delegates_exclude_globs_filter_correctly() {
let src_tmp = TempDir::new().unwrap();
let dst_tmp = TempDir::new().unwrap();
// Create files that should and shouldn't be copied
fs::create_dir_all(src_tmp.path().join(".git/objects")).unwrap();
fs::write(src_tmp.path().join(".git/objects/abc"), "").unwrap();
fs::create_dir_all(src_tmp.path().join("lua")).unwrap();
fs::write(src_tmp.path().join("lua/init.lua"), "-- ok").unwrap();
fs::write(src_tmp.path().join("log.cache"), "cached").unwrap();
let excludes = vec!["**/.git".to_string(), "**/*.cache".to_string()];
delegates::sync_dir(src_tmp.path(), dst_tmp.path(), &excludes).unwrap();
assert!(dst_tmp.path().join("lua/init.lua").exists());
assert!(!dst_tmp.path().join(".git").exists());
assert!(!dst_tmp.path().join("log.cache").exists());
}
#[test]
fn machine_profile_written_with_correct_fields() {
let machines_tmp = TempDir::new().unwrap();
let profile = machine::MachineProfile::new(
"myhost".to_string(),
vec!["mobile".to_string(), "battery".to_string()],
);
profile.write(machines_tmp.path()).unwrap();
let loaded = machine::MachineProfile::read(machines_tmp.path(), "myhost").unwrap();
assert_eq!(loaded.name, "myhost");
assert_eq!(loaded.tags, vec!["mobile", "battery"]);
assert!(!loaded.hostname.is_empty());
// last_sync must be valid RFC 3339
let parsed = chrono::DateTime::parse_from_rfc3339(&loaded.last_sync);
assert!(
parsed.is_ok(),
"last_sync '{}' is not valid RFC 3339",
loaded.last_sync
);
}
#[test]
fn status_shows_no_changes_when_clean() {
let repo_tmp = TempDir::new().unwrap();
let repo = init_repo_with_commit(repo_tmp.path());
let changes = repo.local_changes().unwrap();
assert!(
changes.is_empty(),
"expected no local changes, got: {:?}",
changes
);
assert!(repo.is_clean().unwrap());
}
#[test]
fn push_with_no_changes_returns_none() {
let repo_tmp = TempDir::new().unwrap();
let repo = init_repo_with_commit(repo_tmp.path());
// No new changes — commit should return None
let result = repo.commit("second commit").unwrap();
assert!(
result.is_none(),
"expected None (nothing to commit), got: {:?}",
result
);
}
// ─── git.rs additional coverage ────────────────────────────────────────────
#[test]
fn init_creates_repo_with_main_branch() {
let tmp = TempDir::new().unwrap();
let repo = SyncRepo::init(tmp.path()).unwrap();
fs::write(tmp.path().join("x"), "").unwrap();
repo.stage_all().unwrap();
let oid = repo.commit("initial").unwrap();
assert!(oid.is_some(), "first commit should succeed");
// Verify HEAD is on refs/heads/main.
let head_ref = std::process::Command::new("git")
.args(["-C", tmp.path().to_str().unwrap(), "symbolic-ref", "HEAD"])
.output()
.unwrap();
let head_name = String::from_utf8_lossy(&head_ref.stdout);
assert!(
head_name.trim() == "refs/heads/main",
"expected refs/heads/main, got {head_name}"
);
}
#[test]
fn open_or_clone_opens_existing_repo() {
let tmp = TempDir::new().unwrap();
SyncRepo::init(tmp.path()).unwrap();
// Calling open_or_clone on an existing path must not attempt to clone.
let again = SyncRepo::open_or_clone("/nonexistent-url-that-would-fail", tmp.path());
assert!(again.is_ok());
}
#[test]
fn open_or_clone_clones_into_missing_path() {
let bare = TempDir::new().unwrap();
let bare_repo = make_bare_repo(bare.path());
// Seed the bare repo with at least one commit so a clone is meaningful.
let local = TempDir::new().unwrap();
let repo = SyncRepo::init(local.path()).unwrap();
fs::write(local.path().join("seed"), "x").unwrap();
repo.commit("seed").unwrap();
repo.set_remote("origin", bare.path().to_str().unwrap())
.unwrap();
repo.push("origin", "main").unwrap();
drop(bare_repo);
let dest_parent = TempDir::new().unwrap();
let dest = dest_parent.path().join("clone-target");
let cloned = SyncRepo::open_or_clone(bare.path().to_str().unwrap(), &dest).unwrap();
assert_eq!(cloned.path, dest);
assert!(dest.join("seed").exists());
}
#[test]
fn local_changes_reports_new_modified_and_deleted() {
let tmp = TempDir::new().unwrap();
let repo = init_repo_with_commit(tmp.path());
fs::write(tmp.path().join("added.txt"), "new").unwrap();
fs::write(tmp.path().join(".gitkeep"), "modified").unwrap();
let changes = repo.local_changes().unwrap();
assert!(!changes.is_empty());
let kinds: Vec<char> = changes.iter().map(|(c, _)| *c).collect();
assert!(kinds.contains(&'A'));
assert!(kinds.contains(&'M'));
}
#[test]
fn is_clean_after_commit() {
let tmp = TempDir::new().unwrap();
let repo = init_repo_with_commit(tmp.path());
assert!(repo.is_clean().unwrap());
}
#[test]
fn working_diff_includes_modified_tracked_content() {
let tmp = TempDir::new().unwrap();
let repo = init_repo_with_commit(tmp.path());
// Modify an already-tracked file so it appears in `git diff HEAD`.
fs::write(tmp.path().join(".gitkeep"), "tracked change\n").unwrap();
let diff = repo.working_diff().unwrap();
assert!(
diff.contains("tracked change"),
"diff did not include tracked change, diff was: {diff:?}"
);
}
#[test]
fn working_diff_empty_when_only_untracked_files() {
let tmp = TempDir::new().unwrap();
let repo = init_repo_with_commit(tmp.path());
fs::write(tmp.path().join("new-untracked.txt"), "hi").unwrap();
// working_diff uses diff_tree_to_workdir_with_index without INCLUDE_UNTRACKED,
// so untracked files don't appear — local_changes is the right tool for that.
let diff = repo.working_diff().unwrap();
assert!(
diff.is_empty() || !diff.contains("new-untracked"),
"expected untracked file to be excluded, diff was: {diff:?}"
);
}
#[test]
fn set_remote_overwrites_existing_remote() {
let tmp = TempDir::new().unwrap();
let repo = SyncRepo::init(tmp.path()).unwrap();
repo.set_remote("origin", "https://example.com/a.git")
.unwrap();
// A second call must not error out — it should replace the previous URL.
repo.set_remote("origin", "https://example.com/b.git")
.unwrap();
}
#[test]
fn last_commit_time_returns_none_for_empty_repo() {
let tmp = TempDir::new().unwrap();
let repo = SyncRepo::init(tmp.path()).unwrap();
assert!(repo.last_commit_time().is_none());
}
#[test]
fn last_commit_time_present_after_commit() {
let tmp = TempDir::new().unwrap();
let repo = init_repo_with_commit(tmp.path());
assert!(repo.last_commit_time().is_some());
}
#[test]
fn push_pull_round_trip_through_bare_remote() {
let bare = TempDir::new().unwrap();
make_bare_repo(bare.path());
// Push from author repo.
let author = TempDir::new().unwrap();
let r1 = SyncRepo::init(author.path()).unwrap();
r1.set_remote("origin", bare.path().to_str().unwrap())
.unwrap();
fs::write(author.path().join("note.txt"), "v1").unwrap();
r1.commit("v1").unwrap();
r1.push("origin", "main").unwrap();
// Clone into reader repo and confirm contents.
let reader_tmp = TempDir::new().unwrap();
let r2 = SyncRepo::clone_from(bare.path().to_str().unwrap(), reader_tmp.path()).unwrap();
assert_eq!(
fs::read_to_string(reader_tmp.path().join("note.txt")).unwrap(),
"v1"
);
// Author writes a second version and pushes.
fs::write(author.path().join("note.txt"), "v2").unwrap();
r1.commit("v2").unwrap();
r1.push("origin", "main").unwrap();
// Reader pulls and sees the new content.
r2.pull("origin", "main").unwrap();
assert_eq!(
fs::read_to_string(reader_tmp.path().join("note.txt")).unwrap(),
"v2"
);
}
#[test]
fn pull_with_no_remote_changes_is_noop() {
let bare = TempDir::new().unwrap();
make_bare_repo(bare.path());
let local = TempDir::new().unwrap();
let repo = SyncRepo::init(local.path()).unwrap();
repo.set_remote("origin", bare.path().to_str().unwrap())
.unwrap();
fs::write(local.path().join("a"), "1").unwrap();
repo.commit("c1").unwrap();
repo.push("origin", "main").unwrap();
// Calling pull immediately after push must be up-to-date and succeed.
repo.pull("origin", "main").unwrap();
assert!(repo.is_clean().unwrap());
}
#[test]
fn remote_changes_returns_empty_when_remote_unknown() {
let tmp = TempDir::new().unwrap();
let repo = init_repo_with_commit(tmp.path());
let changes = repo.remote_changes("origin", "main").unwrap();
assert!(changes.is_empty());
}
// ─── machine list ──────────────────────────────────────────────────────────
#[test]
fn machine_list_returns_all_profiles_sorted() {
let machines_tmp = TempDir::new().unwrap();
for name in ["delta", "alpha", "charlie", "bravo"] {
machine::MachineProfile::new(name.to_string(), vec![])
.write(machines_tmp.path())
.unwrap();
}
let list = machine::MachineProfile::list(machines_tmp.path()).unwrap();
let names: Vec<&str> = list.iter().map(|m| m.name.as_str()).collect();
assert_eq!(names, vec!["alpha", "bravo", "charlie", "delta"]);
}
// ─── packages snapshot ─────────────────────────────────────────────────────
#[test]
fn snapshot_writes_destination_when_manager_unknown_is_skipped() {
let dest_tmp = TempDir::new().unwrap();
let dest = dest_tmp.path().join("nested/dir/file.txt");
let wrote = packages::snapshot("does-not-exist", &dest).unwrap();
assert!(!wrote);
assert!(!dest.exists());
}

View file

@ -1,10 +1,11 @@
[package] [package]
name = "breadd" name = "breadd"
version = "0.1.0" version = "1.0.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
bread-shared = { path = "../bread-shared" } bread-shared = { path = "../bread-shared" }
bread-sync = { path = "../bread-sync" }
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
tokio.workspace = true tokio.workspace = true
@ -14,10 +15,9 @@ tracing-subscriber.workspace = true
mlua = { version = "0.9", features = ["lua54", "vendored", "async", "serialize"] } mlua = { version = "0.9", features = ["lua54", "vendored", "async", "serialize"] }
async-trait = "0.1" async-trait = "0.1"
toml = "0.8" toml = "0.8"
udev = "0.9" udev = { version = "0.9", features = ["send"] }
rtnetlink = "0.9" rtnetlink = "0.9"
zbus = { version = "3.13", features = ["tokio"] } zbus = { version = "3.13", features = ["tokio"] }
hex = "0.4"
futures-util = "0.3" futures-util = "0.3"
netlink-packet-route = "0.11" netlink-packet-route = "0.11"
netlink-packet-core = "0.4" netlink-packet-core = "0.4"

View file

@ -0,0 +1,255 @@
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
use futures_util::StreamExt;
use serde_json::json;
use std::collections::HashMap;
use tokio::sync::mpsc;
use tracing::{debug, info};
use zbus::zvariant::{OwnedObjectPath, OwnedValue};
use zbus::{Message, MessageStream};
use super::Adapter;
#[derive(Clone, Debug)]
pub struct BluetoothAdapter;
impl BluetoothAdapter {
pub fn new() -> Self {
Self
}
/// Emit `bluetooth.enumerate` events for every device that is currently connected.
/// Errors are swallowed — Bluetooth hardware being absent is not a daemon startup failure.
pub async fn enumerate_existing(&self, tx: &mpsc::Sender<RawEvent>) {
match try_enumerate(tx).await {
Ok(n) => debug!("bluetooth enumerated {n} connected device(s)"),
Err(e) => debug!("bluetooth enumeration skipped: {e}"),
}
}
}
#[async_trait]
impl Adapter for BluetoothAdapter {
fn name(&self) -> &'static str {
"bluetooth"
}
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
info!("bluetooth adapter starting");
let conn = zbus::Connection::system()
.await
.map_err(|e| anyhow!("bluetooth D-Bus unavailable: {e}"))?;
let mut stream = MessageStream::from(&conn);
while let Some(result) = stream.next().await {
match result {
Ok(message) => {
if let Some(event) = parse_bluetooth_message(&message) {
if tx.send(event).await.is_err() {
return Ok(());
}
}
}
Err(e) => debug!("bluetooth stream error: {e}"),
}
}
Ok(())
}
}
async fn try_enumerate(tx: &mpsc::Sender<RawEvent>) -> Result<usize> {
let conn = zbus::Connection::system().await?;
let msg = conn
.call_method(
Some("org.bluez"),
"/",
Some("org.freedesktop.DBus.ObjectManager"),
"GetManagedObjects",
&(),
)
.await?;
let objects: HashMap<OwnedObjectPath, HashMap<String, HashMap<String, OwnedValue>>> =
msg.body()?;
let mut count = 0;
for (path, interfaces) in objects {
let Some(props) = interfaces.get("org.bluez.Device1") else {
continue;
};
let props_json = serde_json::to_value(props).unwrap_or_else(|_| json!({}));
if !props_json
.get("Connected")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
continue;
}
let name = props_json
.get("Name")
.or_else(|| props_json.get("Alias"))
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let address = props_json
.get("Address")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let _ = tx
.send(RawEvent {
source: AdapterSource::Bluetooth,
kind: "bluetooth.enumerate".to_string(),
payload: json!({
"path": path.as_str(),
"address": address,
"name": name,
"properties": props_json,
}),
timestamp: now_unix_ms(),
})
.await;
count += 1;
}
Ok(count)
}
fn parse_bluetooth_message(message: &Message) -> Option<RawEvent> {
let header = message.header().ok()?;
let interface = header.interface().ok()??.as_str().to_string();
let member = header.member().ok()??.as_str().to_string();
let path = header
.path()
.ok()
.flatten()
.map(|p| p.as_str().to_string())
.unwrap_or_default();
// Connected / disconnected — PropertiesChanged on a BlueZ device object
if interface == "org.freedesktop.DBus.Properties" && member == "PropertiesChanged" {
if !path.starts_with("/org/bluez/") {
return None;
}
let (iface, changed, _): (String, HashMap<String, OwnedValue>, Vec<String>) =
message.body().ok()?;
if iface != "org.bluez.Device1" {
return None;
}
let changed_json = serde_json::to_value(&changed).ok()?;
let connected = changed_json.get("Connected").and_then(|v| v.as_bool())?;
let address = address_from_path(&path);
let kind = if connected {
"bluetooth.device.connected"
} else {
"bluetooth.device.disconnected"
};
return Some(RawEvent {
source: AdapterSource::Bluetooth,
kind: kind.to_string(),
payload: json!({
"path": path,
"address": address,
"properties": changed_json,
}),
timestamp: now_unix_ms(),
});
}
// Device paired / discovered — InterfacesAdded from BlueZ ObjectManager
if interface == "org.freedesktop.DBus.ObjectManager" && member == "InterfacesAdded" {
let (obj_path, interfaces): (
OwnedObjectPath,
HashMap<String, HashMap<String, OwnedValue>>,
) = message.body().ok()?;
let obj_str = obj_path.as_str();
if !obj_str.starts_with("/org/bluez/") {
return None;
}
let props = interfaces.get("org.bluez.Device1")?;
let props_json = serde_json::to_value(props).ok()?;
let name = props_json
.get("Name")
.or_else(|| props_json.get("Alias"))
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let address = props_json
.get("Address")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| address_from_path(obj_str));
return Some(RawEvent {
source: AdapterSource::Bluetooth,
kind: "bluetooth.device.added".to_string(),
payload: json!({
"path": obj_str,
"address": address,
"name": name,
"properties": props_json,
}),
timestamp: now_unix_ms(),
});
}
// Device unpaired — InterfacesRemoved from BlueZ ObjectManager
if interface == "org.freedesktop.DBus.ObjectManager" && member == "InterfacesRemoved" {
let (obj_path, interfaces): (OwnedObjectPath, Vec<String>) = message.body().ok()?;
let obj_str = obj_path.as_str();
if !obj_str.starts_with("/org/bluez/") {
return None;
}
if !interfaces.iter().any(|i| i == "org.bluez.Device1") {
return None;
}
let address = address_from_path(obj_str);
return Some(RawEvent {
source: AdapterSource::Bluetooth,
kind: "bluetooth.device.removed".to_string(),
payload: json!({
"path": obj_str,
"address": address,
}),
timestamp: now_unix_ms(),
});
}
None
}
/// `/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF` → `"AA:BB:CC:DD:EE:FF"`
fn address_from_path(path: &str) -> String {
path.rsplit('/')
.next()
.and_then(|s| s.strip_prefix("dev_"))
.map(|s| s.replace('_', ":"))
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn address_from_path_parses_standard_bluez_path() {
assert_eq!(
address_from_path("/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF"),
"AA:BB:CC:DD:EE:FF"
);
}
#[test]
fn address_from_path_returns_empty_for_adapter_path() {
assert_eq!(address_from_path("/org/bluez/hci0"), "");
}
#[test]
fn address_from_path_returns_empty_for_root() {
assert_eq!(address_from_path("/"), "");
}
}

View file

@ -48,13 +48,39 @@ impl Adapter for HyprlandAdapter {
} }
fn hyprland_event_socket() -> Result<PathBuf> { fn hyprland_event_socket() -> Result<PathBuf> {
let instance = env::var("HYPRLAND_INSTANCE_SIGNATURE")
.map_err(|_| anyhow!("HYPRLAND_INSTANCE_SIGNATURE is not set"))?;
let runtime = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string()); let runtime = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
Ok(PathBuf::from(runtime)
// If the env var is set, use it directly.
if let Ok(instance) = env::var("HYPRLAND_INSTANCE_SIGNATURE") {
return Ok(PathBuf::from(runtime)
.join("hypr") .join("hypr")
.join(instance) .join(instance)
.join(".socket2.sock")) .join(".socket2.sock"));
}
// Otherwise scan $XDG_RUNTIME_DIR/hypr/ for a running instance.
// Hyprland creates a per-instance directory there containing .socket2.sock.
// This handles the case where breadd starts as a systemd user service before
// Hyprland has exported HYPRLAND_INSTANCE_SIGNATURE into the environment.
let hypr_dir = PathBuf::from(&runtime).join("hypr");
let mut sockets: Vec<PathBuf> = std::fs::read_dir(&hypr_dir)
.map_err(|_| anyhow!("no Hyprland instance found ({})", hypr_dir.display()))?
.flatten()
.map(|e| e.path().join(".socket2.sock"))
.filter(|p| p.exists())
.collect();
match sockets.len() {
0 => Err(anyhow!(
"no Hyprland instance found in {}",
hypr_dir.display()
)),
1 => Ok(sockets.remove(0)),
n => {
warn!("found {n} Hyprland instances, using first");
Ok(sockets.remove(0))
}
}
} }
fn parse_hyprland_line(line: &str) -> (String, String) { fn parse_hyprland_line(line: &str) -> (String, String) {

View file

@ -1,18 +1,29 @@
use anyhow::Result; use anyhow::Result;
use async_trait::async_trait; use async_trait::async_trait;
use bread_shared::RawEvent; use bread_shared::RawEvent;
use tokio::sync::{mpsc, watch}; use serde::Serialize;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{mpsc, watch, RwLock};
use tracing::info; use tracing::info;
use crate::core::config::Config; use crate::core::config::Config;
use crate::core::supervisor::spawn_supervised; use crate::core::supervisor::spawn_supervised;
pub mod bluetooth;
pub mod hyprland; pub mod hyprland;
pub mod network; pub mod network;
pub mod power;
pub mod udev;
pub mod network_rtnetlink; pub mod network_rtnetlink;
pub mod power;
pub mod power_upower; pub mod power_upower;
pub mod udev;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AdapterStatus {
Connected,
Disconnected,
}
#[async_trait] #[async_trait]
pub trait Adapter: Send + Sync { pub trait Adapter: Send + Sync {
@ -30,6 +41,7 @@ pub struct Manager {
raw_tx: mpsc::Sender<RawEvent>, raw_tx: mpsc::Sender<RawEvent>,
config: Config, config: Config,
shutdown_rx: watch::Receiver<bool>, shutdown_rx: watch::Receiver<bool>,
status: Arc<RwLock<HashMap<String, AdapterStatus>>>,
} }
impl Manager { impl Manager {
@ -42,9 +54,14 @@ impl Manager {
raw_tx, raw_tx,
config, config,
shutdown_rx, shutdown_rx,
status: Arc::new(RwLock::new(HashMap::new())),
} }
} }
pub fn status_handle(&self) -> Arc<RwLock<HashMap<String, AdapterStatus>>> {
self.status.clone()
}
pub async fn start_all(&self) -> Result<()> { pub async fn start_all(&self) -> Result<()> {
info!("starting adapters"); info!("starting adapters");
@ -55,7 +72,7 @@ impl Manager {
} }
if self.config.adapters.hyprland.enabled { if self.config.adapters.hyprland.enabled {
self.spawn_adapter(hyprland::HyprlandAdapter::default()); self.spawn_adapter(hyprland::HyprlandAdapter);
} }
if self.config.adapters.power.enabled { if self.config.adapters.power.enabled {
@ -70,13 +87,19 @@ impl Manager {
} }
} }
if self.config.adapters.bluetooth.enabled {
let adapter = bluetooth::BluetoothAdapter::new();
adapter.enumerate_existing(&self.raw_tx).await;
self.spawn_adapter(adapter);
}
if self.config.adapters.network.enabled { if self.config.adapters.network.enabled {
// Prefer rtnetlink-based adapter; fall back to existing sysfs-based adapter // Prefer rtnetlink-based adapter; fall back to existing sysfs-based adapter
let rt = network_rtnetlink::RtnetlinkAdapter::new(); let rt = network_rtnetlink::RtnetlinkAdapter::new();
if let Ok(adapter) = rt { if let Ok(adapter) = rt {
self.spawn_adapter(adapter); self.spawn_adapter(adapter);
} else { } else {
self.spawn_adapter(network::NetworkAdapter::default()); self.spawn_adapter(network::NetworkAdapter);
} }
} }
@ -91,17 +114,27 @@ impl Manager {
let tx = self.raw_tx.clone(); let tx = self.raw_tx.clone();
let shutdown_rx = self.shutdown_rx.clone(); let shutdown_rx = self.shutdown_rx.clone();
let shutdown_for_task = shutdown_rx.clone(); let shutdown_for_task = shutdown_rx.clone();
let status = self.status.clone();
spawn_supervised(name, shutdown_rx, move || { spawn_supervised(name, shutdown_rx, move || {
let adapter = adapter.clone(); let adapter = adapter.clone();
let tx = tx.clone(); let tx = tx.clone();
let mut shutdown_rx = shutdown_for_task.clone(); let mut shutdown_rx = shutdown_for_task.clone();
let status = status.clone();
async move { async move {
adapter.on_connect().await?; adapter.on_connect().await?;
{
let mut guard = status.write().await;
guard.insert(adapter.name().to_string(), AdapterStatus::Connected);
}
let result = tokio::select! { let result = tokio::select! {
result = adapter.run(tx) => result, result = adapter.run(tx) => result,
_ = shutdown_rx.changed() => Ok(()), _ = shutdown_rx.changed() => Ok(()),
}; };
adapter.on_disconnect().await?; adapter.on_disconnect().await?;
{
let mut guard = status.write().await;
guard.insert(adapter.name().to_string(), AdapterStatus::Disconnected);
}
result result
} }
}); });

View file

@ -70,7 +70,14 @@ impl Adapter for RtnetlinkAdapter {
"netns_id": netns_id, "netns_id": netns_id,
"netns_fd": netns_fd "netns_fd": netns_fd
}); });
let _ = tx.send(RawEvent { source: AdapterSource::Network, kind: kind.to_string(), payload, timestamp: bread_shared::now_unix_ms() }).await; let _ = tx
.send(RawEvent {
source: AdapterSource::Network,
kind: kind.to_string(),
payload,
timestamp: bread_shared::now_unix_ms(),
})
.await;
} }
} }
netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::NewRoute(route)) => { netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::NewRoute(route)) => {
@ -86,17 +93,32 @@ impl Adapter for RtnetlinkAdapter {
"gateway": gateway_ip, "gateway": gateway_ip,
"table": route.header.table "table": route.header.table
}); });
let _ = tx.send(RawEvent { source: AdapterSource::Network, kind: "route.default.changed".to_string(), payload, timestamp: bread_shared::now_unix_ms() }).await; let _ = tx
.send(RawEvent {
source: AdapterSource::Network,
kind: "route.default.changed".to_string(),
payload,
timestamp: bread_shared::now_unix_ms(),
})
.await;
} }
} }
netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::NewAddress(addr)) => { netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::NewAddress(
addr,
)) => {
let address = addr.nlas.iter().find_map(|nla| match nla { let address = addr.nlas.iter().find_map(|nla| match nla {
netlink_packet_route::address::nlas::Nla::Address(bytes) => Some(bytes.clone()), netlink_packet_route::address::nlas::Nla::Address(bytes) => {
netlink_packet_route::address::nlas::Nla::Local(bytes) => Some(bytes.clone()), Some(bytes.clone())
}
netlink_packet_route::address::nlas::Nla::Local(bytes) => {
Some(bytes.clone())
}
_ => None, _ => None,
}); });
let label = addr.nlas.iter().find_map(|nla| match nla { let label = addr.nlas.iter().find_map(|nla| match nla {
netlink_packet_route::address::nlas::Nla::Label(label) => Some(label.clone()), netlink_packet_route::address::nlas::Nla::Label(label) => {
Some(label.clone())
}
_ => None, _ => None,
}); });
let ip = address.as_deref().and_then(ip_from_bytes); let ip = address.as_deref().and_then(ip_from_bytes);
@ -107,16 +129,31 @@ impl Adapter for RtnetlinkAdapter {
"address": ip, "address": ip,
"label": label "label": label
}); });
let _ = tx.send(RawEvent { source: AdapterSource::Network, kind: "address.added".to_string(), payload, timestamp: bread_shared::now_unix_ms() }).await; let _ = tx
.send(RawEvent {
source: AdapterSource::Network,
kind: "address.added".to_string(),
payload,
timestamp: bread_shared::now_unix_ms(),
})
.await;
} }
netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::DelAddress(addr)) => { netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::DelAddress(
addr,
)) => {
let address = addr.nlas.iter().find_map(|nla| match nla { let address = addr.nlas.iter().find_map(|nla| match nla {
netlink_packet_route::address::nlas::Nla::Address(bytes) => Some(bytes.clone()), netlink_packet_route::address::nlas::Nla::Address(bytes) => {
netlink_packet_route::address::nlas::Nla::Local(bytes) => Some(bytes.clone()), Some(bytes.clone())
}
netlink_packet_route::address::nlas::Nla::Local(bytes) => {
Some(bytes.clone())
}
_ => None, _ => None,
}); });
let label = addr.nlas.iter().find_map(|nla| match nla { let label = addr.nlas.iter().find_map(|nla| match nla {
netlink_packet_route::address::nlas::Nla::Label(label) => Some(label.clone()), netlink_packet_route::address::nlas::Nla::Label(label) => {
Some(label.clone())
}
_ => None, _ => None,
}); });
let ip = address.as_deref().and_then(ip_from_bytes); let ip = address.as_deref().and_then(ip_from_bytes);
@ -127,7 +164,14 @@ impl Adapter for RtnetlinkAdapter {
"address": ip, "address": ip,
"label": label "label": label
}); });
let _ = tx.send(RawEvent { source: AdapterSource::Network, kind: "address.removed".to_string(), payload, timestamp: bread_shared::now_unix_ms() }).await; let _ = tx
.send(RawEvent {
source: AdapterSource::Network,
kind: "address.removed".to_string(),
payload,
timestamp: bread_shared::now_unix_ms(),
})
.await;
} }
_ => { _ => {
debug!("unhandled netlink message"); debug!("unhandled netlink message");

View file

@ -6,8 +6,8 @@ use serde_json::json;
use std::collections::HashMap; use std::collections::HashMap;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tracing::{debug, info}; use tracing::{debug, info};
use zbus::{Message, MessageStream};
use zbus::zvariant::{OwnedObjectPath, OwnedValue}; use zbus::zvariant::{OwnedObjectPath, OwnedValue};
use zbus::{Message, MessageStream};
use super::Adapter; use super::Adapter;

View file

@ -1,12 +1,9 @@
use std::collections::HashMap; use std::os::unix::io::AsRawFd;
use std::fs;
use std::path::Path;
use anyhow::Result; use anyhow::Result;
use bread_shared::{now_unix_ms, AdapterSource, RawEvent}; use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
use serde_json::json; use serde_json::json;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::time::{sleep, Duration};
use tracing::debug; use tracing::debug;
use crate::adapters::Adapter; use crate::adapters::Adapter;
@ -22,10 +19,7 @@ impl UdevAdapter {
} }
pub async fn enumerate_existing(&self, tx: &mpsc::Sender<RawEvent>) -> Result<()> { pub async fn enumerate_existing(&self, tx: &mpsc::Sender<RawEvent>) -> Result<()> {
let devices = enumerate_with_udev(&self.subsystems).unwrap_or_else(|_| { let devices = enumerate_with_udev(&self.subsystems)?;
scan_devices(&self.subsystems).unwrap_or_default()
});
for device in devices { for device in devices {
tx.send(RawEvent { tx.send(RawEvent {
source: AdapterSource::Udev, source: AdapterSource::Udev,
@ -52,57 +46,66 @@ impl Adapter for UdevAdapter {
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> { async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
debug!("udev adapter started"); debug!("udev adapter started");
if let Ok(()) = run_udev_monitor(self.subsystems.clone(), tx.clone()).await { run_udev_monitor(self.subsystems.clone(), tx).await
return Ok(());
}
// Fallback for environments where monitor sockets are unavailable.
let mut known: HashMap<String, ScannedDevice> = scan_devices(&self.subsystems)?
.into_iter()
.map(|d| (d.id.clone(), d))
.collect();
loop {
let current = scan_devices(&self.subsystems)?;
let current_map: HashMap<String, ScannedDevice> = current
.into_iter()
.map(|d| (d.id.clone(), d))
.collect();
for (id, dev) in &current_map {
if !known.contains_key(id) {
tx.send(raw_change_event("add", dev)).await?;
}
}
for (id, dev) in &known {
if !current_map.contains_key(id) {
tx.send(raw_change_event("remove", dev)).await?;
}
}
known = current_map;
sleep(Duration::from_secs(2)).await;
}
} }
} }
#[derive(Clone, Debug)]
struct ScannedDevice { struct ScannedDevice {
id: String, id: String,
name: String, name: String,
subsystem: String, subsystem: String,
} }
// udev::MonitorSocket uses a non-blocking socket; calling iter().next() without
// first polling the fd returns None immediately and exits the loop — which is
// why the old code silently fell back to sysfs on every start. We use poll(2)
// inside spawn_blocking so the thread truly blocks until events are available.
async fn run_udev_monitor(subsystems: Vec<String>, tx: mpsc::Sender<RawEvent>) -> Result<()> { async fn run_udev_monitor(subsystems: Vec<String>, tx: mpsc::Sender<RawEvent>) -> Result<()> {
tokio::task::spawn_blocking(move || -> Result<()> { tokio::task::spawn_blocking(move || -> Result<()> {
let mut builder = udev::MonitorBuilder::new()?; let mut builder = udev::MonitorBuilder::new()?;
for subsystem in &subsystems { for subsystem in &subsystems {
builder = builder.match_subsystem(subsystem)?; builder = builder.match_subsystem(subsystem)?;
} }
let monitor = builder.listen()?; let socket = builder.listen()?;
let fd = socket.as_raw_fd();
for event in monitor.iter() { loop {
let mut pfd = libc::pollfd {
fd,
events: libc::POLLIN,
revents: 0,
};
let ret = unsafe { libc::poll(&mut pfd, 1, 1000) };
if ret < 0 {
let err = std::io::Error::last_os_error();
if err.kind() == std::io::ErrorKind::Interrupted {
continue;
}
return Err(err.into());
}
if ret == 0 {
// Timeout: bail if the downstream channel has been dropped.
if tx.is_closed() {
return Ok(());
}
continue;
}
if pfd.revents & libc::POLLIN != 0 {
while let Some(event) = socket.iter().next() {
if tx.blocking_send(build_event(&event)).is_err() {
return Ok(());
}
}
}
}
})
.await??;
Ok(())
}
fn build_event(event: &udev::Event) -> RawEvent {
let action = event let action = event
.action() .action()
.map(|a| a.to_string_lossy().to_string()) .map(|a| a.to_string_lossy().to_string())
@ -117,12 +120,9 @@ async fn run_udev_monitor(subsystems: Vec<String>, tx: mpsc::Sender<RawEvent>) -
.map(|v| v.to_string_lossy().to_string()) .map(|v| v.to_string_lossy().to_string())
.or_else(|| event.devnode().map(|n| n.display().to_string())) .or_else(|| event.devnode().map(|n| n.display().to_string()))
.unwrap_or_else(|| "unknown".to_string()); .unwrap_or_else(|| "unknown".to_string());
let id = event let id = event.syspath().to_string_lossy().to_string();
.syspath()
.to_string_lossy()
.to_string();
let msg = RawEvent { RawEvent {
source: AdapterSource::Udev, source: AdapterSource::Udev,
kind: "udev.change".to_string(), kind: "udev.change".to_string(),
payload: json!({ payload: json!({
@ -130,20 +130,20 @@ async fn run_udev_monitor(subsystems: Vec<String>, tx: mpsc::Sender<RawEvent>) -
"id": id, "id": id,
"name": name, "name": name,
"subsystem": subsystem, "subsystem": subsystem,
"id_input_keyboard": prop_bool(event, "ID_INPUT_KEYBOARD"),
"id_input_mouse": prop_bool(event, "ID_INPUT_MOUSE"),
"id_input_joystick": prop_bool(event, "ID_INPUT_JOYSTICK"),
"id_input_touchpad": prop_bool(event, "ID_INPUT_TOUCHPAD"),
"id_input_tablet": prop_bool(event, "ID_INPUT_TABLET"),
"id_usb_class": prop_str(event, "ID_USB_CLASS"),
"id_usb_interfaces": prop_str(event, "ID_USB_INTERFACES"),
"id_vendor": prop_str(event, "ID_VENDOR"),
"id_model": prop_str(event, "ID_MODEL"),
"vendor_id": prop_str(event, "ID_VENDOR_ID"),
"product_id": prop_str(event, "ID_MODEL_ID"),
}), }),
timestamp: now_unix_ms(), timestamp: now_unix_ms(),
};
if tx.blocking_send(msg).is_err() {
break;
} }
}
Ok(())
})
.await??;
Ok(())
} }
fn enumerate_with_udev(subsystems: &[String]) -> Result<Vec<ScannedDevice>> { fn enumerate_with_udev(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
@ -165,7 +165,6 @@ fn enumerate_with_udev(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
.or_else(|| dev.sysname().to_str().map(ToString::to_string)) .or_else(|| dev.sysname().to_str().map(ToString::to_string))
.unwrap_or_else(|| "unknown".to_string()); .unwrap_or_else(|| "unknown".to_string());
let id = dev.syspath().to_string_lossy().to_string(); let id = dev.syspath().to_string_lossy().to_string();
out.push(ScannedDevice { out.push(ScannedDevice {
id, id,
name, name,
@ -176,90 +175,16 @@ fn enumerate_with_udev(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
Ok(out) Ok(out)
} }
fn raw_change_event(action: &str, dev: &ScannedDevice) -> RawEvent { fn prop_bool(event: &udev::Event, key: &str) -> bool {
RawEvent { event
source: AdapterSource::Udev, .property_value(key)
kind: "udev.change".to_string(), .and_then(|v| v.to_str())
payload: json!({ .map(|v| v == "1")
"action": action, .unwrap_or(false)
"id": dev.id,
"name": dev.name,
"subsystem": dev.subsystem,
}),
timestamp: now_unix_ms(),
}
} }
fn scan_devices(subsystems: &[String]) -> Result<Vec<ScannedDevice>> { fn prop_str(event: &udev::Event, key: &str) -> Option<String> {
let mut out = Vec::new(); event
.property_value(key)
if subsystems.iter().any(|s| s == "drm") { .map(|v| v.to_string_lossy().to_string())
let drm_dir = Path::new("/sys/class/drm");
if drm_dir.exists() {
for entry in fs::read_dir(drm_dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
if !name.contains('-') {
continue;
}
let status = fs::read_to_string(entry.path().join("status")).unwrap_or_default();
if status.trim() == "connected" {
out.push(ScannedDevice {
id: format!("drm:{name}"),
name,
subsystem: "drm".to_string(),
});
}
}
}
}
if subsystems.iter().any(|s| s == "input") {
let input_dir = Path::new("/dev/input/by-id");
if input_dir.exists() {
for entry in fs::read_dir(input_dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
out.push(ScannedDevice {
id: format!("input:{name}"),
name,
subsystem: "input".to_string(),
});
}
}
}
if subsystems.iter().any(|s| s == "power_supply") {
let pwr_dir = Path::new("/sys/class/power_supply");
if pwr_dir.exists() {
for entry in fs::read_dir(pwr_dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
out.push(ScannedDevice {
id: format!("power_supply:{name}"),
name,
subsystem: "power_supply".to_string(),
});
}
}
}
if subsystems.iter().any(|s| s == "usb") {
let usb_dir = Path::new("/sys/bus/usb/devices");
if usb_dir.exists() {
for entry in fs::read_dir(usb_dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
if !name.contains(':') && name.chars().any(|c| c.is_ascii_digit()) {
out.push(ScannedDevice {
id: format!("usb:{name}"),
name,
subsystem: "usb".to_string(),
});
}
}
}
}
Ok(out)
} }

View file

@ -5,15 +5,19 @@ use std::path::{Path, PathBuf};
use anyhow::Result; use anyhow::Result;
use serde::Deserialize; use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Default, Deserialize)]
pub struct Config { pub struct Config {
#[serde(default)] #[serde(default)]
pub daemon: DaemonConfig, pub daemon: DaemonConfig,
#[serde(default)] #[serde(default)]
pub lua: LuaConfig, pub lua: LuaConfig,
#[serde(default)] #[serde(default)]
pub modules: ModulesConfig,
#[serde(default)]
pub adapters: AdaptersConfig, pub adapters: AdaptersConfig,
#[serde(default)] #[serde(default)]
pub notifications: NotificationsConfig,
#[serde(default)]
pub events: EventsConfig, pub events: EventsConfig,
} }
@ -34,6 +38,14 @@ pub struct LuaConfig {
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct ModulesConfig {
#[serde(default = "default_true")]
pub builtin: bool,
#[serde(default)]
pub disable: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct AdaptersConfig { pub struct AdaptersConfig {
#[serde(default)] #[serde(default)]
pub hyprland: AdapterToggle, pub hyprland: AdapterToggle,
@ -43,6 +55,8 @@ pub struct AdaptersConfig {
pub power: PowerConfig, pub power: PowerConfig,
#[serde(default)] #[serde(default)]
pub network: AdapterToggle, pub network: AdapterToggle,
#[serde(default)]
pub bluetooth: AdapterToggle,
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
@ -73,15 +87,14 @@ pub struct EventsConfig {
pub dedup_window_ms: u64, pub dedup_window_ms: u64,
} }
impl Default for Config { #[derive(Debug, Clone, Deserialize)]
fn default() -> Self { pub struct NotificationsConfig {
Self { #[serde(default = "default_notify_timeout")]
daemon: DaemonConfig::default(), pub default_timeout_ms: i64,
lua: LuaConfig::default(), #[serde(default = "default_notify_urgency")]
adapters: AdaptersConfig::default(), pub default_urgency: String,
events: EventsConfig::default(), #[serde(default = "default_notify_path")]
} pub notify_send_path: String,
}
} }
impl Default for DaemonConfig { impl Default for DaemonConfig {
@ -102,13 +115,11 @@ impl Default for LuaConfig {
} }
} }
impl Default for AdaptersConfig { impl Default for ModulesConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
hyprland: AdapterToggle::default(), builtin: default_true(),
udev: UdevConfig::default(), disable: Vec::new(),
power: PowerConfig::default(),
network: AdapterToggle::default(),
} }
} }
} }
@ -147,6 +158,16 @@ impl Default for EventsConfig {
} }
} }
impl Default for NotificationsConfig {
fn default() -> Self {
Self {
default_timeout_ms: default_notify_timeout(),
default_urgency: default_notify_urgency(),
notify_send_path: default_notify_path(),
}
}
}
impl Config { impl Config {
pub fn load() -> Result<Self> { pub fn load() -> Result<Self> {
let path = config_path(); let path = config_path();
@ -218,6 +239,18 @@ fn default_dedup_window() -> u64 {
100 100
} }
fn default_notify_timeout() -> i64 {
3000
}
fn default_notify_urgency() -> String {
"normal".to_string()
}
fn default_notify_path() -> String {
"notify-send".to_string()
}
fn default_udev_subsystems() -> Vec<String> { fn default_udev_subsystems() -> Vec<String> {
vec![ vec![
"usb".to_string(), "usb".to_string(),
@ -226,3 +259,246 @@ fn default_udev_subsystems() -> Vec<String> {
"power_supply".to_string(), "power_supply".to_string(),
] ]
} }
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
// Tests that mutate process env vars must serialize against each other
// — cargo runs tests in parallel by default and HOME/XDG_RUNTIME_DIR are
// process-global. Tests that don't touch env are free to run unguarded.
static ENV_LOCK: Mutex<()> = Mutex::new(());
struct EnvGuard {
saved: Vec<(&'static str, Option<String>)>,
_guard: std::sync::MutexGuard<'static, ()>,
}
impl EnvGuard {
fn new(vars: &[&'static str]) -> Self {
let guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let saved = vars.iter().map(|k| (*k, std::env::var(k).ok())).collect();
Self {
saved,
_guard: guard,
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
for (key, value) in &self.saved {
match value {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
}
}
#[test]
fn default_config_uses_documented_defaults() {
let cfg = Config::default();
assert_eq!(cfg.daemon.log_level, "info");
assert!(cfg.daemon.socket_path.is_empty());
assert_eq!(cfg.lua.entry_point, "~/.config/bread/init.lua");
assert_eq!(cfg.lua.module_path, "~/.config/bread/modules");
assert!(cfg.adapters.hyprland.enabled);
assert!(cfg.adapters.udev.enabled);
assert!(cfg.adapters.power.enabled);
assert!(cfg.adapters.network.enabled);
assert!(cfg.adapters.bluetooth.enabled);
assert_eq!(cfg.adapters.power.poll_interval_secs, 30);
assert_eq!(cfg.events.dedup_window_ms, 100);
assert_eq!(cfg.notifications.default_timeout_ms, 3000);
assert_eq!(cfg.notifications.default_urgency, "normal");
assert_eq!(cfg.notifications.notify_send_path, "notify-send");
assert!(cfg.modules.builtin);
assert!(cfg.modules.disable.is_empty());
}
#[test]
fn default_udev_subsystems_match_documented_list() {
assert_eq!(
default_udev_subsystems(),
vec!["usb", "input", "drm", "power_supply"]
);
}
#[test]
fn parse_empty_toml_yields_defaults() {
let cfg: Config = toml::from_str("").unwrap();
assert_eq!(cfg.daemon.log_level, "info");
assert!(cfg.adapters.hyprland.enabled);
}
#[test]
fn parse_full_toml_overrides_all_values() {
let raw = r#"
[daemon]
log_level = "debug"
socket_path = "/tmp/custom.sock"
[lua]
entry_point = "/abs/init.lua"
module_path = "/abs/mods"
[modules]
builtin = false
disable = ["foo", "bar"]
[adapters.hyprland]
enabled = false
[adapters.udev]
enabled = true
subsystems = ["usb"]
[adapters.power]
enabled = false
poll_interval_secs = 5
[adapters.network]
enabled = false
[adapters.bluetooth]
enabled = false
[events]
dedup_window_ms = 250
[notifications]
default_timeout_ms = 1000
default_urgency = "critical"
notify_send_path = "/usr/local/bin/notify-send"
"#;
let cfg: Config = toml::from_str(raw).unwrap();
assert_eq!(cfg.daemon.log_level, "debug");
assert_eq!(cfg.daemon.socket_path, "/tmp/custom.sock");
assert_eq!(cfg.lua.entry_point, "/abs/init.lua");
assert_eq!(cfg.lua.module_path, "/abs/mods");
assert!(!cfg.modules.builtin);
assert_eq!(cfg.modules.disable, vec!["foo", "bar"]);
assert!(!cfg.adapters.hyprland.enabled);
assert!(cfg.adapters.udev.enabled);
assert_eq!(cfg.adapters.udev.subsystems, vec!["usb"]);
assert!(!cfg.adapters.power.enabled);
assert_eq!(cfg.adapters.power.poll_interval_secs, 5);
assert!(!cfg.adapters.network.enabled);
assert!(!cfg.adapters.bluetooth.enabled);
assert_eq!(cfg.events.dedup_window_ms, 250);
assert_eq!(cfg.notifications.default_timeout_ms, 1000);
assert_eq!(cfg.notifications.default_urgency, "critical");
}
#[test]
fn parse_partial_toml_fills_missing_with_defaults() {
let raw = r#"
[daemon]
log_level = "trace"
"#;
let cfg: Config = toml::from_str(raw).unwrap();
assert_eq!(cfg.daemon.log_level, "trace");
// Untouched sections still get their defaults.
assert!(cfg.adapters.hyprland.enabled);
assert_eq!(cfg.events.dedup_window_ms, 100);
}
#[test]
fn invalid_toml_returns_error() {
let result: Result<Config, _> = toml::from_str("[daemon\nbroken");
assert!(result.is_err());
}
#[test]
fn socket_path_uses_explicit_path_verbatim() {
let mut cfg = Config::default();
cfg.daemon.socket_path = "/run/bread.sock".to_string();
assert_eq!(cfg.socket_path(), PathBuf::from("/run/bread.sock"));
}
#[test]
fn socket_path_expands_tilde_when_explicit() {
let _g = EnvGuard::new(&["HOME"]);
std::env::set_var("HOME", "/synthetic/home");
let mut cfg = Config::default();
cfg.daemon.socket_path = "~/sockets/bread.sock".to_string();
assert_eq!(
cfg.socket_path(),
PathBuf::from("/synthetic/home/sockets/bread.sock")
);
}
#[test]
fn socket_path_falls_back_to_xdg_runtime_dir() {
let _g = EnvGuard::new(&["XDG_RUNTIME_DIR"]);
std::env::set_var("XDG_RUNTIME_DIR", "/tmp/xdg");
let cfg = Config::default();
assert_eq!(
cfg.socket_path(),
PathBuf::from("/tmp/xdg/bread/breadd.sock")
);
}
#[test]
fn socket_path_uses_tmp_when_no_xdg_runtime_dir() {
let _g = EnvGuard::new(&["XDG_RUNTIME_DIR"]);
std::env::remove_var("XDG_RUNTIME_DIR");
let cfg = Config::default();
assert_eq!(cfg.socket_path(), PathBuf::from("/tmp/bread/breadd.sock"));
}
#[test]
fn lua_entry_point_and_module_path_expand_tilde() {
let _g = EnvGuard::new(&["HOME"]);
std::env::set_var("HOME", "/synthetic/home");
let cfg = Config::default();
assert_eq!(
cfg.lua_entry_point(),
PathBuf::from("/synthetic/home/.config/bread/init.lua")
);
assert_eq!(
cfg.lua_module_path(),
PathBuf::from("/synthetic/home/.config/bread/modules")
);
}
#[test]
fn lua_entry_point_returns_absolute_path_unchanged() {
let mut cfg = Config::default();
cfg.lua.entry_point = "/etc/bread/init.lua".to_string();
assert_eq!(cfg.lua_entry_point(), PathBuf::from("/etc/bread/init.lua"));
}
#[test]
fn expand_home_handles_missing_home_env() {
let _g = EnvGuard::new(&["HOME"]);
std::env::remove_var("HOME");
// Without HOME, ~/-prefixed paths fall back to the literal string.
assert_eq!(expand_home("~/foo"), PathBuf::from("~/foo"));
// Non-tilde paths are unchanged regardless.
assert_eq!(expand_home("/abs/path"), PathBuf::from("/abs/path"));
}
#[test]
fn config_path_respects_xdg_config_home() {
let _g = EnvGuard::new(&["XDG_CONFIG_HOME", "HOME"]);
std::env::set_var("XDG_CONFIG_HOME", "/synthetic/xdg-config");
assert_eq!(
config_path(),
PathBuf::from("/synthetic/xdg-config/bread/breadd.toml")
);
}
#[test]
fn config_path_falls_back_to_home_when_no_xdg() {
let _g = EnvGuard::new(&["XDG_CONFIG_HOME", "HOME"]);
std::env::remove_var("XDG_CONFIG_HOME");
std::env::set_var("HOME", "/synthetic/home");
assert_eq!(
config_path(),
PathBuf::from("/synthetic/home/.config/bread/breadd.toml")
);
}
}

View file

@ -4,14 +4,16 @@ use std::sync::RwLock;
use bread_shared::{AdapterSource, BreadEvent, RawEvent}; use bread_shared::{AdapterSource, BreadEvent, RawEvent};
use serde_json::{json, Value}; use serde_json::{json, Value};
use crate::core::types::DeviceClass;
/// How many multiples of `dedup_window_ms` an entry must be idle before eviction. /// How many multiples of `dedup_window_ms` an entry must be idle before eviction.
const EVICT_MULTIPLIER: u64 = 60; const EVICT_MULTIPLIER: u64 = 60;
pub struct EventNormalizer { pub struct EventNormalizer {
dedup_window_ms: u64, dedup_window_ms: u64,
recent: RwLock<HashMap<String, u64>>, recent: RwLock<HashMap<String, u64>>,
/// Tracks the first time a physical device (keyed by verb+vendor_id+product_id)
/// fired within the current window, so subsequent child-node events from the
/// same plug-in are suppressed at the normalizer level.
seen_devices: RwLock<HashMap<String, u64>>,
} }
impl EventNormalizer { impl EventNormalizer {
@ -19,6 +21,7 @@ impl EventNormalizer {
Self { Self {
dedup_window_ms, dedup_window_ms,
recent: RwLock::new(HashMap::new()), recent: RwLock::new(HashMap::new()),
seen_devices: RwLock::new(HashMap::new()),
} }
} }
@ -28,6 +31,7 @@ impl EventNormalizer {
AdapterSource::Hyprland => self.normalize_hyprland(raw), AdapterSource::Hyprland => self.normalize_hyprland(raw),
AdapterSource::Power => self.normalize_power(raw), AdapterSource::Power => self.normalize_power(raw),
AdapterSource::Network => self.normalize_network(raw), AdapterSource::Network => self.normalize_network(raw),
AdapterSource::Bluetooth => self.normalize_bluetooth(raw),
AdapterSource::System => vec![BreadEvent { AdapterSource::System => vec![BreadEvent {
event: raw.kind.clone(), event: raw.kind.clone(),
timestamp: raw.timestamp, timestamp: raw.timestamp,
@ -41,62 +45,213 @@ impl EventNormalizer {
} }
fn normalize_udev(&self, raw: &RawEvent) -> Vec<BreadEvent> { fn normalize_udev(&self, raw: &RawEvent) -> Vec<BreadEvent> {
let action = raw.payload.get("action").and_then(Value::as_str).unwrap_or("change"); let action = raw
let id = raw.payload.get("id").and_then(Value::as_str).unwrap_or("unknown"); .payload
let class = classify_device(&raw.payload); .get("action")
let class_str = serde_json::to_string(&class) .and_then(Value::as_str)
.unwrap_or_else(|_| "\"unknown\"".to_string()) .unwrap_or("change");
.replace('"', "");
// "bind" is the kernel attaching a driver to an interface — not a meaningful
// device state change for automation purposes.
if action == "bind" {
return vec![];
}
let name = raw
.payload
.get("name")
.and_then(Value::as_str)
.unwrap_or("unknown");
let vendor = raw
.payload
.get("id_vendor")
.and_then(Value::as_str)
.unwrap_or_default();
let vendor_id = raw
.payload
.get("vendor_id")
.and_then(Value::as_str)
.unwrap_or_default();
let product_id = raw
.payload
.get("product_id")
.and_then(Value::as_str)
.unwrap_or_default();
let subsystem = raw
.payload
.get("subsystem")
.and_then(Value::as_str)
.unwrap_or_default();
// Drop anonymous child USB interfaces (e.g. 3-5:1.0, 3-5:1.1) that carry
// no identity information — they are USB protocol artefacts, not devices.
if name == "unknown" && vendor.is_empty() && vendor_id.is_empty() {
return vec![];
}
// For connected/disconnected, suppress duplicate events from child nodes of
// the same physical device (e.g. input66, mouse0, event17 all from one plug-in).
// Key by verb+vendor_id+product_id so a second distinct device of the same
// model plugged in after the window still fires correctly.
let verb = match action { let verb = match action {
"add" => "connected", "add" => "connected",
"remove" => "disconnected", "remove" => "disconnected",
_ => "changed", _ => "changed",
}; };
let mut events = vec![BreadEvent { if (verb == "connected" || verb == "disconnected")
&& !vendor_id.is_empty()
&& !product_id.is_empty()
{
let device_key = format!("{}:{}:{}", verb, vendor_id, product_id);
let now = raw.timestamp;
let already_seen = {
let seen = self.seen_devices.read().unwrap_or_else(|p| p.into_inner());
seen.get(&device_key)
.map(|&last| now.saturating_sub(last) < self.dedup_window_ms)
.unwrap_or(false)
};
if already_seen {
return vec![];
}
let mut seen = self.seen_devices.write().unwrap_or_else(|p| p.into_inner());
seen.insert(device_key, now);
// Evict stale entries
let evict_before =
now.saturating_sub(self.dedup_window_ms.saturating_mul(EVICT_MULTIPLIER));
if evict_before > 0 {
seen.retain(|_, &mut last| last >= evict_before);
}
}
let id = raw
.payload
.get("id")
.and_then(Value::as_str)
.unwrap_or("unknown");
// Device name is always "unknown" here; the state engine applies user-defined
// classification rules from devices.lua before dispatching to subscribers.
vec![BreadEvent {
event: format!("bread.device.{}", verb), event: format!("bread.device.{}", verb),
timestamp: raw.timestamp, timestamp: raw.timestamp,
source: AdapterSource::Udev, source: AdapterSource::Udev,
data: json!({ data: json!({
"id": id, "id": id,
"class": class, "device": "unknown",
"name": name,
"vendor": vendor,
"vendor_id": vendor_id,
"product_id": product_id,
"subsystem": subsystem,
"raw": raw.payload, "raw": raw.payload,
}), }),
}]; }]
events.push(BreadEvent {
event: format!("bread.device.{}.{}", class_str, verb),
timestamp: raw.timestamp,
source: AdapterSource::Udev,
data: json!({
"id": id,
"class": class,
}),
});
events
} }
fn normalize_hyprland(&self, raw: &RawEvent) -> Vec<BreadEvent> { fn normalize_hyprland(&self, raw: &RawEvent) -> Vec<BreadEvent> {
let kind = raw.payload.get("kind").and_then(Value::as_str).unwrap_or("unknown"); let kind = raw
let mapped = match kind { .payload
"workspace" | "workspacev2" => "bread.workspace.changed", .get("kind")
"monitoradded" => "bread.monitor.connected", .and_then(Value::as_str)
"monitorremoved" => "bread.monitor.disconnected", .unwrap_or("unknown");
"activewindow" | "activewindowv2" => "bread.window.focus.changed", let data = raw
"openwindow" => "bread.window.opened", .payload
"closewindow" => "bread.window.closed", .get("data")
_ => "bread.hyprland.event", .and_then(Value::as_str)
}; .unwrap_or("");
vec![BreadEvent { match kind {
event: mapped.to_string(), "workspace" | "workspacev2" => vec![BreadEvent {
event: "bread.workspace.changed".to_string(),
timestamp: raw.timestamp, timestamp: raw.timestamp,
source: AdapterSource::Hyprland, source: AdapterSource::Hyprland,
data: raw.payload.clone(), data: raw.payload.clone(),
}],
"createworkspace" => vec![BreadEvent {
event: "bread.workspace.created".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: json!({ "workspace": data }),
}],
"destroyworkspace" => vec![BreadEvent {
event: "bread.workspace.destroyed".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: json!({ "workspace": data }),
}],
"monitoradded" => vec![BreadEvent {
event: "bread.monitor.connected".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: json!({ "name": data }),
}],
"monitorremoved" => vec![BreadEvent {
event: "bread.monitor.disconnected".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: json!({ "name": data }),
}],
"activewindow" => vec![BreadEvent {
event: "bread.window.focus.changed".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: raw.payload.clone(),
}],
"activewindowv2" => {
let fields = split_hyprland_fields(data);
vec![BreadEvent {
event: "bread.window.focused".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: json!({
"address": fields.first().unwrap_or(&"")
}),
}] }]
} }
"openwindow" => {
let fields = split_hyprland_fields(data);
vec![BreadEvent {
event: "bread.window.opened".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: json!({
"address": fields.first().unwrap_or(&""),
"workspace": fields.get(1).unwrap_or(&""),
"class": fields.get(2).unwrap_or(&""),
"title": fields.get(3).unwrap_or(&""),
}),
}]
}
"closewindow" => {
let fields = split_hyprland_fields(data);
vec![BreadEvent {
event: "bread.window.closed".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: json!({ "address": fields.first().unwrap_or(&"") }),
}]
}
"movewindow" => {
let fields = split_hyprland_fields(data);
vec![BreadEvent {
event: "bread.window.moved".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: json!({
"address": fields.first().unwrap_or(&""),
"workspace": fields.get(1).unwrap_or(&""),
}),
}]
}
_ => vec![BreadEvent {
event: "bread.hyprland.event".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: raw.payload.clone(),
}],
}
}
fn normalize_power(&self, raw: &RawEvent) -> Vec<BreadEvent> { fn normalize_power(&self, raw: &RawEvent) -> Vec<BreadEvent> {
let mut events = Vec::new(); let mut events = Vec::new();
@ -149,8 +304,89 @@ impl EventNormalizer {
events events
} }
fn normalize_bluetooth(&self, raw: &RawEvent) -> Vec<BreadEvent> {
let path = raw
.payload
.get("path")
.and_then(Value::as_str)
.unwrap_or("unknown");
let address = raw
.payload
.get("address")
.and_then(Value::as_str)
.unwrap_or("unknown");
let name = raw
.payload
.get("name")
.and_then(Value::as_str)
.or_else(|| {
raw.payload
.pointer("/properties/Name")
.or_else(|| raw.payload.pointer("/properties/Alias"))
.and_then(Value::as_str)
})
.unwrap_or("unknown");
match raw.kind.as_str() {
"bluetooth.enumerate" | "bluetooth.device.connected" => vec![BreadEvent {
event: "bread.device.connected".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Bluetooth,
data: json!({
"id": path,
"device": "unknown",
"name": name,
"address": address,
"subsystem": "bluetooth",
"raw": raw.payload,
}),
}],
"bluetooth.device.disconnected" => vec![BreadEvent {
event: "bread.device.disconnected".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Bluetooth,
data: json!({
"id": path,
"device": "unknown",
"name": name,
"address": address,
"subsystem": "bluetooth",
"raw": raw.payload,
}),
}],
"bluetooth.device.added" => vec![BreadEvent {
event: "bread.bluetooth.device.paired".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Bluetooth,
data: json!({
"id": path,
"name": name,
"address": address,
"subsystem": "bluetooth",
"raw": raw.payload,
}),
}],
"bluetooth.device.removed" => vec![BreadEvent {
event: "bread.bluetooth.device.unpaired".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Bluetooth,
data: json!({
"id": path,
"address": address,
"subsystem": "bluetooth",
"raw": raw.payload,
}),
}],
_ => vec![],
}
}
fn normalize_network(&self, raw: &RawEvent) -> Vec<BreadEvent> { fn normalize_network(&self, raw: &RawEvent) -> Vec<BreadEvent> {
let online = raw.payload.get("online").and_then(Value::as_bool).unwrap_or(false); let online = raw
.payload
.get("online")
.and_then(Value::as_bool)
.unwrap_or(false);
let name = if online { let name = if online {
"bread.network.connected" "bread.network.connected"
} else { } else {
@ -192,7 +428,8 @@ impl EventNormalizer {
recent.insert(key.clone(), now); recent.insert(key.clone(), now);
// Evict stale entries to prevent unbounded growth. // Evict stale entries to prevent unbounded growth.
let evict_before = now.saturating_sub(self.dedup_window_ms.saturating_mul(EVICT_MULTIPLIER)); let evict_before =
now.saturating_sub(self.dedup_window_ms.saturating_mul(EVICT_MULTIPLIER));
if evict_before > 0 { if evict_before > 0 {
recent.retain(|_, &mut last| last >= evict_before); recent.retain(|_, &mut last| last >= evict_before);
} }
@ -201,36 +438,527 @@ impl EventNormalizer {
} }
} }
fn classify_device(payload: &Value) -> DeviceClass { fn split_hyprland_fields(data: &str) -> Vec<&str> {
let name = payload if data.is_empty() {
.get("name") return Vec::new();
.and_then(Value::as_str) }
.unwrap_or_default() data.split(">>").collect()
.to_lowercase(); }
let subsystem = payload
.get("subsystem") #[cfg(test)]
.and_then(Value::as_str) mod tests {
.unwrap_or_default() use super::*;
.to_lowercase();
fn raw(source: AdapterSource, kind: &str, payload: Value, ts: u64) -> RawEvent {
if name.contains("dock") { RawEvent {
return DeviceClass::Dock; source,
kind: kind.to_string(),
payload,
timestamp: ts,
}
}
// ─── Udev ─────────────────────────────────────────────────────────────
#[test]
fn udev_add_emits_connected_with_identity_fields() {
let n = EventNormalizer::new(100);
let ev = raw(
AdapterSource::Udev,
"udev",
json!({
"action": "add",
"name": "Logitech Mouse",
"id_vendor": "Logitech",
"vendor_id": "046d",
"product_id": "c52b",
"subsystem": "usb",
"id": "1-1.4",
}),
1000,
);
let out = n.normalize(&ev);
assert_eq!(out.len(), 1);
assert_eq!(out[0].event, "bread.device.connected");
assert_eq!(out[0].data.get("vendor_id").unwrap(), "046d");
assert_eq!(out[0].data.get("product_id").unwrap(), "c52b");
assert_eq!(out[0].data.get("name").unwrap(), "Logitech Mouse");
assert_eq!(out[0].data.get("subsystem").unwrap(), "usb");
assert_eq!(out[0].data.get("device").unwrap(), "unknown");
}
#[test]
fn udev_remove_emits_disconnected() {
let n = EventNormalizer::new(100);
let ev = raw(
AdapterSource::Udev,
"udev",
json!({
"action": "remove",
"name": "Logitech",
"vendor_id": "046d",
"product_id": "c52b",
"subsystem": "usb",
"id": "1-1.4",
}),
1000,
);
let out = n.normalize(&ev);
assert_eq!(out.len(), 1);
assert_eq!(out[0].event, "bread.device.disconnected");
}
#[test]
fn udev_bind_action_is_suppressed() {
let n = EventNormalizer::new(100);
let ev = raw(
AdapterSource::Udev,
"udev",
json!({
"action": "bind",
"name": "x",
"vendor_id": "046d",
"product_id": "c52b",
}),
1000,
);
assert!(n.normalize(&ev).is_empty());
}
#[test]
fn udev_anonymous_child_interface_is_dropped() {
let n = EventNormalizer::new(100);
// No name, no vendor — pure USB protocol artefact.
let ev = raw(
AdapterSource::Udev,
"udev",
json!({
"action": "add",
"id": "3-5:1.0",
}),
1000,
);
assert!(n.normalize(&ev).is_empty());
}
#[test]
fn udev_dedupes_child_nodes_of_same_physical_device() {
let n = EventNormalizer::new(1000);
let mk = |id: &str, ts: u64| {
raw(
AdapterSource::Udev,
"udev",
json!({
"action": "add",
"name": "Hub Device",
"vendor_id": "1d6b",
"product_id": "0002",
"subsystem": "usb",
"id": id,
}),
ts,
)
};
// First child fires
assert_eq!(n.normalize(&mk("usb-1", 1000)).len(), 1);
// Sibling within window is suppressed
assert_eq!(n.normalize(&mk("usb-2", 1050)).len(), 0);
// After the dedup window, a sibling fires again
assert_eq!(n.normalize(&mk("usb-3", 3000)).len(), 1);
}
#[test]
fn udev_disconnect_does_not_share_dedup_with_connect() {
let n = EventNormalizer::new(1000);
let connect = raw(
AdapterSource::Udev,
"udev",
json!({"action": "add", "name": "x", "vendor_id": "1", "product_id": "2", "id": "a"}),
1000,
);
let disconnect = raw(
AdapterSource::Udev,
"udev",
json!({"action": "remove", "name": "x", "vendor_id": "1", "product_id": "2", "id": "a"}),
1100,
);
assert_eq!(n.normalize(&connect).len(), 1);
// Disconnect uses a different verb in the dedup key, so it fires.
assert_eq!(n.normalize(&disconnect).len(), 1);
}
// ─── Hyprland ─────────────────────────────────────────────────────────
#[test]
fn hyprland_workspace_change() {
let n = EventNormalizer::new(0);
let ev = raw(
AdapterSource::Hyprland,
"hypr",
json!({"kind": "workspace", "data": "2"}),
1,
);
let out = n.normalize(&ev);
assert_eq!(out.len(), 1);
assert_eq!(out[0].event, "bread.workspace.changed");
}
#[test]
fn hyprland_active_window_v2_parses_address_from_fields() {
let n = EventNormalizer::new(0);
let ev = raw(
AdapterSource::Hyprland,
"hypr",
json!({"kind": "activewindowv2", "data": "0xdeadbeef"}),
1,
);
let out = n.normalize(&ev);
assert_eq!(out.len(), 1);
assert_eq!(out[0].event, "bread.window.focused");
assert_eq!(out[0].data.get("address").unwrap(), "0xdeadbeef");
}
#[test]
fn hyprland_openwindow_splits_all_fields() {
let n = EventNormalizer::new(0);
let ev = raw(
AdapterSource::Hyprland,
"hypr",
json!({"kind": "openwindow", "data": "0xabc>>2>>firefox>>Mozilla Firefox"}),
1,
);
let out = n.normalize(&ev);
assert_eq!(out.len(), 1);
assert_eq!(out[0].event, "bread.window.opened");
let d = &out[0].data;
assert_eq!(d.get("address").unwrap(), "0xabc");
assert_eq!(d.get("workspace").unwrap(), "2");
assert_eq!(d.get("class").unwrap(), "firefox");
assert_eq!(d.get("title").unwrap(), "Mozilla Firefox");
}
#[test]
fn hyprland_unknown_kind_falls_through_to_generic_event() {
let n = EventNormalizer::new(0);
let ev = raw(
AdapterSource::Hyprland,
"hypr",
json!({"kind": "submap", "data": "resize"}),
1,
);
let out = n.normalize(&ev);
assert_eq!(out.len(), 1);
assert_eq!(out[0].event, "bread.hyprland.event");
}
#[test]
fn hyprland_monitor_lifecycle() {
let n = EventNormalizer::new(0);
let added = n.normalize(&raw(
AdapterSource::Hyprland,
"hypr",
json!({"kind": "monitoradded", "data": "HDMI-A-1"}),
1,
));
let removed = n.normalize(&raw(
AdapterSource::Hyprland,
"hypr",
json!({"kind": "monitorremoved", "data": "HDMI-A-1"}),
2,
));
assert_eq!(added[0].event, "bread.monitor.connected");
assert_eq!(added[0].data.get("name").unwrap(), "HDMI-A-1");
assert_eq!(removed[0].event, "bread.monitor.disconnected");
}
// ─── Power ─────────────────────────────────────────────────────────────
#[test]
fn power_ac_connected_emits_named_event() {
let n = EventNormalizer::new(0);
let out = n.normalize(&raw(
AdapterSource::Power,
"power",
json!({"ac_connected": true}),
1,
));
assert_eq!(out.len(), 1);
assert_eq!(out[0].event, "bread.power.ac.connected");
}
#[test]
fn power_battery_thresholds_select_correct_event() {
let n = EventNormalizer::new(0);
let cases = [
(3, "bread.power.battery.critical"),
(5, "bread.power.battery.critical"),
(8, "bread.power.battery.very_low"),
(10, "bread.power.battery.very_low"),
(15, "bread.power.battery.low"),
(20, "bread.power.battery.low"),
(100, "bread.power.battery.full"),
];
for (level, expected) in cases {
let out = n.normalize(&raw(
AdapterSource::Power,
"power",
json!({"battery_percent": level}),
level * 1000,
));
assert_eq!(
out[0].event, expected,
"level {level} should map to {expected}"
);
}
}
#[test]
fn power_mid_range_battery_emits_generic_changed() {
let n = EventNormalizer::new(0);
let out = n.normalize(&raw(
AdapterSource::Power,
"power",
json!({"battery_percent": 50}),
1,
));
assert_eq!(out.len(), 1);
assert_eq!(out[0].event, "bread.power.changed");
}
#[test]
fn power_ac_and_battery_can_both_fire() {
let n = EventNormalizer::new(0);
let out = n.normalize(&raw(
AdapterSource::Power,
"power",
json!({"ac_connected": false, "battery_percent": 4}),
1,
));
let names: Vec<&str> = out.iter().map(|e| e.event.as_str()).collect();
assert!(names.contains(&"bread.power.ac.disconnected"));
assert!(names.contains(&"bread.power.battery.critical"));
}
// ─── Bluetooth ─────────────────────────────────────────────────────────
#[test]
fn bluetooth_connected_emits_device_connected() {
let n = EventNormalizer::new(0);
let ev = raw(
AdapterSource::Bluetooth,
"bluetooth",
json!({
"path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
"address": "AA:BB:CC:DD:EE:FF",
"properties": { "Connected": true },
}),
1,
);
let out = n.normalize(&raw(
AdapterSource::Bluetooth,
"bluetooth.device.connected",
ev.payload.clone(),
1,
));
assert_eq!(out.len(), 1);
assert_eq!(out[0].event, "bread.device.connected");
assert_eq!(out[0].data.get("address").unwrap(), "AA:BB:CC:DD:EE:FF");
assert_eq!(out[0].data.get("subsystem").unwrap(), "bluetooth");
assert_eq!(out[0].data.get("device").unwrap(), "unknown");
}
#[test]
fn bluetooth_disconnected_emits_device_disconnected() {
let n = EventNormalizer::new(0);
let out = n.normalize(&raw(
AdapterSource::Bluetooth,
"bluetooth.device.disconnected",
json!({
"path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
"address": "AA:BB:CC:DD:EE:FF",
"properties": { "Connected": false },
}),
1,
));
assert_eq!(out.len(), 1);
assert_eq!(out[0].event, "bread.device.disconnected");
}
#[test]
fn bluetooth_enumerate_includes_name() {
let n = EventNormalizer::new(0);
let out = n.normalize(&raw(
AdapterSource::Bluetooth,
"bluetooth.enumerate",
json!({
"path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
"address": "AA:BB:CC:DD:EE:FF",
"name": "WH-1000XM4",
"properties": {},
}),
1,
));
assert_eq!(out.len(), 1);
assert_eq!(out[0].event, "bread.device.connected");
assert_eq!(out[0].data.get("name").unwrap(), "WH-1000XM4");
}
#[test]
fn bluetooth_paired_emits_bluetooth_specific_event() {
let n = EventNormalizer::new(0);
let out = n.normalize(&raw(
AdapterSource::Bluetooth,
"bluetooth.device.added",
json!({
"path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
"address": "AA:BB:CC:DD:EE:FF",
"name": "My Headphones",
"properties": {},
}),
1,
));
assert_eq!(out.len(), 1);
assert_eq!(out[0].event, "bread.bluetooth.device.paired");
assert_eq!(out[0].data.get("name").unwrap(), "My Headphones");
}
#[test]
fn bluetooth_unpaired_emits_bluetooth_specific_event() {
let n = EventNormalizer::new(0);
let out = n.normalize(&raw(
AdapterSource::Bluetooth,
"bluetooth.device.removed",
json!({
"path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
"address": "AA:BB:CC:DD:EE:FF",
}),
1,
));
assert_eq!(out.len(), 1);
assert_eq!(out[0].event, "bread.bluetooth.device.unpaired");
assert_eq!(out[0].data.get("address").unwrap(), "AA:BB:CC:DD:EE:FF");
}
#[test]
fn bluetooth_name_falls_back_to_properties() {
let n = EventNormalizer::new(0);
let out = n.normalize(&raw(
AdapterSource::Bluetooth,
"bluetooth.device.connected",
json!({
"path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
"address": "AA:BB:CC:DD:EE:FF",
"properties": { "Connected": true, "Name": "Fallback Name" },
}),
1,
));
assert_eq!(out.len(), 1);
assert_eq!(out[0].data.get("name").unwrap(), "Fallback Name");
}
// ─── Network ───────────────────────────────────────────────────────────
#[test]
fn network_online_and_offline() {
let n = EventNormalizer::new(0);
let online = n.normalize(&raw(
AdapterSource::Network,
"net",
json!({"online": true}),
1,
));
let offline = n.normalize(&raw(
AdapterSource::Network,
"net",
json!({"online": false}),
2,
));
assert_eq!(online[0].event, "bread.network.connected");
assert_eq!(offline[0].event, "bread.network.disconnected");
}
// ─── System pass-through ───────────────────────────────────────────────
#[test]
fn system_events_pass_through_unchanged() {
let n = EventNormalizer::new(0);
let out = n.normalize(&raw(
AdapterSource::System,
"bread.custom.event",
json!({"foo": "bar"}),
1,
));
assert_eq!(out.len(), 1);
assert_eq!(out[0].event, "bread.custom.event");
assert_eq!(out[0].source, AdapterSource::System);
assert_eq!(out[0].data.get("foo").unwrap(), "bar");
}
// ─── Dedup ─────────────────────────────────────────────────────────────
#[test]
fn dedup_drops_duplicate_within_window() {
let n = EventNormalizer::new(500);
let ev = raw(AdapterSource::Network, "net", json!({"online": true}), 1000);
assert_eq!(n.normalize(&ev).len(), 1);
let dup = raw(AdapterSource::Network, "net", json!({"online": true}), 1200);
assert_eq!(n.normalize(&dup).len(), 0);
}
#[test]
fn dedup_allows_after_window_elapses() {
let n = EventNormalizer::new(500);
let first = raw(AdapterSource::Network, "net", json!({"online": true}), 1000);
assert_eq!(n.normalize(&first).len(), 1);
let later = raw(AdapterSource::Network, "net", json!({"online": true}), 2000);
assert_eq!(n.normalize(&later).len(), 1);
}
#[test]
fn dedup_distinguishes_different_payloads() {
let n = EventNormalizer::new(10_000);
let a = raw(
AdapterSource::Hyprland,
"hypr",
json!({"kind": "workspace", "data": "1"}),
1000,
);
let b = raw(
AdapterSource::Hyprland,
"hypr",
json!({"kind": "workspace", "data": "2"}),
1100,
);
assert_eq!(n.normalize(&a).len(), 1);
// Different payloads = different dedup key
assert_eq!(n.normalize(&b).len(), 1);
}
#[test]
fn dedup_window_of_zero_allows_everything() {
let n = EventNormalizer::new(0);
for _ in 0..3 {
assert_eq!(
n.normalize(&raw(
AdapterSource::Network,
"net",
json!({"online": true}),
1000,
))
.len(),
1
);
}
}
// ─── Helper ────────────────────────────────────────────────────────────
#[test]
fn split_fields_handles_empty_and_single() {
assert!(split_hyprland_fields("").is_empty());
assert_eq!(split_hyprland_fields("only"), vec!["only"]);
assert_eq!(split_hyprland_fields("a>>b>>c"), vec!["a", "b", "c"]);
} }
if subsystem == "input" && name.contains("keyboard") {
return DeviceClass::Keyboard;
}
if subsystem == "input" && name.contains("mouse") {
return DeviceClass::Mouse;
}
if subsystem == "drm" {
return DeviceClass::Display;
}
if subsystem == "sound" || name.contains("audio") {
return DeviceClass::Audio;
}
if subsystem == "block" || name.contains("storage") {
return DeviceClass::Storage;
}
DeviceClass::Unknown
} }

File diff suppressed because it is too large Load diff

View file

@ -18,7 +18,12 @@ pub struct SubscriptionTable {
} }
impl SubscriptionTable { impl SubscriptionTable {
pub fn add_with_id(&mut self, id: SubscriptionId, pattern: String, once: bool) -> SubscriptionId { pub fn add_with_id(
&mut self,
id: SubscriptionId,
pattern: String,
once: bool,
) -> SubscriptionId {
self.next_id = self.next_id.max(id.0.saturating_add(1)); self.next_id = self.next_id.max(id.0.saturating_add(1));
let sub = Subscription { id, pattern, once }; let sub = Subscription { id, pattern, once };
@ -35,7 +40,6 @@ impl SubscriptionTable {
// swap_remove moves the last element into `idx`. We need to update by_id // swap_remove moves the last element into `idx`. We need to update by_id
// for that element. But first, remove its stale entry (it was at the last // for that element. But first, remove its stale entry (it was at the last
// position before the swap); then re-insert it at the new position. // position before the swap); then re-insert it at the new position.
let last_idx = self.entries.len() - 1;
self.entries.swap_remove(idx); self.entries.swap_remove(idx);
if idx < self.entries.len() { if idx < self.entries.len() {
@ -68,5 +72,222 @@ fn matches_pattern(pattern: &str, event_name: &str) -> bool {
return event_name.starts_with(prefix); return event_name.starts_with(prefix);
} }
pattern == event_name if let Some(prefix) = pattern.strip_suffix(".**") {
if event_name == prefix {
return true;
}
}
matches_glob(pattern.as_bytes(), event_name.as_bytes())
}
fn matches_glob(pattern: &[u8], text: &[u8]) -> bool {
if pattern.is_empty() {
return text.is_empty();
}
if pattern.len() >= 2 && pattern[0] == b'*' && pattern[1] == b'*' {
let mut idx = 2;
while pattern.len() >= idx + 2 && pattern[idx] == b'*' && pattern[idx + 1] == b'*' {
idx += 2;
}
let rest = &pattern[idx..];
if rest.is_empty() {
return true;
}
for offset in 0..=text.len() {
if matches_glob(rest, &text[offset..]) {
return true;
}
}
return false;
}
match pattern[0] {
b'*' => {
let mut offset = 0;
loop {
if matches_glob(&pattern[1..], &text[offset..]) {
return true;
}
if offset == text.len() || text[offset] == b'.' {
break;
}
offset += 1;
}
false
}
b'?' => {
if text.is_empty() || text[0] == b'.' {
return false;
}
matches_glob(&pattern[1..], &text[1..])
}
ch => {
if text.first().copied() != Some(ch) {
return false;
}
matches_glob(&pattern[1..], &text[1..])
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exact_match() {
assert!(matches_pattern(
"bread.device.dock.connected",
"bread.device.dock.connected"
));
assert!(!matches_pattern(
"bread.device.dock.connected",
"bread.device.dock.disconnected"
));
}
#[test]
fn single_segment_wildcard() {
assert!(matches_pattern(
"bread.device.*",
"bread.device.dock.connected"
));
assert!(matches_pattern("bread.device.*", "bread.device.foo"));
assert!(!matches_pattern("bread.device.*", "bread.device"));
}
#[test]
fn recursive_wildcard() {
assert!(matches_pattern(
"bread.device.**",
"bread.device.dock.connected"
));
assert!(matches_pattern("bread.**", "bread.device.dock.connected"));
assert!(matches_pattern("bread.**", "bread"));
}
#[test]
fn single_char_wildcard() {
assert!(matches_pattern("bread.monitor.?", "bread.monitor.1"));
assert!(!matches_pattern("bread.monitor.?", "bread.monitor.10"));
assert!(!matches_pattern("bread.monitor.?", "bread.monitor."));
}
#[test]
fn star_does_not_cross_dot_segments() {
// `*` matches within a segment only.
assert!(matches_pattern(
"bread.*.connected",
"bread.device.connected"
));
assert!(!matches_pattern(
"bread.*.connected",
"bread.device.dock.connected"
));
}
#[test]
fn double_star_matches_zero_or_more_segments() {
assert!(matches_pattern("bread.**", "bread.a"));
assert!(matches_pattern("bread.**", "bread.a.b.c.d"));
}
#[test]
fn empty_pattern_matches_only_empty_text() {
assert!(matches_pattern("", ""));
assert!(!matches_pattern("", "bread"));
}
#[test]
fn empty_text_only_matches_wildcards() {
assert!(matches_pattern("**", ""));
assert!(!matches_pattern("bread.*", ""));
}
// ─── SubscriptionTable ────────────────────────────────────────────────
#[test]
fn table_add_assigns_provided_id_and_finds_match() {
let mut t = SubscriptionTable::default();
let id = t.add_with_id(SubscriptionId(7), "bread.window.*".into(), false);
assert_eq!(id, SubscriptionId(7));
let matches = t.match_event("bread.window.opened");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].id, SubscriptionId(7));
assert_eq!(matches[0].pattern, "bread.window.*");
assert!(!matches[0].once);
}
#[test]
fn table_match_returns_all_matching_subscriptions() {
let mut t = SubscriptionTable::default();
t.add_with_id(SubscriptionId(1), "bread.window.opened".into(), false);
t.add_with_id(SubscriptionId(2), "bread.window.*".into(), false);
t.add_with_id(SubscriptionId(3), "bread.**".into(), true);
t.add_with_id(SubscriptionId(4), "bread.device.*".into(), false);
let matches = t.match_event("bread.window.opened");
let ids: Vec<u64> = matches.iter().map(|s| s.id.0).collect();
assert!(ids.contains(&1));
assert!(ids.contains(&2));
assert!(ids.contains(&3));
assert!(!ids.contains(&4));
}
#[test]
fn table_remove_returns_true_only_for_known_ids() {
let mut t = SubscriptionTable::default();
t.add_with_id(SubscriptionId(1), "a".into(), false);
assert!(t.remove(SubscriptionId(1)));
// Second remove of the same id is false.
assert!(!t.remove(SubscriptionId(1)));
// Removing a never-known id is false.
assert!(!t.remove(SubscriptionId(999)));
}
#[test]
fn table_remove_preserves_other_entries_after_swap_remove() {
let mut t = SubscriptionTable::default();
t.add_with_id(SubscriptionId(1), "a".into(), false);
t.add_with_id(SubscriptionId(2), "b".into(), false);
t.add_with_id(SubscriptionId(3), "c".into(), false);
// Remove the middle entry — swap_remove will move entry 3 into the slot.
assert!(t.remove(SubscriptionId(2)));
// Subsequent removes still work, proving the by_id index was kept consistent.
assert!(t.remove(SubscriptionId(3)));
assert!(t.remove(SubscriptionId(1)));
}
#[test]
fn table_clear_removes_all() {
let mut t = SubscriptionTable::default();
t.add_with_id(SubscriptionId(1), "a".into(), false);
t.add_with_id(SubscriptionId(2), "b".into(), false);
t.clear();
assert!(t.match_event("a").is_empty());
assert!(t.match_event("b").is_empty());
// After clear, the ids are reusable.
assert!(!t.remove(SubscriptionId(1)));
}
#[test]
fn table_match_returns_empty_for_unmatched_event() {
let mut t = SubscriptionTable::default();
t.add_with_id(SubscriptionId(1), "bread.device.*".into(), false);
assert!(t.match_event("bread.window.opened").is_empty());
}
#[test]
fn table_once_flag_is_preserved_in_match_result() {
let mut t = SubscriptionTable::default();
t.add_with_id(SubscriptionId(1), "bread.test".into(), true);
let matches = t.match_event("bread.test");
assert_eq!(matches.len(), 1);
assert!(matches[0].once);
}
} }

View file

@ -8,8 +8,7 @@ pub fn spawn_supervised<F, Fut>(
name: &'static str, name: &'static str,
mut shutdown_rx: watch::Receiver<bool>, mut shutdown_rx: watch::Receiver<bool>,
mut task_factory: F, mut task_factory: F,
) ) where
where
F: FnMut() -> Fut + Send + 'static, F: FnMut() -> Fut + Send + 'static,
Fut: Future<Output = anyhow::Result<()>> + Send + 'static, Fut: Future<Output = anyhow::Result<()>> + Send + 'static,
{ {
@ -50,7 +49,11 @@ where
} }
let wait_ms = 500u64.saturating_mul(2u64.saturating_pow(attempt.min(6))); let wait_ms = 500u64.saturating_mul(2u64.saturating_pow(attempt.min(6)));
warn!(adapter = name, delay_ms = wait_ms, "restarting adapter after failure"); warn!(
adapter = name,
delay_ms = wait_ms,
"restarting adapter after failure"
);
tokio::select! { tokio::select! {
_ = sleep(Duration::from_millis(wait_ms)) => {}, _ = sleep(Duration::from_millis(wait_ms)) => {},
_ = shutdown_rx.changed() => { _ = shutdown_rx.changed() => {

View file

@ -1,8 +1,9 @@
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RuntimeState { pub struct RuntimeState {
pub monitors: Vec<Monitor>, pub monitors: Vec<Monitor>,
pub workspaces: Vec<Workspace>, pub workspaces: Vec<Workspace>,
@ -15,22 +16,6 @@ pub struct RuntimeState {
pub modules: Vec<ModuleStatus>, pub modules: Vec<ModuleStatus>,
} }
impl Default for RuntimeState {
fn default() -> Self {
Self {
monitors: Vec::new(),
workspaces: Vec::new(),
active_workspace: None,
active_window: None,
devices: DeviceTopology::default(),
network: NetworkState::default(),
power: PowerState::default(),
profile: ProfileState::default(),
modules: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Monitor { pub struct Monitor {
pub name: String, pub name: String,
@ -54,21 +39,38 @@ pub struct DeviceTopology {
pub struct Device { pub struct Device {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub class: DeviceClass, pub device: String,
pub subsystem: String, pub subsystem: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub vendor_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub product_id: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] /// One set of match conditions. All provided fields must match.
#[serde(rename_all = "snake_case")] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub enum DeviceClass { pub struct MatchCondition {
Dock, pub vendor_id: Option<String>,
Keyboard, pub product_id: Option<String>,
Mouse, pub name: Option<String>,
Tablet, pub vendor: Option<String>,
Display, pub name_contains: Option<String>,
Storage, pub id_input_keyboard: Option<bool>,
Audio, pub id_input_mouse: Option<bool>,
Unknown, pub id_input_tablet: Option<bool>,
/// True triggers the compound USB hub + secondary-interface check.
pub usb_hub: Option<bool>,
pub id_usb_class: Option<String>,
pub subsystem: Option<String>,
}
/// A device rule from `devices.lua`. The device name is assigned if ANY
/// condition in `conditions` matches (OR semantics across conditions,
/// AND semantics within a condition).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceRule {
pub device: String,
pub conditions: Vec<MatchCondition>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
@ -82,23 +84,13 @@ pub struct InterfaceState {
pub up: bool, pub up: bool,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PowerState { pub struct PowerState {
pub ac_connected: bool, pub ac_connected: bool,
pub battery_percent: Option<u8>, pub battery_percent: Option<u8>,
pub battery_low: bool, pub battery_low: bool,
} }
impl Default for PowerState {
fn default() -> Self {
Self {
ac_connected: false,
battery_percent: None,
battery_low: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileState { pub struct ProfileState {
pub active: String, pub active: String,
@ -121,6 +113,10 @@ pub struct ModuleStatus {
pub name: String, pub name: String,
pub status: ModuleLoadState, pub status: ModuleLoadState,
pub last_error: Option<String>, pub last_error: Option<String>,
#[serde(default)]
pub builtin: bool,
#[serde(default)]
pub store: HashMap<String, Value>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -129,4 +125,6 @@ pub enum ModuleLoadState {
Loaded, Loaded,
LoadError, LoadError,
NotFound, NotFound,
Degraded,
Disabled,
} }

View file

@ -1,18 +1,22 @@
use std::collections::{HashMap, VecDeque};
use std::fs; use std::fs;
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf; use std::path::PathBuf;
use std::process; use std::process;
use std::sync::atomic::AtomicU64;
use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use bread_shared::{AdapterSource, BreadEvent}; use bread_shared::{now_unix_ms, AdapterSource, BreadEvent};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream}; use tokio::net::{UnixListener, UnixStream};
use tokio::sync::{broadcast, mpsc, watch}; use tokio::sync::{broadcast, mpsc, watch, RwLock};
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use crate::adapters::AdapterStatus;
use crate::core::state_engine::StateHandle; use crate::core::state_engine::StateHandle;
use crate::lua::RuntimeHandle; use crate::lua::RuntimeHandle;
@ -23,6 +27,9 @@ pub struct Server {
event_tx: broadcast::Sender<BreadEvent>, event_tx: broadcast::Sender<BreadEvent>,
lua_runtime: RuntimeHandle, lua_runtime: RuntimeHandle,
emit_tx: mpsc::UnboundedSender<BreadEvent>, emit_tx: mpsc::UnboundedSender<BreadEvent>,
adapter_status: Arc<RwLock<HashMap<String, AdapterStatus>>>,
subscription_count: Arc<AtomicU64>,
event_buffer: Arc<std::sync::Mutex<VecDeque<BreadEvent>>>,
started_at: Instant, started_at: Instant,
pid: u32, pid: u32,
} }
@ -45,12 +52,18 @@ struct IpcResponse {
} }
impl Server { impl Server {
// Server::new legitimately requires all 8 fields; a builder pattern here would be
// over-engineering for a single-call-site constructor.
#[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
socket_path: PathBuf, socket_path: PathBuf,
state_handle: StateHandle, state_handle: StateHandle,
event_tx: broadcast::Sender<BreadEvent>, event_tx: broadcast::Sender<BreadEvent>,
lua_runtime: RuntimeHandle, lua_runtime: RuntimeHandle,
emit_tx: mpsc::UnboundedSender<BreadEvent>, emit_tx: mpsc::UnboundedSender<BreadEvent>,
adapter_status: Arc<RwLock<HashMap<String, AdapterStatus>>>,
subscription_count: Arc<AtomicU64>,
event_buffer: Arc<std::sync::Mutex<VecDeque<BreadEvent>>>,
) -> Self { ) -> Self {
Self { Self {
socket_path, socket_path,
@ -58,6 +71,9 @@ impl Server {
event_tx, event_tx,
lua_runtime, lua_runtime,
emit_tx, emit_tx,
adapter_status,
subscription_count,
event_buffer,
started_at: Instant::now(), started_at: Instant::now(),
pid: process::id(), pid: process::id(),
} }
@ -148,7 +164,10 @@ impl Server {
Ok(()) Ok(())
} }
async fn handle_request(&self, req: IpcRequest) -> std::result::Result<(String, Value), (String, String)> { async fn handle_request(
&self,
req: IpcRequest,
) -> std::result::Result<(String, Value), (String, String)> {
let id = req.id.clone(); let id = req.id.clone();
let result = match req.method.as_str() { let result = match req.method.as_str() {
"ping" => Ok(json!({ "ok": true })), "ping" => Ok(json!({ "ok": true })),
@ -166,12 +185,25 @@ impl Server {
let full = self.state_handle.state_dump().await; let full = self.state_handle.state_dump().await;
Ok(full.get("modules").cloned().unwrap_or_else(|| json!([]))) Ok(full.get("modules").cloned().unwrap_or_else(|| json!([])))
} }
"modules.reload" => self "modules.reload" => {
.lua_runtime let started = Instant::now();
.reload() if let Err(err) = self.lua_runtime.reload().await {
return Err((id, err.to_string()));
}
let duration_ms = started.elapsed().as_millis();
let modules = self
.state_handle
.state_dump()
.await .await
.map(|_| json!({ "reloaded": true })) .get("modules")
.map_err(|e| e.to_string()), .cloned()
.unwrap_or_else(|| json!([]));
Ok(json!({
"ok": true,
"duration_ms": duration_ms,
"modules": modules,
}))
}
"profile.list" => { "profile.list" => {
let full = self.state_handle.state_dump().await; let full = self.state_handle.state_dump().await;
let profiles = full let profiles = full
@ -182,11 +214,7 @@ impl Server {
Ok(profiles) Ok(profiles)
} }
"profile.activate" => { "profile.activate" => {
let Some(name) = req let Some(name) = req.params.get("name").and_then(Value::as_str) else {
.params
.get("name")
.and_then(Value::as_str)
else {
return Err((id, "missing profile name".to_string())); return Err((id, "missing profile name".to_string()));
}; };
@ -205,11 +233,7 @@ impl Server {
Ok(json!({ "active": name })) Ok(json!({ "active": name }))
} }
"emit" => { "emit" => {
let Some(event) = req let Some(event) = req.params.get("event").and_then(Value::as_str) else {
.params
.get("event")
.and_then(Value::as_str)
else {
return Err((id, "missing event name".to_string())); return Err((id, "missing event name".to_string()));
}; };
let data = req.params.get("data").cloned().unwrap_or_else(|| json!({})); let data = req.params.get("data").cloned().unwrap_or_else(|| json!({}));
@ -224,13 +248,70 @@ impl Server {
} }
"health" => { "health" => {
let uptime_ms = self.started_at.elapsed().as_millis(); let uptime_ms = self.started_at.elapsed().as_millis();
let state = self.state_handle.state_dump().await;
let modules = state.get("modules").cloned().unwrap_or_else(|| json!([]));
let adapters = self.adapter_status.read().await.clone();
let subscription_count = self
.subscription_count
.load(std::sync::atomic::Ordering::Relaxed);
let recent_errors = self.lua_runtime.recent_errors();
Ok(json!({ Ok(json!({
"ok": true, "ok": true,
"pid": self.pid, "pid": self.pid,
"version": env!("CARGO_PKG_VERSION"), "version": env!("CARGO_PKG_VERSION"),
"uptime_ms": uptime_ms, "uptime_ms": uptime_ms,
"socket": self.socket_path.to_string_lossy(),
"adapters": adapters,
"modules": modules,
"subscriptions": subscription_count,
"recent_errors": recent_errors,
})) }))
} }
"sync.status" => {
let sync_path = bread_sync::config::bread_config_dir().join("sync.toml");
match std::fs::read_to_string(&sync_path)
.ok()
.and_then(|s| s.parse::<toml::Value>().ok())
{
Some(toml) => {
let machine = toml
.get("machine")
.and_then(|m| m.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let remote = toml
.get("remote")
.and_then(|r| r.get("url"))
.and_then(|v| v.as_str())
.unwrap_or("unknown");
Ok(json!({
"initialized": true,
"machine": machine,
"remote": remote,
}))
}
None => Ok(json!({ "initialized": false })),
}
}
"events.replay" => {
let since_ms = req
.params
.get("since_ms")
.and_then(Value::as_u64)
.unwrap_or(0);
let cutoff = now_unix_ms().saturating_sub(since_ms);
let replay: Vec<BreadEvent> = self
.event_buffer
.lock()
.map(|buf| {
buf.iter()
.filter(|e| e.timestamp >= cutoff)
.cloned()
.collect()
})
.unwrap_or_default();
Ok(serde_json::to_value(replay).unwrap_or_else(|_| json!([])))
}
_ => Err("unknown method".to_string()), _ => Err("unknown method".to_string()),
}; };
@ -264,9 +345,134 @@ impl Server {
} }
fn matches_filter(event_name: &str, pattern: &str) -> bool { fn matches_filter(event_name: &str, pattern: &str) -> bool {
// Delegate to the same glob logic used by the subscription table so that
// `bread events --filter "bread.device.**"` behaves identically to
// `bread.on("bread.device.**", ...)` in Lua.
if pattern.ends_with(".*") { if pattern.ends_with(".*") {
let prefix = &pattern[..pattern.len() - 1]; let prefix = &pattern[..pattern.len() - 1];
return event_name.starts_with(prefix); return event_name.starts_with(prefix);
} }
event_name == pattern
if let Some(prefix) = pattern.strip_suffix(".**") {
if event_name == prefix || event_name.starts_with(&format!("{prefix}.")) {
return true;
}
return false;
}
matches_glob_filter(pattern.as_bytes(), event_name.as_bytes())
}
fn matches_glob_filter(pattern: &[u8], text: &[u8]) -> bool {
if pattern.is_empty() {
return text.is_empty();
}
if pattern.len() >= 2 && pattern[0] == b'*' && pattern[1] == b'*' {
let rest = &pattern[2..];
if rest.is_empty() {
return true;
}
for offset in 0..=text.len() {
if matches_glob_filter(rest, &text[offset..]) {
return true;
}
}
return false;
}
match pattern[0] {
b'*' => {
let mut offset = 0;
loop {
if matches_glob_filter(&pattern[1..], &text[offset..]) {
return true;
}
if offset == text.len() || text[offset] == b'.' {
break;
}
offset += 1;
}
false
}
b'?' => {
if text.is_empty() || text[0] == b'.' {
return false;
}
matches_glob_filter(&pattern[1..], &text[1..])
}
ch => {
if text.first().copied() != Some(ch) {
return false;
}
matches_glob_filter(&pattern[1..], &text[1..])
}
}
}
#[cfg(test)]
mod tests {
use super::matches_filter;
#[test]
fn filter_exact_match() {
assert!(matches_filter("bread.window.opened", "bread.window.opened"));
assert!(!matches_filter(
"bread.window.opened",
"bread.window.closed"
));
}
#[test]
fn filter_dot_star_matches_one_segment_only() {
assert!(matches_filter("bread.device.connected", "bread.device.*"));
assert!(matches_filter(
"bread.device.dock.connected",
"bread.device.*"
));
assert!(!matches_filter("bread.device", "bread.device.*"));
}
#[test]
fn filter_dot_double_star_matches_zero_or_more_segments() {
// Matches the exact prefix (zero segments after).
assert!(matches_filter("bread.device", "bread.device.**"));
// And matches deeper paths.
assert!(matches_filter(
"bread.device.dock.connected",
"bread.device.**"
));
// But not a sibling at the same depth.
assert!(!matches_filter(
"bread.network.connected",
"bread.device.**"
));
}
#[test]
fn filter_question_mark_matches_single_char_not_dot() {
assert!(matches_filter("bread.x", "bread.?"));
assert!(!matches_filter("bread.xy", "bread.?"));
assert!(!matches_filter("bread.", "bread.?"));
}
#[test]
fn filter_mid_pattern_star_does_not_cross_dots() {
// A `*` in the middle of the pattern (not the `.*` suffix shortcut)
// matches within a single segment only.
assert!(matches_filter("bread.alpha.connected", "bread.*.connected"));
assert!(!matches_filter(
"bread.alpha.beta.connected",
"bread.*.connected"
));
}
#[test]
fn filter_dot_star_at_end_acts_as_prefix_match() {
// `bread.*` ending the pattern is treated as a prefix match, so
// matches everything under `bread.` regardless of depth. This is
// consistent with the subscription table's pattern matcher.
assert!(matches_filter("bread.alpha", "bread.*"));
assert!(matches_filter("bread.alpha.beta", "bread.*"));
}
} }

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,8 @@ mod core;
mod ipc; mod ipc;
mod lua; mod lua;
use std::collections::VecDeque;
use std::sync::atomic::AtomicU64;
use std::sync::Arc; use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
@ -33,9 +35,11 @@ async fn main() -> Result<()> {
let (event_stream_tx, _) = broadcast::channel(2048); let (event_stream_tx, _) = broadcast::channel(2048);
let (shutdown_tx, shutdown_rx) = watch::channel(false); let (shutdown_tx, shutdown_rx) = watch::channel(false);
let subscription_count = Arc::new(AtomicU64::new(0));
let state_handle = StateHandle::new(state.clone(), state_cmd_tx); let state_handle = StateHandle::new(state.clone(), state_cmd_tx);
let lua_runtime = lua::spawn_runtime(config.clone(), state_handle.clone(), normalized_tx.clone())?; let lua_runtime =
lua::spawn_runtime(config.clone(), state_handle.clone(), normalized_tx.clone())?;
let lua_tx = lua_runtime.sender(); let lua_tx = lua_runtime.sender();
tokio::spawn(run_state_engine( tokio::spawn(run_state_engine(
@ -44,6 +48,7 @@ async fn main() -> Result<()> {
state.clone(), state.clone(),
lua_tx, lua_tx,
event_stream_tx.clone(), event_stream_tx.clone(),
subscription_count.clone(),
shutdown_rx.clone(), shutdown_rx.clone(),
)); ));
@ -78,6 +83,28 @@ async fn main() -> Result<()> {
let adapter_manager = adapters::Manager::new(raw_tx, config.clone(), shutdown_rx.clone()); let adapter_manager = adapters::Manager::new(raw_tx, config.clone(), shutdown_rx.clone());
adapter_manager.start_all().await?; adapter_manager.start_all().await?;
let adapter_status = adapter_manager.status_handle();
let event_buffer = Arc::new(std::sync::Mutex::new(VecDeque::with_capacity(1000)));
{
let mut rx = event_stream_tx.subscribe();
let event_buffer = event_buffer.clone();
tokio::spawn(async move {
loop {
let evt = match rx.recv().await {
Ok(evt) => evt,
Err(_) => break,
};
if let Ok(mut buf) = event_buffer.lock() {
if buf.len() >= 1000 {
buf.pop_front();
}
buf.push_back(evt);
}
}
});
}
let _ = normalized_tx.send(BreadEvent::new( let _ = normalized_tx.send(BreadEvent::new(
"bread.system.startup", "bread.system.startup",
AdapterSource::System, AdapterSource::System,
@ -90,6 +117,9 @@ async fn main() -> Result<()> {
event_stream_tx, event_stream_tx,
lua_runtime.clone(), lua_runtime.clone(),
normalized_tx, normalized_tx,
adapter_status,
subscription_count,
event_buffer,
); );
info!("breadd fully started"); info!("breadd fully started");
@ -115,7 +145,8 @@ async fn wait_for_shutdown() {
#[cfg(unix)] #[cfg(unix)]
{ {
use tokio::signal::unix::{signal, SignalKind}; use tokio::signal::unix::{signal, SignalKind};
let mut sigterm = signal(SignalKind::terminate()).expect("failed to install SIGTERM handler"); let mut sigterm =
signal(SignalKind::terminate()).expect("failed to install SIGTERM handler");
tokio::select! { tokio::select! {
_ = ctrl_c => {}, _ = ctrl_c => {},
_ = sigterm.recv() => {}, _ = sigterm.recv() => {},

View file

@ -31,6 +31,291 @@ async fn ping_and_state_dump_work() -> Result<()> {
Ok(()) Ok(())
} }
#[tokio::test]
async fn unknown_method_returns_error() -> Result<()> {
let harness = TestHarness::spawn()?;
harness.wait_until_ready().await?;
let result = harness.send_request("not.a.real.method", json!({})).await;
assert!(result.is_err(), "expected error for unknown method");
let msg = result.err().unwrap().to_string();
assert!(
msg.contains("unknown method"),
"expected 'unknown method', got: {msg}"
);
harness.shutdown();
Ok(())
}
#[tokio::test]
async fn profile_activate_updates_state() -> Result<()> {
let harness = TestHarness::spawn()?;
harness.wait_until_ready().await?;
let result = harness
.send_request("profile.activate", json!({"name": "battery"}))
.await?;
assert_eq!(
result.get("active").and_then(Value::as_str),
Some("battery")
);
let dump = harness.send_request("state.dump", json!({})).await?;
assert_eq!(
dump.get("profile")
.and_then(|v| v.get("active"))
.and_then(Value::as_str),
Some("battery")
);
harness.shutdown();
Ok(())
}
#[tokio::test]
async fn profile_activate_without_name_errors() -> Result<()> {
let harness = TestHarness::spawn()?;
harness.wait_until_ready().await?;
let result = harness.send_request("profile.activate", json!({})).await;
assert!(result.is_err());
let msg = result.err().unwrap().to_string();
assert!(msg.contains("missing profile name"), "got: {msg}");
harness.shutdown();
Ok(())
}
#[tokio::test]
async fn emit_without_event_errors() -> Result<()> {
let harness = TestHarness::spawn()?;
harness.wait_until_ready().await?;
let result = harness.send_request("emit", json!({})).await;
assert!(result.is_err());
harness.shutdown();
Ok(())
}
#[tokio::test]
async fn state_get_returns_specific_subtree() -> Result<()> {
let harness = TestHarness::spawn()?;
harness.wait_until_ready().await?;
let modules = harness
.send_request("state.get", json!({"key": "modules"}))
.await?;
assert!(modules.is_array(), "expected modules to be an array");
let active = harness
.send_request("state.get", json!({"key": "profile.active"}))
.await?;
assert!(
active.as_str().is_some(),
"expected profile.active to be a string, got: {active:?}"
);
harness.shutdown();
Ok(())
}
#[tokio::test]
async fn state_get_missing_key_returns_error() -> Result<()> {
let harness = TestHarness::spawn()?;
harness.wait_until_ready().await?;
let result = harness
.send_request("state.get", json!({"key": "does.not.exist"}))
.await;
assert!(result.is_err());
harness.shutdown();
Ok(())
}
#[tokio::test]
async fn modules_list_returns_array() -> Result<()> {
let harness = TestHarness::spawn()?;
harness.wait_until_ready().await?;
let result = harness.send_request("modules.list", json!({})).await?;
assert!(result.is_array());
harness.shutdown();
Ok(())
}
#[tokio::test]
async fn modules_reload_succeeds() -> Result<()> {
let harness = TestHarness::spawn()?;
harness.wait_until_ready().await?;
let result = harness.send_request("modules.reload", json!({})).await?;
assert_eq!(result.get("ok").and_then(Value::as_bool), Some(true));
assert!(result.get("duration_ms").is_some());
harness.shutdown();
Ok(())
}
#[tokio::test]
async fn sync_status_uninitialized_when_no_config() -> Result<()> {
let harness = TestHarness::spawn()?;
harness.wait_until_ready().await?;
let result = harness.send_request("sync.status", json!({})).await?;
assert_eq!(
result.get("initialized").and_then(Value::as_bool),
Some(false)
);
harness.shutdown();
Ok(())
}
#[tokio::test]
async fn sync_status_reports_initialized_with_config() -> Result<()> {
let harness = TestHarness::spawn_with_sync_config("myhost", "git@example.com:user/repo.git")?;
harness.wait_until_ready().await?;
let result = harness.send_request("sync.status", json!({})).await?;
assert_eq!(
result.get("initialized").and_then(Value::as_bool),
Some(true)
);
assert_eq!(
result.get("machine").and_then(Value::as_str),
Some("myhost")
);
assert_eq!(
result.get("remote").and_then(Value::as_str),
Some("git@example.com:user/repo.git")
);
harness.shutdown();
Ok(())
}
#[tokio::test]
async fn events_replay_returns_buffered_events() -> Result<()> {
let harness = TestHarness::spawn()?;
harness.wait_until_ready().await?;
// Emit a couple of events.
harness
.send_request("emit", json!({"event": "bread.replay.a", "data": {}}))
.await?;
harness
.send_request("emit", json!({"event": "bread.replay.b", "data": {}}))
.await?;
// Small delay so the events make it into the buffer.
sleep(Duration::from_millis(100)).await;
let result = harness
.send_request("events.replay", json!({"since_ms": 10_000}))
.await?;
let arr = result.as_array().expect("replay result should be array");
let names: Vec<&str> = arr
.iter()
.filter_map(|e| e.get("event").and_then(Value::as_str))
.collect();
assert!(names.contains(&"bread.replay.a"));
assert!(names.contains(&"bread.replay.b"));
harness.shutdown();
Ok(())
}
#[tokio::test]
async fn event_stream_filter_excludes_non_matching_events() -> Result<()> {
let harness = TestHarness::spawn()?;
harness.wait_until_ready().await?;
let stream = UnixStream::connect(harness.socket_path()).await?;
let (read_half, mut write_half) = stream.into_split();
let subscribe = json!({
"id": "sub-x",
"method": "events.subscribe",
"params": {
"filter": "bread.match.*"
}
});
write_half
.write_all(format!("{}\n", serde_json::to_string(&subscribe)?).as_bytes())
.await?;
let mut reader = BufReader::new(read_half).lines();
// Consume the ack line.
reader.next_line().await?;
// Emit one matching and one non-matching event.
harness
.send_request("emit", json!({"event": "bread.nomatch.x", "data": {}}))
.await?;
harness
.send_request("emit", json!({"event": "bread.match.yes", "data": {}}))
.await?;
let deadline = Instant::now() + Duration::from_secs(5);
let mut matched = false;
while Instant::now() < deadline {
let Some(line) = reader.next_line().await? else {
break;
};
let event: Value = serde_json::from_str(&line)?;
let name = event.get("event").and_then(Value::as_str).unwrap_or("");
assert!(
!name.starts_with("bread.nomatch"),
"filter let through non-matching event: {name}"
);
if name == "bread.match.yes" {
matched = true;
break;
}
}
assert!(matched, "did not receive matching event through filter");
harness.shutdown();
Ok(())
}
#[tokio::test]
async fn multiple_concurrent_clients_each_get_response() -> Result<()> {
let harness = TestHarness::spawn()?;
harness.wait_until_ready().await?;
let socket = harness.socket_path().to_path_buf();
let mut handles = Vec::new();
for i in 0..8 {
let socket = socket.clone();
handles.push(tokio::spawn(async move {
let stream = UnixStream::connect(&socket).await?;
let (read_half, mut write_half) = stream.into_split();
let req = json!({"id": i.to_string(), "method": "ping", "params": {}});
write_half
.write_all(format!("{}\n", serde_json::to_string(&req)?).as_bytes())
.await?;
let mut lines = BufReader::new(read_half).lines();
let line = lines.next_line().await?.ok_or_else(|| anyhow!("eof"))?;
let parsed: Value = serde_json::from_str(&line)?;
assert_eq!(
parsed.get("id").and_then(Value::as_str),
Some(i.to_string().as_str())
);
Ok::<(), anyhow::Error>(())
}));
}
for h in handles {
h.await??;
}
harness.shutdown();
Ok(())
}
#[tokio::test] #[tokio::test]
async fn events_stream_receives_emitted_events() -> Result<()> { async fn events_stream_receives_emitted_events() -> Result<()> {
let harness = TestHarness::spawn()?; let harness = TestHarness::spawn()?;
@ -100,6 +385,14 @@ struct TestHarness {
impl TestHarness { impl TestHarness {
fn spawn() -> Result<Self> { fn spawn() -> Result<Self> {
Self::spawn_inner(None)
}
fn spawn_with_sync_config(machine: &str, remote_url: &str) -> Result<Self> {
Self::spawn_inner(Some((machine.to_string(), remote_url.to_string())))
}
fn spawn_inner(sync_config: Option<(String, String)>) -> Result<Self> {
let temp = tempfile::tempdir()?; let temp = tempfile::tempdir()?;
let runtime_dir = temp.path().join("runtime"); let runtime_dir = temp.path().join("runtime");
let config_home = temp.path().join("config"); let config_home = temp.path().join("config");
@ -140,6 +433,21 @@ enabled = false
"#, "#,
)?; )?;
if let Some((machine, remote_url)) = sync_config {
let sync_toml = format!(
r#"
[remote]
url = "{remote_url}"
branch = "main"
[machine]
name = "{machine}"
tags = []
"#
);
fs::write(bread_cfg.join("sync.toml"), sync_toml)?;
}
let socket_path = runtime_dir.join("bread").join("breadd.sock"); let socket_path = runtime_dir.join("bread").join("breadd.sock");
let child = Command::new(env!("CARGO_BIN_EXE_breadd")) let child = Command::new(env!("CARGO_BIN_EXE_breadd"))
.env("XDG_RUNTIME_DIR", &runtime_dir) .env("XDG_RUNTIME_DIR", &runtime_dir)

View file

@ -1,5 +1,47 @@
Packaging notes Packaging
================ =========
This repo targets Arch packaging. The Arch PKGBUILD skeleton lives under This directory contains distribution packaging for Bread.
`packaging/arch/`.
```
packaging/
├── arch/
│ └── PKGBUILD ← Arch Linux package build script
└── systemd/
└── breadd.service ← systemd user service unit
```
## Arch Linux
```bash
cd packaging/arch
makepkg -si
```
The PKGBUILD builds both `breadd` and `bread` from source and installs them to `/usr/bin`. It also installs the systemd user service unit to `/usr/lib/systemd/user/`.
Before publishing to the AUR, update `pkgver`, `source`, and `sha256sums` to point at a tagged release tarball.
## systemd user service
The service unit starts `breadd` as a user service after the graphical session is available.
```bash
# Install and enable manually (if not using the PKGBUILD)
mkdir -p ~/.config/systemd/user
cp systemd/breadd.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now breadd
# Check status
systemctl --user status breadd
journalctl --user -u breadd -f
```
The service sets `RUST_LOG=info` by default. To increase verbosity, override it in a drop-in:
```ini
# ~/.config/systemd/user/breadd.service.d/debug.conf
[Service]
Environment=RUST_LOG=debug
```

View file

@ -1,14 +1,19 @@
# Maintainer: Your Name <you@example.com> # Maintainer: Breadway <rileyhorsham@gmail.com>
pkgname=breadd pkgname=bread
pkgver=0.1.0 pkgver=1.0.0
pkgrel=1 pkgrel=1
pkgdesc="Bread daemon - event normalizer and automation runtime" pkgdesc="A reactive automation fabric for Linux desktops"
arch=('x86_64') arch=('x86_64')
url="https://github.com/Breadway/bread" url="https://github.com/Breadway/bread"
license=('MIT') license=('MIT')
depends=('glibc') depends=('glibc' 'libgit2')
makedepends=('rust') optdepends=(
'libnotify: desktop notifications via bread.notify()'
'upower: D-Bus battery events (sysfs polling used otherwise)'
'git: bread sync push/pull operations'
)
makedepends=('rust' 'cargo')
source=("${pkgname}-${pkgver}.tar.gz") source=("${pkgname}-${pkgver}.tar.gz")
sha256sums=('SKIP') sha256sums=('SKIP')
@ -17,9 +22,15 @@ build() {
cargo build --release --locked cargo build --release --locked
} }
check() {
cd "${srcdir}/${pkgname}-${pkgver}"
cargo test --release --locked --workspace
}
package() { package() {
cd "${srcdir}/${pkgname}-${pkgver}" cd "${srcdir}/${pkgname}-${pkgver}"
install -Dm755 target/release/breadd "${pkgdir}/usr/bin/breadd" install -Dm755 target/release/breadd "${pkgdir}/usr/bin/breadd"
install -Dm755 target/release/bread-cli "${pkgdir}/usr/bin/bread-cli" install -Dm755 target/release/bread "${pkgdir}/usr/bin/bread"
install -Dm644 packaging/systemd/breadd.service "${pkgdir}/usr/lib/systemd/user/breadd.service" install -Dm644 packaging/systemd/breadd.service "${pkgdir}/usr/lib/systemd/user/breadd.service"
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
} }

View file

@ -1,9 +1,29 @@
Arch packaging Arch packaging
============== ==============
This is a minimal PKGBUILD skeleton. `PKGBUILD` builds and installs both `breadd` and `bread` from source.
Steps to use: ## Local build
- Update `pkgver`, `source`, `sha256sums`, and `url`.
- Set the correct license and dependencies. ```bash
- Ensure the release tarball includes `packaging/systemd/breadd.service`. makepkg -si
```
## Before publishing to AUR
1. Tag a release on GitHub.
2. Update `pkgver` to match the tag.
3. Update `source` to the release tarball URL.
4. Run `updpkgsums` (or manually set `sha256sums`).
5. Update `url` if the repository has moved.
6. Set `depends` accurately — at minimum: `glibc`. Add `udev` and `libgit2` if not linking statically.
## Runtime dependencies
| Package | Required | Notes |
|---------|----------|-------|
| `glibc` | yes | always |
| `udev` | yes | device events |
| `dbus` | optional | UPower battery events |
| `libnotify` | optional | `bread.notify()` (uses `notify-send`) |
| `git` | optional | `bread sync` push/pull |

View file

@ -5,7 +5,7 @@ Wants=graphical-session.target
[Service] [Service]
Type=simple Type=simple
ExecStart=%h/.cargo/bin/breadd ExecStart=/usr/bin/breadd
Restart=on-failure Restart=on-failure
RestartSec=2 RestartSec=2
UMask=0077 UMask=0077

109
scripts/install.sh Executable file
View file

@ -0,0 +1,109 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BIN_DIR="${BIN_DIR:-$HOME/.local/bin}"
SERVICE_DIR="${HOME}/.config/systemd/user"
CONFIG_DIR="${HOME}/.config/bread"
MODULES_DIR="${CONFIG_DIR}/modules"
# ── build ──────────────────────────────────────────────────────────────────────
echo "building bread (release)..."
cargo build --release --manifest-path "$REPO_ROOT/Cargo.toml"
echo ""
# ── symlinks ───────────────────────────────────────────────────────────────────
echo "symlinking binaries into $BIN_DIR..."
mkdir -p "$BIN_DIR"
ln -sf "$REPO_ROOT/target/release/breadd" "$BIN_DIR/breadd"
ln -sf "$REPO_ROOT/target/release/bread" "$BIN_DIR/bread"
echo " $BIN_DIR/breadd -> $REPO_ROOT/target/release/breadd"
echo " $BIN_DIR/bread -> $REPO_ROOT/target/release/bread"
if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then
echo ""
echo " note: $BIN_DIR is not in PATH — add to your shell profile:"
echo " export PATH=\"\$HOME/.local/bin:\$PATH\""
fi
echo ""
# ── config ─────────────────────────────────────────────────────────────────────
echo "setting up config..."
mkdir -p "$CONFIG_DIR" "$MODULES_DIR"
if [[ ! -f "$CONFIG_DIR/breadd.toml" ]]; then
cat > "$CONFIG_DIR/breadd.toml" << 'EOF'
[daemon]
log_level = "info"
[lua]
entry_point = "~/.config/bread/init.lua"
module_path = "~/.config/bread/modules"
[adapters.hyprland]
enabled = true
[adapters.udev]
enabled = true
[adapters.power]
enabled = true
[adapters.network]
enabled = true
EOF
echo " created $CONFIG_DIR/breadd.toml"
else
echo " $CONFIG_DIR/breadd.toml already exists, skipping"
fi
if [[ ! -f "$CONFIG_DIR/init.lua" ]]; then
cat > "$CONFIG_DIR/init.lua" << 'EOF'
-- bread init.lua — loaded before modules, use for global setup
bread.log("bread started")
EOF
echo " created $CONFIG_DIR/init.lua"
else
echo " $CONFIG_DIR/init.lua already exists, skipping"
fi
echo ""
# ── systemd user service ───────────────────────────────────────────────────────
echo "installing systemd user service..."
mkdir -p "$SERVICE_DIR"
# Patch ExecStart to match the actual install location rather than hardcoding /usr/bin.
sed "s|ExecStart=.*|ExecStart=$BIN_DIR/breadd|" \
"$REPO_ROOT/packaging/systemd/breadd.service" \
> "$SERVICE_DIR/breadd.service"
echo " installed $SERVICE_DIR/breadd.service (ExecStart=$BIN_DIR/breadd)"
systemctl --user daemon-reload
if systemctl --user is-active --quiet breadd 2>/dev/null; then
systemctl --user restart breadd
echo " breadd restarted"
else
systemctl --user enable --now breadd
echo " breadd enabled and started"
fi
echo ""
# ── verify ─────────────────────────────────────────────────────────────────────
# Wait up to ~5s for the daemon to come up. Polling beats a fixed sleep
# because a freshly enabled systemd unit can take a variable amount of time
# to fork, bind the socket, and become ready.
ready=0
for _ in $(seq 1 25); do
if "$BIN_DIR/bread" ping &>/dev/null; then
ready=1
break
fi
sleep 0.2
done
if [[ "$ready" -eq 1 ]]; then
"$BIN_DIR/bread" doctor
else
echo "warning: daemon did not respond to ping within 5s"
echo " check: journalctl --user -u breadd -n 20"
fi