From 1208c5d1b71a3b84f9bea384786a7866232aba75 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 19 Jul 2026 03:05:57 +0800 Subject: [PATCH] Add filesystem/git/podman/systemd adapters, git/shell hooks, bread-emit CLI, app-detection helpers --- Cargo.lock | 11 + Cargo.toml | 1 + Documentation.md | 186 +++++++++- Examples.md | 59 ++++ README.md | 296 +--------------- bread-cli/src/hooks_git.rs | 357 ++++++++++++++++++++ bread-cli/src/hooks_shell.rs | 512 ++++++++++++++++++++++++++++ bread-cli/src/main.rs | 58 +++- bread-emit/Cargo.toml | 12 + bread-emit/src/main.rs | 75 +++++ bread-shared/Cargo.toml | 2 + bread-shared/src/apps.rs | 113 +++++++ bread-shared/src/glob.rs | 10 +- bread-shared/src/lib.rs | 102 +++++- breadd/Cargo.toml | 1 + breadd/src/adapters/filesystem.rs | 470 ++++++++++++++++++++++++++ breadd/src/adapters/git.rs | 524 +++++++++++++++++++++++++++++ breadd/src/adapters/mod.rs | 30 ++ breadd/src/adapters/podman.rs | 304 +++++++++++++++++ breadd/src/adapters/systemd.rs | 293 ++++++++++++++++ breadd/src/core/config.rs | 87 ++++- breadd/src/core/normalizer.rs | 111 +++++- breadd/src/core/types.rs | 32 ++ breadd/src/ipc/mod.rs | 96 +++++- breadd/src/lua/mod.rs | 372 +++++++++++++++++++- breadd/src/main.rs | 2 + breadd/tests/ipc_integration.rs | 264 ++++++++++++++- examples/modules/dock-workflow.lua | 53 +++ packaging/arch/PKGBUILD | 4 +- 29 files changed, 4098 insertions(+), 339 deletions(-) create mode 100644 bread-cli/src/hooks_git.rs create mode 100644 bread-cli/src/hooks_shell.rs create mode 100644 bread-emit/Cargo.toml create mode 100644 bread-emit/src/main.rs create mode 100644 bread-shared/src/apps.rs create mode 100644 breadd/src/adapters/filesystem.rs create mode 100644 breadd/src/adapters/git.rs create mode 100644 breadd/src/adapters/podman.rs create mode 100644 breadd/src/adapters/systemd.rs create mode 100644 examples/modules/dock-workflow.lua diff --git a/Cargo.lock b/Cargo.lock index 29f5aa2..44f3cf3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -309,12 +309,22 @@ dependencies = [ "toml", ] +[[package]] +name = "bread-emit" +version = "0.6.6" +dependencies = [ + "bread-shared", + "serde_json", +] + [[package]] name = "bread-shared" version = "0.6.6" dependencies = [ + "dirs", "serde", "serde_json", + "toml", ] [[package]] @@ -329,6 +339,7 @@ dependencies = [ "mlua", "netlink-packet-core", "netlink-packet-route", + "notify", "rtnetlink", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 8837389..1190489 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "bread-shared", "breadd", "bread-cli", + "bread-emit", ] resolver = "2" diff --git a/Documentation.md b/Documentation.md index a06699f..36fa768 100644 --- a/Documentation.md +++ b/Documentation.md @@ -3,15 +3,19 @@ ## Contents - [Overview](#overview) +- [API Stability & Versioning](#api-stability--versioning) - [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) - [Debugging tips](#debugging-tips) - [Dictionary: Lua API](#dictionary-lua-api) + - [Workflows](#workflows-since-v12) - [Bluetooth](#bluetooth) - [Dictionary: Built-in modules](#dictionary-built-in-modules) - [Dictionary: Event reference](#dictionary-event-reference) +- [Namespaces](#namespaces) +- [Integrating a bread* app](#integrating-a-bread-app) - [Dictionary: Runtime state schema](#dictionary-runtime-state-schema) - [Dictionary: IPC protocol](#dictionary-ipc-protocol) @@ -23,10 +27,21 @@ Bread is a reactive automation fabric for Linux desktops. The daemon (`breadd`) - **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. +Adapters currently supported: Hyprland compositor IPC, Linux udev/netlink, UPower/sysfs power, rtnetlink/sysfs network, BlueZ Bluetooth, shell precmd/preexec hooks (terminal), git hooks + a dirty-state poller, project-root filesystem watches, `systemd --user` unit state, Podman container events, and SSH/remote session detection. Sibling `bread*` applications (breadclip, breadpad, and others across the BOS ecosystem) integrate through the same pipeline under a reserved `bread..*` namespace — see [Namespaces](#namespaces). If you are new to Bread, start with the quick walkthrough below, then jump to the full dictionary when you need exact API details. +## API Stability & Versioning + +The Lua API surface, the IPC method set, the event-name vocabulary, and the runtime-state schema documented in this file are collectively **Bread Automation API v1**. This is what "locking in the schema" means operationally: + +- **Additive-only within a major version.** New bindings, new events, new state fields, and new optional IPC params may be added in a minor release. Existing binding signatures, event names, event `data` shapes, state field meanings, and IPC method contracts do not change or disappear within v1. +- **Deprecation window.** Anything slated for removal is marked `Deprecated` in this file for at least one minor release cycle and continues to function until the next major version (v2). +- **Since markers.** Additions made after the v1.0 baseline are marked inline with `*Since: vX.Y*`. Anything documented in this file without a marker is part of the v1.0 baseline. +- **Version discovery.** The current API version is returned as `api_version` in the `health` IPC response (see [Dictionary: IPC protocol](#dictionary-ipc-protocol)), so a client — the CLI, a Lua module, or a sibling `bread*` app — can assert compatibility at connect time rather than discovering a mismatch mid-session. + +This matters because the moment sibling apps and community modules depend on this vocabulary, it becomes a contract that can break people. Treat this file, not `README.md` or `CLAUDE.md`, as the single source of truth — those files intentionally point back here rather than keeping their own copies, after a duplicated Lua API section in `README.md` was found to have already drifted from reality. + ## Getting started ### 1) Create a minimal config @@ -214,6 +229,63 @@ end) #### `bread.spawn(fn)` Spawn a coroutine and surface errors if it fails. Required for using `bread.wait`. +#### `bread.wait_any(patterns, opts) -> event | nil` *(Since: v1.2)* +Coroutine-only. Like `bread.wait`, but resolves on the first of several patterns to match; returns `nil` after `opts.timeout` if none do. + +```lua +bread.spawn(function() + local event = bread.wait_any( + { "bread.monitor.connected", "bread.hyprland.event" }, + { timeout = 5000 } + ) + if event then + bread.log("a monitor-related event arrived") + end +end) +``` + +#### `bread.wait_all(patterns, opts) -> table` *(Since: v1.2)* +Coroutine-only. Resolves once every listed pattern has fired at least once, or `opts.timeout` elapses. Returns a table keyed by pattern → event; on timeout, the table additionally has `timed_out = true` and contains whichever patterns had already fired. + +### Workflows *(Since: v1.2)* + +Multi-step automations built on `bread.spawn`/`bread.wait` (and `wait_any`/`wait_all`), with status introspectable from outside the running coroutine — via Lua (`bread.workflow.status`/`.list`) or over IPC (`workflows.list`). See [Examples.md](Examples.md#example-4-multi-step-automation-workflows) for a full worked example. + +#### `bread.workflow.define(name, fn)` +Register a workflow body under `name`. `fn` receives one argument: whatever `opts.args` was passed to `.start()` (or `nil`). + +#### `bread.workflow.start(name, opts)` +Run the workflow registered as `name` (spawned as a coroutine, same mechanics as `bread.spawn`). `opts` (optional): + +| Key | Type | Description | +|-----|------|-------------| +| `deadline` | ms | If the workflow hasn't reached a terminal state by then, its status becomes `timed_out`. Independent of any per-`wait` timeout inside the body — a safety net for the whole run, not a replacement for step-level timeouts. | +| `args` | any | Passed through as the sole argument to the workflow body function. | + +Starting a workflow under a name that's already running **replaces** its registry entry — this is a live-status registry, not a run history. + +#### `bread.workflow.step(label)` +Call from inside a running workflow body to record "currently here." Purely observational — it does not affect control flow. Errors if called outside a running workflow body. + +#### `bread.workflow.status(name) -> table | nil` +Returns the current status for `name`, or `nil` if no workflow with that name has ever been started. Shape: + +```json +{ + "name": "dock-connected", + "state": "running", + "step": "waiting for monitor", + "started_at": 1710000000000, + "updated_at": 1710000001500, + "error": null +} +``` + +`state` is one of `running`, `done`, `failed`, `timed_out`. `error` is set (the captured Lua error message) only when `state` is `failed`. + +#### `bread.workflow.list() -> table` +Returns an array of every workflow's current status, in the same shape as `bread.workflow.status`. + ### State #### `bread.state.get(path)` @@ -744,6 +816,99 @@ Both USB/udev devices and Bluetooth devices emit `bread.device.connected` / `bre | `bread.notify.sent` | `{ title, message, urgency }` | | `bread.state.changed.` | emitted by state watches | +#### Terminal (shell precmd/preexec hooks) + +Requires `bread hooks install shell` and sourcing the generated script from your shell rc — see the CLI reference. Fires via the `bread-emit` helper, not the daemon reaching out. + +| Event | Data | +|-------|------| +| `bread.terminal.command.started` | `{ cmd, cwd }` | +| `bread.terminal.command.finished` | `{ cmd, cwd, exit_code, duration_ms }` | +| `bread.terminal.cwd.changed` | `{ cwd, prev_cwd }` | + +Terminal events are exempt from the daemon's event dedup window (running the same command twice in quick succession is legitimate, not noise). + +#### Git (hooks + dirty-state poller) + +`bread.git.commit.created`/`bread.git.branch.changed` come from git hooks installed via `bread hooks install git` (current repo only; never overwrites an existing hook). `bread.git.state.*`/`bread.git.ahead_behind.changed` come from an in-daemon poller over configured project roots (`[adapters.git] roots = [...]` in `breadd.toml`) and never fire for the same transition a hook already reported. + +| Event | Data | +|-------|------| +| `bread.git.commit.created` | `{ repo, sha, branch, message }` | +| `bread.git.branch.changed` | `{ repo, branch, previous_ref }` | +| `bread.git.state.dirty` | `{ repo }` | +| `bread.git.state.clean` | `{ repo }` | +| `bread.git.ahead_behind.changed` | `{ repo, ahead, behind, branch }` | + +#### Filesystem / project detection + +Scoped to configured project roots (`[adapters.filesystem] roots = [...]`), not the whole filesystem. `.git`/`node_modules` are always silent; `target`/`dist`/`build` are silent for edits but reported on new-file creation as `build_artifact.created`. + +| Event | Data | +|-------|------| +| `bread.project.detected` | `{ root, markers }` (markers: any of `.git`, `Cargo.toml`, `package.json`, `go.mod`) | +| `bread.project.file.changed` | `{ path, project_root }` | +| `bread.project.build_artifact.created` | `{ path, project_root }` | + +#### Systemd (`systemd --user` units) + +Only units named in `[adapters.systemd] units = [...]` are watched — subscribing to every user unit is noisy. + +| Event | Data | +|-------|------| +| `bread.service.started` | `{ unit }` | +| `bread.service.stopped` | `{ unit }` | +| `bread.service.failed` | `{ unit, result }` (`result` may be `null`) | + +#### Podman (containers) + +Degrades to simply not emitting if the `podman` binary isn't installed — no daemon startup dependency on it. + +| Event | Data | +|-------|------| +| `bread.container.started` | `{ id, name, image }` | +| `bread.container.stopped` | `{ id, name }` | +| `bread.container.health.changed` | `{ id, name, health }` | + +#### Remote (SSH session detection) + +Rides the same shell-hook transport as Terminal events (`bread hooks install shell`). + +| Event | Data | +|-------|------| +| `bread.remote.session.started` | `{ host }` | +| `bread.remote.session.ended` | `{ host }` | + +--- + +## Namespaces + +*Since: v1.1 — the `AdapterSource::App` variant and the known-apps registry (`bread_shared::apps::KNOWN_APPS`). No sibling app emits through this path yet as of this writing except the breadclip pilot (see its own `EVENTS.md` once that lands); the daemon-side plumbing and the convention itself are what v1.1 adds.* + +Two dotted-name segments are reserved, permanent parts of the schema — not one-off conventions: + +- **`bread..*`** — inbound events published *by* a sibling `bread*` application about its own state (e.g. `bread.clip.copied`). An app may only publish within its own segment; the daemon enforces this at the IPC boundary (a socket client claiming a `source` of an app id it doesn't own is rejected the same way spoofing `power`/`hyprland` is rejected today). +- **`bread.command..`** — outbound commands *to* a sibling application (e.g. `bread.command.clip.clear`). Any module or app may publish; only the target app subscribes. This reuses the existing event bus in both directions — there is no separate request/response protocol. +- The second dotted segment is drawn from a small known-apps registry (`bread_shared::apps::KNOWN_APPS`); daemon-internal domains (`terminal`, `git`, `hyprland`, `device`, `power`, `network`, `service`, `container`, `project`, `remote`, `system`, `profile`, `notify`, `command`, `workflow`) are reserved and cannot be claimed as app ids. +- **Commands are best-effort.** Publishing `bread.command..` with no subscriber (the app isn't installed or isn't running) is a silent no-op — there is nothing to special-case, and no error is raised. An app that acts on a command *should* emit a corresponding `bread...done` (or `.failed`) confirmation; a module that needs to know a command was actually honored must `bread.wait`/`bread.wait_any` on that confirmation with a timeout rather than assume success. There is no mandatory request/response correlation layer — most commands are legitimately fire-and-forget, and building one would contradict the "no listener, no-op" degradation property. +- **`bread.exec(" ...")`** remains the zero-infrastructure fallback for triggering a sibling app that has a synchronous CLI and no need for a structured response. + +--- + +## Integrating a bread\* app + +This is the checklist for adding a new sibling `bread*` application to the fabric — it's deliberately short, because the whole design goal of the name-based app registry (over one `AdapterSource` enum variant per app) is that this never requires a daemon change beyond step 1. **breadclip is the reference implementation** — see its own `EVENTS.md` for a worked example of every step below. + +1. **Register your app id.** Add it to `KNOWN_APPS` in `bread-shared/src/lib.rs` (a one-line, one-word-per-app list) — this is the only change to the `bread` repo itself a new integration needs. +2. **Depend on `bread-utils` with the `bread-client` feature.** In your app's daemon (the long-running piece, if you have one — a short-lived CLI tool can use `bread-emit` instead, see below), add `bread-utils = { ..., features = ["bread-client"] }` and use `bread_utils::bread_client::BreadClient`: + - `BreadClient::connect(app_id)` — cheap, cannot fail (there is no persistent connection to fail at construction time). + - `client.emit(event, data)` — publish within your own `bread..*` namespace. Each call is its own short-lived connection (fire-and-forget, like `bread-emit`) — safe to call from a short-lived per-event process invocation, not just from inside a long-running loop. + - `client.subscribe("bread.command..**", |event| { ... })` — receive commands addressed to you, on a background thread with its own reconnect/backoff loop. +3. **If you don't have a persistent daemon at all** (just a CLI tool invoked occasionally), skip `bread-client` entirely and shell out to `bread-emit` instead (see `bread-emit`'s own `--help`) — it's built for exactly that case (occasional callers that can't justify holding a socket open). +4. **Emit confirmations for commands you honor.** `bread...done` or `.failed` after acting on a `bread.command..` — optional, but it's what lets a Lua workflow `bread.wait`/`bread.wait_any` for the real outcome instead of assuming success the moment it publishes a command. +5. **Write an `EVENTS.md`** in your app's own repo cataloguing every event you publish and every command verb you honor, with `data` shapes — the per-app companion to this file. Be honest about what's *not* implemented yet rather than stubbing a verb that does nothing (see breadclip's `EVENTS.md` for how it documents `pin`/`select` as deliberately deferred, not silently dropped). +6. **Make it opt-out, not opt-in-only, and fail silent.** Your app should work exactly the same whether breadd is installed or not — connecting/emitting/subscribing must never block, error, or crash your app just because the daemon is absent. `BreadClient` is built this way already (dropped no-op on a failed `emit`, transparent reconnect on `subscribe`); if you roll your own transport instead, keep that property. + --- ## Dictionary: Runtime state schema @@ -794,11 +959,21 @@ Both USB/udev devices and Bluetooth devices emit `bread.device.connected` / `bre "builtin": true, "store": {} } + ], + "workflows": [ + { + "name": "dock-connected", + "state": "running", + "step": "waiting for monitor", + "started_at": 1710000000000, + "updated_at": 1710000001500, + "error": null + } ] } ``` -`status` values: `loaded`, `load_error`, `not_found`, `degraded`, `disabled`. +`modules[].status` values: `loaded`, `load_error`, `not_found`, `degraded`, `disabled`. `workflows[].state` values: `running`, `done`, `failed`, `timed_out` *(Since: v1.2 — see [Workflows](#workflows-since-v12))*. --- @@ -823,7 +998,7 @@ Available methods: | Method | Params | Description | |--------|--------|-------------| | `ping` | — | Connectivity check | -| `health` | — | Version, uptime, PID, adapter status | +| `health` | — | Version, uptime, PID, adapter status, `api_version` | | `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 | @@ -832,4 +1007,7 @@ Available methods: | `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 | +| `emit` | `event`, `data`, optional `source`, `kind` | Inject an event. Without `source`, builds a `BreadEvent` directly tagged `System` (legacy path). With `source` set to `terminal`/`git`/`remote`, or a registered sibling-app id (see [Namespaces](#namespaces)), builds a real `RawEvent` (requires `kind` too) that goes through the normalizer like any adapter. Any other `source` value is rejected — this is the anti-spoofing boundary that stops a socket client from forging e.g. `power`/`hyprland` events. | +| `workflows.list` | — | List running/completed workflow instances and their step/status *(Since: v1.2)* | + +The `health` response's `api_version` field lets a client — the CLI, a Lua module via `bread.exec`, or a `bread-client`-linked sibling app — assert compatibility with this document's versioned schema at connect time (see [API Stability & Versioning](#api-stability--versioning)). diff --git a/Examples.md b/Examples.md index 77b9eb1..01da058 100644 --- a/Examples.md +++ b/Examples.md @@ -179,6 +179,65 @@ end return M ``` +## Example 4: Multi-step automation workflows + +The examples above are all single-event reactions: one trigger, one handler. `bread.wait`/`bread.spawn` (coroutine-based waits) and the `bread.workflow` table build on those to let a module walk through several ordered steps — with a timeout on any wait, and an overall deadline for the whole thing — rather than each step having to re-derive "am I still in the middle of handling the last event." + +Full source: `examples/modules/dock-workflow.lua`. + +```lua +-- ~/.config/bread/modules/dock-workflow.lua +local M = bread.module({ name = "dock-workflow", version = "1.0.0" }) + +bread.workflow.define("dock-connected", function() + bread.workflow.step("applying layout") + bread.hyprland.keyword("monitor", "HDMI-A-1, preferred, 1920x0, 1") + + bread.workflow.step("waiting for monitor") + -- wait_any resolves on whichever of these patterns fires first, or + -- returns nil after the timeout — it does not block forever. + local event = bread.wait_any( + { "bread.monitor.connected", "bread.hyprland.event" }, + { timeout = 5000 } + ) + if not event then + error("monitor did not appear in time") + end + + bread.workflow.step("activating profile") + bread.profile.activate("docked") + + bread.workflow.step("waiting for workspace") + bread.wait("bread.workspace.changed", { timeout = 3000 }) + + bread.workflow.step("notifying") + bread.notify("Dock connected", { title = "bread" }) +end) + +function M.on_load() + bread.on("bread.device.dock.connected", function() + -- The deadline covers the whole run, independent of each step's + -- own wait timeout — a safety net against something hanging. + bread.workflow.start("dock-connected", { deadline = 15000 }) + end) +end + +return M +``` + +Walking through what each piece buys you over a plain `bread.on` handler: + +- **`bread.workflow.define(name, fn)` / `.start(name, opts)`** — registers the body under a name, then runs it as a `bread.spawn`ed coroutine. `opts.deadline` (ms) marks the run `timed_out` in the registry if it hasn't reached a terminal state in time — this is independent of, and layered on top of, any per-step `timeout` inside the body. `opts.args` is passed through as the single argument to the body function, if you need to parameterize a run. +- **`bread.workflow.step(label)`** — call it from inside the running body to record "currently here." It doesn't change control flow at all; it exists purely so `bread.workflow.status("dock-connected")` (or the CLI/dashboard) can answer "is this stuck, and where?" instead of a black box between start and finish. +- **`bread.wait_any(patterns, opts)`** — like `bread.wait`, but resolves on the first of several patterns (useful when you're not sure which specific event a compositor/adapter will actually emit for a given transition — see the two candidate patterns above). `bread.wait_all(patterns, opts)` is the complementary primitive: it resolves once every listed pattern has fired at least once (or the timeout elapses), returning a table keyed by pattern. +- **Errors are captured, not lost.** If the body raises (as it does above when the monitor never appears), the workflow's registry entry moves to `failed` with the message attached — visible via `bread.workflow.status(name)` or the `workflows.list` IPC method — rather than only ever showing up as a one-line log the moment it happened. + +Check on a running (or finished) workflow via the IPC method directly (there's no dedicated `bread` subcommand for this yet — see `workflows.list` in the [IPC protocol dictionary](Documentation.md#dictionary-ipc-protocol)): + +```bash +echo '{"id":"1","method":"workflows.list","params":{}}' | nc -U -q0 "$XDG_RUNTIME_DIR/bread/breadd.sock" +``` + ## Tips for porting your own scripts - Start by logging the event payload: `bread.log(event.data.raw)` diff --git a/README.md b/README.md index e3fbf08..0cab0db 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Optional but preferred: ### From source ```bash -git clone https://github.com/Breadway/bread.git +git clone https://git.breadway.dev/Breadway/bread.git cd bread ``` @@ -256,295 +256,15 @@ return M --- -## Event reference +## Event reference, Lua API, and IPC protocol -Events follow the namespace convention `bread...`. +These are fully documented in [`Documentation.md`](Documentation.md) — the single canonical reference (event catalogue, per-function Lua API, runtime-state schema, and IPC protocol), versioned as **Bread Automation API v1**. This README no longer keeps a parallel copy, to avoid the two drifting apart. -| Event | Trigger | -|-------|---------| -| `bread.system.startup` | Daemon fully initialized | -| `bread.device.connected` | Any device attached | -| `bread.device.disconnected` | Any device removed | -| `bread.device..connected` | Named device attached (name from `devices.lua`) | -| `bread.device..disconnected` | Named device removed | -| `bread.monitor.connected` | Display connected | -| `bread.monitor.disconnected` | Display disconnected | -| `bread.workspace.changed` | Active workspace changed | -| `bread.window.focus.changed` | Focused window changed | -| `bread.window.opened` | Window opened | -| `bread.window.closed` | Window closed | -| `bread.power.ac.connected` | AC adapter plugged in | -| `bread.power.ac.disconnected` | AC adapter unplugged | -| `bread.power.battery.low` | Battery ≤ 20% | -| `bread.power.battery.very_low` | Battery ≤ 10% | -| `bread.power.battery.critical` | Battery ≤ 5% | -| `bread.power.battery.full` | Battery at 100% | -| `bread.network.connected` | Network interface came online | -| `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.notify.sent` | Desktop notification dispatched | - ---- - -## 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 - -```lua --- Subscribe to events; returns a subscription ID -local id = bread.on("bread.monitor.connected", function(event) - -- event.event → "bread.monitor.connected" - -- event.data → table of event-specific fields - -- event.source → adapter that produced it - bread.log(event.event) -end) - --- Unsubscribe by ID -bread.off(id) - --- Subscribe once, auto-unsubscribe after first delivery -bread.once("bread.system.startup", function(event) - bread.profile.activate("default") -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) -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 - -```lua --- Read from runtime state by dot-separated path -local monitors = bread.state.get("monitors") -local online = bread.state.get("network.online") - --- 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 - -```lua -bread.profile.activate("desk") -bread.profile.activate("default") -``` - -### Execution and notifications - -```lua --- Fire-and-forget shell command -bread.exec("kitty") - --- Desktop notification (uses notify-send) --- First arg is the message body; opts.title sets the notification title (default: "bread") -bread.notify("Something happened", { title = "My Module", urgency = "normal", timeout = 3000, icon = "dialog-info" }) -bread.notify("AC connected") -- 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 (system 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" -``` - ---- - -## IPC protocol - -The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. The protocol is newline-delimited JSON — useful for scripting or building tooling outside the CLI. - -Request: -```json -{ "id": "1", "method": "state.get", "params": { "key": "monitors" } } -``` - -Response: -```json -{ "id": "1", "result": [ { "name": "HDMI-A-1", "connected": true } ] } -``` - -Available methods: - -| 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 | - -`events.subscribe` upgrades the connection to streaming mode — the daemon pushes events line by line until the client disconnects. +- [Dictionary: Event reference](Documentation.md#dictionary-event-reference) +- [Dictionary: Lua API](Documentation.md#dictionary-lua-api) +- [Dictionary: Runtime state schema](Documentation.md#dictionary-runtime-state-schema) +- [Dictionary: IPC protocol](Documentation.md#dictionary-ipc-protocol) +- [API Stability & Versioning](Documentation.md#api-stability--versioning) --- diff --git a/bread-cli/src/hooks_git.rs b/bread-cli/src/hooks_git.rs new file mode 100644 index 0000000..48874e0 --- /dev/null +++ b/bread-cli/src/hooks_git.rs @@ -0,0 +1,357 @@ +//! `bread hooks install git` — installs small, non-blocking git hooks that +//! emit normalized events (via the `bread-emit` fire-and-forget binary) on +//! commit and branch-change activity. +//! +//! Design constraints this module is built around: +//! +//! - Never touch a hook file bread doesn't own. Frameworks like Husky or the +//! `pre-commit` tool, or a developer's own scripts, commonly already +//! occupy `post-commit` / `post-checkout` / `post-merge`. We only ever +//! overwrite a hook file if it already carries our marker comment (meaning +//! we wrote it on a previous install); otherwise we skip it and tell the +//! user exactly what to add by hand. +//! - Never make git itself slower or block a commit/checkout/merge because +//! breadd is slow or down. The installed scripts background `bread-emit` +//! and unconditionally exit 0. +//! - Respect `core.hooksPath`. If the user has repointed hooks elsewhere, we +//! do not silently write into `.git/hooks` where nothing will ever run +//! them — see [`install_git`] for the exact behavior. + +use anyhow::{bail, Context, Result}; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Distinctive marker comment written as the second line of every hook +/// script bread installs. Its presence is how we tell "a hook we installed +/// previously, safe to overwrite" apart from "someone else's hook, hands +/// off." Keep this stable across versions — changing it would make bread +/// think its own previously-installed hooks belong to someone else. +pub const MARKER: &str = "# bread-managed-hook"; + +/// The three git hooks bread installs, in a stable order for display. +const HOOK_NAMES: [&str; 3] = ["post-commit", "post-checkout", "post-merge"]; + +/// Outcome of attempting to install a single hook file. +#[derive(Debug, PartialEq, Eq)] +enum HookOutcome { + Installed, + Skipped, +} + +/// Install bread's git hooks (`post-commit`, `post-checkout`, `post-merge`) +/// into the current working directory's git repository. +/// +/// This only ever touches the repo rooted at the current directory (via +/// `git rev-parse`, which correctly follows worktrees/submodules to the +/// real git dir) — never a global `core.hooksPath`, never other repos. +pub fn install_git() -> Result<()> { + let git_dir = git_dir()?; + let toplevel = show_toplevel()?; + + if let Some(configured) = hooks_path_override()? { + print_hooks_path_warning(&configured); + bail!( + "bread: refusing to install into '{}/hooks' while core.hooksPath is set to '{}'", + git_dir.display(), + configured + ); + } + + let hooks_dir = git_dir.join("hooks"); + fs::create_dir_all(&hooks_dir) + .with_context(|| format!("failed to create {}", hooks_dir.display()))?; + + let mut installed = Vec::new(); + let mut skipped = Vec::new(); + + for &name in HOOK_NAMES.iter() { + let path = hooks_dir.join(name); + let script = hook_script(name); + match install_one_hook(&path, &script)? { + HookOutcome::Installed => installed.push(path), + HookOutcome::Skipped => skipped.push(path), + } + } + + print_summary(&toplevel, &installed, &skipped); + Ok(()) +} + +/// Install (or skip) a single hook file at `path` with contents `script`. +/// +/// Never overwrites an existing file unless it already carries our marker. +fn install_one_hook(path: &Path, script: &str) -> Result { + if path.exists() { + let existing = fs::read_to_string(path) + .with_context(|| format!("failed to read existing hook {}", path.display()))?; + if !is_bread_managed(&existing) { + eprintln!( + "bread: '{}' already exists and was not installed by bread — leaving it \ + untouched.\n To also emit bread events from it, add this line to the end \ + of the existing script:\n\n {}\n", + path.display(), + emit_line_for(path.file_name().and_then(|n| n.to_str()).unwrap_or("")) + ); + return Ok(HookOutcome::Skipped); + } + // It's ours from a previous install — safe to overwrite. + } + + fs::write(path, script).with_context(|| format!("failed to write hook {}", path.display()))?; + let mut perms = fs::metadata(path) + .with_context(|| format!("failed to stat {}", path.display()))? + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms) + .with_context(|| format!("failed to set permissions on {}", path.display()))?; + + Ok(HookOutcome::Installed) +} + +/// Whether `contents` was written by a previous bread install (contains the +/// marker comment anywhere in the file). +fn is_bread_managed(contents: &str) -> bool { + contents.lines().any(|line| line.trim() == MARKER) +} + +/// The bare `bread-emit` invocation for a branch checkout, with no guard — +/// callers that already run inside a `[ "$3" = "1" ]` check (our own +/// generated hook script) use this directly. +fn branch_changed_emit_line() -> String { + "bread-emit bread.git.branch.changed --source git --kind branch.changed --data \ + \"{\\\"repo\\\":\\\"$(git rev-parse --show-toplevel)\\\",\\\"branch\\\":\\\"$(git rev-parse --abbrev-ref HEAD)\\\",\\\"previous_ref\\\":\\\"$1\\\"}\" >/dev/null 2>&1 &" + .to_string() +} + +/// The single `bread-emit` invocation line appropriate for hook `name`, +/// suggested to users who already have their own script at that hook (so it +/// must stand alone, including its own guard where relevant). +fn emit_line_for(name: &str) -> String { + match name { + "post-checkout" => format!("[ \"$3\" = \"1\" ] && {}", branch_changed_emit_line()), + _ => commit_created_emit_line(), + } +} + +/// The `bread-emit` invocation shared by `post-commit` and `post-merge` +/// (both are "HEAD moved to a new commit" signals). +fn commit_created_emit_line() -> String { + "bread-emit bread.git.commit.created --source git --kind commit.created --data \ + \"{\\\"repo\\\":\\\"$(git rev-parse --show-toplevel)\\\",\\\"sha\\\":\\\"$(git rev-parse HEAD)\\\",\\\"branch\\\":\\\"$(git rev-parse --abbrev-ref HEAD)\\\",\\\"message\\\":\\\"$(git log -1 --pretty=%s | sed 's/\"/\\\\\\\\\"/g')\\\"}\" >/dev/null 2>&1 &" + .to_string() +} + +/// Build the full contents of the hook script for hook `name`. +/// +/// Every script: starts with the marker (so future installs recognize it as +/// ours), backgrounds the `bread-emit` call so a slow/down daemon can never +/// delay the git operation, and unconditionally exits 0 so bread being +/// unavailable can never fail a `git commit`/`checkout`/`merge` for the user. +fn hook_script(name: &str) -> String { + match name { + "post-commit" => format!( + "#!/bin/sh\n{marker}\n# Emits bread.git.commit.created on every commit. Backgrounded and\n\ + # always exits 0 so bread can never slow down or block `git commit`.\n\ + {emit}\nexit 0\n", + marker = MARKER, + emit = commit_created_emit_line(), + ), + "post-checkout" => format!( + "#!/bin/sh\n{marker}\n# git passes: $1=previous HEAD, $2=new HEAD, $3=1 if a branch\n\ + # checkout (0 for a plain file checkout). Only emit on real branch\n\ + # switches. previous_branch is not resolvable from a ref alone here,\n\ + # so we report the previous HEAD's raw SHA ($1) as previous_ref instead\n\ + # of a branch name.\n\ + if [ \"$3\" = \"1\" ]; then\n {emit}\nfi\nexit 0\n", + marker = MARKER, + emit = branch_changed_emit_line(), + ), + "post-merge" => format!( + "#!/bin/sh\n{marker}\n# A merge moves HEAD to a new commit, same as post-commit; emit the\n\ + # same bread.git.commit.created shape so a merge (fast-forward or not)\n\ + # also surfaces as a commit-created event. Backgrounded and always\n\ + # exits 0 so bread can never slow down or block `git merge`.\n\ + {emit}\nexit 0\n", + marker = MARKER, + emit = commit_created_emit_line(), + ), + other => unreachable!("unknown hook name: {other}"), + } +} + +/// `git rev-parse --git-dir`, resolved to an absolute path. This is the +/// correct git directory even inside worktrees or submodules (unlike +/// hardcoding `.git`). +fn git_dir() -> Result { + let out = run_git(&["rev-parse", "--git-dir"]).context( + "bread: this does not look like a git repository. Run 'bread hooks install git' from \ + inside a git work tree.", + )?; + let raw = PathBuf::from(out); + if raw.is_absolute() { + Ok(raw) + } else { + // `--git-dir` is often relative to CWD (e.g. ".git"); resolve it. + std::env::current_dir() + .map(|cwd| cwd.join(raw)) + .context("failed to resolve current directory") + } +} + +/// `git rev-parse --show-toplevel` — the repo root, used only for display. +fn show_toplevel() -> Result { + run_git(&["rev-parse", "--show-toplevel"]) + .map(PathBuf::from) + .context( + "bread: this does not look like a git repository. Run 'bread hooks install git' \ + from inside a git work tree.", + ) +} + +/// `git config --get core.hooksPath`, if set to something non-default. +/// Returns `Ok(None)` when unset (the common case). +fn hooks_path_override() -> Result> { + let output = Command::new("git") + .args(["config", "--get", "core.hooksPath"]) + .output() + .context("failed to run 'git config --get core.hooksPath' (is git installed?)")?; + + if !output.status.success() { + // Exit code 1 from `git config --get` means "key not set" — that's + // the normal, expected case, not an error. + return Ok(None); + } + + let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if value.is_empty() { + Ok(None) + } else { + Ok(Some(value)) + } +} + +fn print_hooks_path_warning(configured: &str) { + eprintln!( + "bread: this repo has 'core.hooksPath' set to '{configured}', so hooks placed in \ + '.git/hooks' will never run.\n\ + bread will not silently write into '.git/hooks' where they'd be dead code, and it \ + will not write into your configured hooksPath without being asked to.\n\n\ + To proceed, either:\n\ + \x20 - point core.hooksPath back at the default: git config --unset core.hooksPath\n\ + \x20 (or set it explicitly to .git/hooks), then re-run this command; or\n\ + \x20 - install the three hook scripts into '{configured}' yourself (see \ + `bread hooks install git --help` for the exact script contents this command \ + would otherwise write).\n" + ); +} + +fn print_summary(toplevel: &Path, installed: &[PathBuf], skipped: &[PathBuf]) { + println!("bread: git hooks for {}", toplevel.display()); + if installed.is_empty() { + println!(" installed: (none)"); + } else { + println!(" installed:"); + for path in installed { + println!(" {}", path.display()); + } + } + if !skipped.is_empty() { + println!(" skipped (already exist, not bread-managed):"); + for path in skipped { + println!(" {}", path.display()); + } + } +} + +/// Run `git ` in the current directory and return trimmed stdout. +/// Fails if git is missing or the command exits non-zero. +fn run_git(args: &[&str]) -> Result { + let output = Command::new("git") + .args(args) + .output() + .with_context(|| format!("failed to run 'git {}' (is git installed?)", args.join(" ")))?; + + if !output.status.success() { + bail!("'git {}' failed", args.join(" ")); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_own_marker() { + let script = format!("#!/bin/sh\n{MARKER}\necho hi\n"); + assert!(is_bread_managed(&script)); + } + + #[test] + fn does_not_falsely_detect_marker() { + let script = "#!/bin/sh\n# some other tool's hook\necho hi\n"; + assert!(!is_bread_managed(script)); + } + + #[test] + fn marker_must_match_whole_trimmed_line() { + // A substring match would be a false positive risk; require the + // trimmed line to equal the marker exactly. + let script = "#!/bin/sh\n# this mentions bread-managed-hook in passing\n"; + assert!(!is_bread_managed(script)); + } + + #[test] + fn empty_file_is_not_managed() { + assert!(!is_bread_managed("")); + } + + #[test] + fn all_hook_scripts_start_with_shebang_and_marker() { + for name in HOOK_NAMES { + let script = hook_script(name); + let mut lines = script.lines(); + assert_eq!(lines.next(), Some("#!/bin/sh")); + assert_eq!(lines.next(), Some(MARKER)); + } + } + + #[test] + fn all_hook_scripts_exit_0_unconditionally() { + for name in HOOK_NAMES { + let script = hook_script(name); + assert!( + script.trim_end().ends_with("exit 0"), + "hook {name} does not unconditionally exit 0" + ); + } + } + + #[test] + fn post_commit_and_post_merge_emit_commit_created() { + for name in ["post-commit", "post-merge"] { + let script = hook_script(name); + assert!(script.contains("bread.git.commit.created")); + assert!(script.contains("--kind commit.created")); + // Must be backgrounded so a slow/down daemon can't block git. + assert!(script.contains("&\nexit 0") || script.contains(" &\n")); + } + } + + #[test] + fn post_checkout_only_emits_on_branch_checkout() { + let script = hook_script("post-checkout"); + assert!(script.contains("bread.git.branch.changed")); + assert!(script.contains("--kind branch.changed")); + assert!(script.contains("\"$3\" = \"1\"")); + } + + #[test] + fn hook_script_rejects_unknown_name() { + let result = std::panic::catch_unwind(|| hook_script("pre-push")); + assert!(result.is_err()); + } +} diff --git a/bread-cli/src/hooks_shell.rs b/bread-cli/src/hooks_shell.rs new file mode 100644 index 0000000..63b692a --- /dev/null +++ b/bread-cli/src/hooks_shell.rs @@ -0,0 +1,512 @@ +//! `bread hooks install shell` — generates a shell hook script that reports +//! terminal telemetry (command start/finish, cwd changes, remote-session +//! start/end) to the bread daemon. +//! +//! # Why this exists as a generated file, not an rc-file edit +//! +//! This module deliberately **never touches the user's `.bashrc`, `.zshrc`, +//! or fish `config.fish`.** It only writes self-contained hook script(s) +//! under `~/.config/bread/hooks/` and prints the one-line `source` snippet +//! the user needs to add themselves. Editing a user's shell startup file +//! automatically is the kind of silent, hard-to-audit change that belongs +//! to the user, not to a CLI subcommand — if `bread` got it wrong, or the +//! user later doesn't want it, an rc-file edit is much harder to notice and +//! undo than a printed snippet they chose to paste in. +//! +//! # Why bread-emit, not `bread emit` +//! +//! The generated hooks shell out to the separate `bread-emit` binary +//! (`bread-emit/src/main.rs`), not `bread emit`. `bread-emit` skips clap +//! parsing and the Tokio runtime entirely and never waits for a reply — it +//! is cheap enough to call on every single shell prompt. The full `bread` +//! CLI spins up an async runtime per invocation and would be perceptible +//! latency if called twice per prompt. +//! +//! # Why both a `.sh` and a `.fish` file are always written +//! +//! `install_shell` always regenerates both `shell-hook.sh` (bash + zsh, a +//! single file with an `if [ -n "$ZSH_VERSION" ]; ... elif [ -n +//! "$BASH_VERSION" ]; ...` branch) and `shell-hook.fish` (fish has +//! meaningfully different hook primitives and cannot source a POSIX-ish +//! script anyway), regardless of which shell was detected or requested. +//! This keeps both files present and up to date at fixed, predictable +//! paths so a user who switches shells later doesn't need to re-run +//! install — they just add the one extra `source` line to the new shell's +//! rc file. Only the *printed* snippet is specific to the detected/forced +//! shell. +//! +//! Existing hook files at these paths are always overwritten. Unlike git +//! hooks, there is no ecosystem of third-party `shell-hook.sh` files a user +//! might have installed by other means — this file is entirely owned and +//! generated by this command, so overwriting on every run is safe and is +//! in fact required to pick up script changes across `bread` upgrades. + +use anyhow::{bail, Result}; +use std::fs; +use std::path::PathBuf; + +/// Which shell to print the `source` snippet for. Detected from `$SHELL` +/// unless the caller forces one via `bread hooks install shell --shell +/// `. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TargetShell { + Bash, + Zsh, + Fish, + /// Detection failed (unrecognized or unset `$SHELL`); both snippets are + /// printed and the user picks the one that matches their shell. + Unknown, +} + +impl TargetShell { + fn parse(name: &str) -> Result { + match name { + "bash" => Ok(TargetShell::Bash), + "zsh" => Ok(TargetShell::Zsh), + "fish" => Ok(TargetShell::Fish), + other => bail!( + "bread: unrecognized shell '{}' (expected one of: bash, zsh, fish)", + other + ), + } + } + + /// Detect the caller's shell from `$SHELL`'s basename. This is a best + /// effort: `$SHELL` reflects the user's login shell, which is normally + /// also the shell running the CLI, but that's not guaranteed (e.g. + /// invoked from inside a script run under a different interpreter). + fn detect_from_env() -> TargetShell { + let Ok(shell_path) = std::env::var("SHELL") else { + return TargetShell::Unknown; + }; + let name = PathBuf::from(shell_path) + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(); + match name.as_str() { + "bash" => TargetShell::Bash, + "zsh" => TargetShell::Zsh, + "fish" => TargetShell::Fish, + _ => TargetShell::Unknown, + } + } +} + +/// Returns the default hooks directory: `~/.config/bread/hooks`. +pub fn hooks_dir() -> PathBuf { + if let Some(cfg) = dirs::config_dir() { + return cfg.join("bread").join("hooks"); + } + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home) + .join(".config") + .join("bread") + .join("hooks"); + } + PathBuf::from(".config/bread/hooks") +} + +/// Install (or regenerate) the shell hook scripts and print the snippet the +/// user needs to add to their own rc file. `shell` optionally forces +/// bash/zsh/fish; otherwise the target shell is auto-detected from `$SHELL` +/// purely to decide which snippet to print — both hook files are written +/// either way (see module docs). +pub fn install_shell(shell: Option) -> Result<()> { + let target = match shell { + Some(name) => TargetShell::parse(&name)?, + None => TargetShell::detect_from_env(), + }; + + let dir = hooks_dir(); + fs::create_dir_all(&dir) + .map_err(|e| anyhow::anyhow!("failed to create {}: {}", dir.display(), e))?; + + let sh_path = dir.join("shell-hook.sh"); + write_hook_file(&sh_path, BASH_ZSH_HOOK_SCRIPT)?; + + let fish_path = dir.join("shell-hook.fish"); + write_hook_file(&fish_path, FISH_HOOK_SCRIPT)?; + + println!(); + match target { + TargetShell::Bash | TargetShell::Zsh => { + println!("Add this line to your ~/.bashrc or ~/.zshrc:"); + println!(); + println!(" source {}", sh_path.display()); + } + TargetShell::Fish => { + println!("Add this line to your ~/.config/fish/config.fish:"); + println!(); + println!(" source {}", fish_path.display()); + } + TargetShell::Unknown => { + println!( + "bread: could not detect your shell from $SHELL; add whichever \ + of these matches the shell you use interactively:" + ); + println!(); + println!(" bash/zsh — add to ~/.bashrc or ~/.zshrc:"); + println!(" source {}", sh_path.display()); + println!(); + println!(" fish — add to ~/.config/fish/config.fish:"); + println!(" source {}", fish_path.display()); + } + } + println!(); + println!( + "bread never edits rc files on its own — that line above is something \ + only you should add." + ); + + Ok(()) +} + +fn write_hook_file(path: &PathBuf, contents: &str) -> Result<()> { + let existed = path.exists(); + fs::write(path, contents) + .map_err(|e| anyhow::anyhow!("failed to write {}: {}", path.display(), e))?; + if existed { + println!("bread: regenerated {}", path.display()); + } else { + println!("bread: wrote {}", path.display()); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Hook script bodies +// --------------------------------------------------------------------------- +// +// Both scripts below shell out to `bread-emit` (not `bread emit`) for every +// event, always backgrounded and with output discarded (`>/dev/null 2>&1 +// &`), so a down, slow, or stalled daemon can never block the interactive +// shell waiting for the next prompt. +// +// Event/kind pairs emitted (kept in sync with the daemon's normalizer): +// bread.terminal.command.started (kind command.started) +// bread.terminal.command.finished (kind command.finished) +// bread.terminal.cwd.changed (kind cwd.changed) +// bread.remote.session.started (kind session.started) +// bread.remote.session.ended (kind session.ended) +// +// `bread-emit`'s positional argument is technically redundant once +// --source/--kind are both given (the daemon derives the real event name +// from source+kind and ignores the literal positional string in that +// mode), but a sensible name is still passed since bread-emit requires a +// positional argument. + +/// Combined bash + zsh hook script written to `~/.config/bread/hooks/shell-hook.sh`. +/// +/// zsh has native `preexec`/`precmd`/`chpwd` hooks; bash has none of the +/// three, so its branch approximates them with a `trap ... DEBUG` (guarded +/// so it only fires once per prompt, not once per pipeline stage) and a +/// `PROMPT_COMMAND` that both plays the precmd role and polls `$PWD` against +/// a remembered previous value to emulate `chpwd`. +const BASH_ZSH_HOOK_SCRIPT: &str = r#"# bread shell hook — generated by `bread hooks install shell`. +# Do not hand-edit; rerun `bread hooks install shell` to regenerate. +# +# Reports terminal telemetry (command start/finish, cwd changes, remote +# session start/end) to the bread daemon via the lightweight `bread-emit` +# binary. Every call below is backgrounded and has its output discarded so a +# down or slow daemon can never delay the shell prompt. +# +# Assumes GNU date (for `date +%s%3N`, millisecond epoch) and GNU sed (for +# the JSON-escaping helper below) — both are standard on Arch Linux. + +# Escape a string for embedding inside a JSON string literal: backslashes, +# double quotes, tabs, carriage returns, and embedded newlines. Backslashes +# must be escaped first, before any escape sequence that introduces new +# backslashes, or the newly-added backslashes would themselves get doubled. +_bread_json_escape() { + printf '%s' "$1" \ + | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\t/\\t/g' -e 's/\r/\\r/g' \ + | sed ':a;N;$!ba;s/\n/\\n/g' +} + +# --- Remote session detection (runs once at shell-init time) -------------- +# Only the outermost login shell announces: BREAD_SSH_ANNOUNCED is exported +# so nested subshells (and the EXIT trap that fires only in this branch) +# don't each fire their own started/ended pair. +if { [ -n "$SSH_TTY" ] || [ -n "$SSH_CONNECTION" ]; } && [ -z "$BREAD_SSH_ANNOUNCED" ]; then + export BREAD_SSH_ANNOUNCED=1 + # SSH_CONNECTION is "client_ip client_port server_ip server_port"; fall + # back to SSH_CLIENT (same leading field) if it's unset for some reason. + _bread_ssh_host=$(printf '%s' "${SSH_CONNECTION:-$SSH_CLIENT}" | awk '{print $1}') + + _bread_esc_host=$(_bread_json_escape "$_bread_ssh_host") + bread-emit bread.remote.session.started --source remote --kind session.started \ + --data "{\"host\":\"$_bread_esc_host\"}" >/dev/null 2>&1 & + + _bread_ssh_exit_hook() { + _bread_esc_host_exit=$(_bread_json_escape "$_bread_ssh_host") + bread-emit bread.remote.session.ended --source remote --kind session.ended \ + --data "{\"host\":\"$_bread_esc_host_exit\"}" >/dev/null 2>&1 & + } + trap _bread_ssh_exit_hook EXIT +fi + +if [ -n "$ZSH_VERSION" ]; then + # --- zsh: preexec / precmd / chpwd are native --------------------------- + autoload -Uz add-zsh-hook + + _bread_preexec() { + _bread_cmd_start=$(date +%s%3N) + _bread_cmd_cwd="$PWD" + _bread_cmd_str="$1" + local esc_cmd esc_cwd + esc_cmd=$(_bread_json_escape "$1") + esc_cwd=$(_bread_json_escape "$PWD") + bread-emit bread.terminal.command.started --source terminal --kind command.started \ + --data "{\"cmd\":\"$esc_cmd\",\"cwd\":\"$esc_cwd\"}" >/dev/null 2>&1 & + } + add-zsh-hook preexec _bread_preexec + + _bread_precmd() { + local exit_code=$? + if [ -n "$_bread_cmd_str" ]; then + local now duration_ms esc_cmd esc_cwd + now=$(date +%s%3N) + duration_ms=$(( now - _bread_cmd_start )) + esc_cmd=$(_bread_json_escape "$_bread_cmd_str") + esc_cwd=$(_bread_json_escape "$_bread_cmd_cwd") + bread-emit bread.terminal.command.finished --source terminal --kind command.finished \ + --data "{\"cmd\":\"$esc_cmd\",\"cwd\":\"$esc_cwd\",\"exit_code\":$exit_code,\"duration_ms\":$duration_ms}" >/dev/null 2>&1 & + _bread_cmd_str="" + fi + } + add-zsh-hook precmd _bread_precmd + + _bread_chpwd() { + local esc_cwd esc_prev + esc_cwd=$(_bread_json_escape "$PWD") + esc_prev=$(_bread_json_escape "${OLDPWD:-$PWD}") + bread-emit bread.terminal.cwd.changed --source terminal --kind cwd.changed \ + --data "{\"cwd\":\"$esc_cwd\",\"prev_cwd\":\"$esc_prev\"}" >/dev/null 2>&1 & + } + add-zsh-hook chpwd _bread_chpwd + +elif [ -n "$BASH_VERSION" ]; then + # --- bash: no native preexec/precmd/chpwd; approximate them ------------- + # trap ... DEBUG fires before every simple command. `_bread_preexec_guard` + # ensures only the first simple command between prompts is recorded, not + # every stage of a pipeline or every `;`-separated command on one line. + # This relies on bash's default of NOT propagating the DEBUG trap into + # function calls (i.e. `set -o functrace`/`shopt -s extdebug` are off); + # if something in the user's environment turns those on, the guard below + # could fire more than once per prompt. + _bread_preexec() { + [ -n "$_bread_preexec_guard" ] && return + case "$BASH_COMMAND" in + _bread_precmd*|"$PROMPT_COMMAND") return ;; + esac + _bread_preexec_guard=1 + _bread_cmd_start=$(date +%s%3N) + _bread_cmd_cwd="$PWD" + _bread_cmd_str="$BASH_COMMAND" + local esc_cmd esc_cwd + esc_cmd=$(_bread_json_escape "$BASH_COMMAND") + esc_cwd=$(_bread_json_escape "$PWD") + bread-emit bread.terminal.command.started --source terminal --kind command.started \ + --data "{\"cmd\":\"$esc_cmd\",\"cwd\":\"$esc_cwd\"}" >/dev/null 2>&1 & + } + trap '_bread_preexec' DEBUG + + _bread_precmd() { + local exit_code=$? + if [ -n "$_bread_preexec_guard" ]; then + local now duration_ms esc_cmd esc_cwd + now=$(date +%s%3N) + duration_ms=$(( now - _bread_cmd_start )) + esc_cmd=$(_bread_json_escape "$_bread_cmd_str") + esc_cwd=$(_bread_json_escape "$_bread_cmd_cwd") + bread-emit bread.terminal.command.finished --source terminal --kind command.finished \ + --data "{\"cmd\":\"$esc_cmd\",\"cwd\":\"$esc_cwd\",\"exit_code\":$exit_code,\"duration_ms\":$duration_ms}" >/dev/null 2>&1 & + fi + _bread_preexec_guard="" + + # chpwd emulation: bash has no native hook, so compare $PWD against a + # remembered previous value on every prompt. + if [ "$PWD" != "${_bread_prev_pwd:-$PWD}" ]; then + local esc_cwd2 esc_prev + esc_cwd2=$(_bread_json_escape "$PWD") + esc_prev=$(_bread_json_escape "${_bread_prev_pwd:-$PWD}") + bread-emit bread.terminal.cwd.changed --source terminal --kind cwd.changed \ + --data "{\"cwd\":\"$esc_cwd2\",\"prev_cwd\":\"$esc_prev\"}" >/dev/null 2>&1 & + _bread_prev_pwd="$PWD" + fi + } + PROMPT_COMMAND="_bread_precmd${PROMPT_COMMAND:+; $PROMPT_COMMAND}" + _bread_prev_pwd="$PWD" +fi +"#; + +/// Fish hook script written to `~/.config/bread/hooks/shell-hook.fish`. +/// +/// Fish's job-control primitives are meaningfully nicer than bash/zsh here: +/// `fish_preexec`/`fish_postexec` are real events (no DEBUG-trap games), +/// `$status` in the postexec handler gives the exit code directly, and +/// `$CMD_DURATION` is already a builtin millisecond duration — no manual +/// timestamp math needed. `string escape --style=json` (fish >= 3.3) does +/// the JSON-string-literal escaping, quotes included, in one call. +const FISH_HOOK_SCRIPT: &str = r#"# bread shell hook (fish) — generated by `bread hooks install shell`. +# Do not hand-edit; rerun `bread hooks install shell` to regenerate. +# +# Reports terminal telemetry (command start/finish, cwd changes, remote +# session start/end) to the bread daemon via the lightweight `bread-emit` +# binary. Every call below is backgrounded and has its output discarded so a +# down or slow daemon can never delay the shell prompt. +# +# Requires fish >= 3.3 for `string escape --style=json`. + +function _bread_json_escape --description 'Escape a value as a JSON string literal (surrounding quotes included)' + string escape --style=json -- $argv[1] +end + +function _bread_preexec --on-event fish_preexec --description 'bread: emit command.started' + set -g _bread_cmd_cwd $PWD + set -l cmd_json (_bread_json_escape $argv[1]) + set -l cwd_json (_bread_json_escape $PWD) + bread-emit bread.terminal.command.started --source terminal --kind command.started \ + --data "{\"cmd\": $cmd_json, \"cwd\": $cwd_json}" >/dev/null 2>&1 & +end + +function _bread_postexec --on-event fish_postexec --description 'bread: emit command.finished' + # $status must be captured first, before any other command in this + # function has a chance to overwrite it. + set -l exit_code $status + set -l cmd_json (_bread_json_escape $argv[1]) + set -l cwd_json (_bread_json_escape $_bread_cmd_cwd) + bread-emit bread.terminal.command.finished --source terminal --kind command.finished \ + --data "{\"cmd\": $cmd_json, \"cwd\": $cwd_json, \"exit_code\": $exit_code, \"duration_ms\": $CMD_DURATION}" >/dev/null 2>&1 & +end + +function _bread_pwd_changed --on-variable PWD --description 'bread: emit cwd.changed' + status is-interactive; or return + set -l cwd_json (_bread_json_escape $PWD) + set -l prev_json (_bread_json_escape $_bread_prev_pwd) + bread-emit bread.terminal.cwd.changed --source terminal --kind cwd.changed \ + --data "{\"cwd\": $cwd_json, \"prev_cwd\": $prev_json}" >/dev/null 2>&1 & + set -g _bread_prev_pwd $PWD +end +set -g _bread_prev_pwd $PWD + +# --- Remote session detection (runs once at shell-init time) -------------- +# Only the outermost login shell announces: BREAD_SSH_ANNOUNCED is exported +# so nested fish subshells (and the fish_exit handler, which is only +# registered in this branch) don't each fire their own started/ended pair. +set -l _bread_is_ssh 0 +if test -n "$SSH_TTY" + set _bread_is_ssh 1 +else if test -n "$SSH_CONNECTION" + set _bread_is_ssh 1 +end + +if test $_bread_is_ssh -eq 1; and test -z "$BREAD_SSH_ANNOUNCED" + set -gx BREAD_SSH_ANNOUNCED 1 + # SSH_CONNECTION is "client_ip client_port server_ip server_port"; fall + # back to SSH_CLIENT (same leading field) if it's unset for some reason. + set -l _bread_conn_str $SSH_CONNECTION + if test -z "$_bread_conn_str" + set _bread_conn_str $SSH_CLIENT + end + set -g _bread_ssh_host (string split ' ' -- $_bread_conn_str)[1] + + set -l host_json (_bread_json_escape $_bread_ssh_host) + bread-emit bread.remote.session.started --source remote --kind session.started \ + --data "{\"host\": $host_json}" >/dev/null 2>&1 & + + function _bread_ssh_exit_hook --on-event fish_exit --description 'bread: emit remote session.ended' + set -l host_json (_bread_json_escape $_bread_ssh_host) + bread-emit bread.remote.session.ended --source remote --kind session.ended \ + --data "{\"host\": $host_json}" >/dev/null 2>&1 & + end +end +"#; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_accepts_known_shells() { + assert_eq!(TargetShell::parse("bash").unwrap(), TargetShell::Bash); + assert_eq!(TargetShell::parse("zsh").unwrap(), TargetShell::Zsh); + assert_eq!(TargetShell::parse("fish").unwrap(), TargetShell::Fish); + } + + #[test] + fn parse_rejects_unknown_shell() { + assert!(TargetShell::parse("powershell").is_err()); + assert!(TargetShell::parse("").is_err()); + } + + #[test] + fn detect_from_env_reads_shell_basename() { + std::env::set_var("SHELL", "/usr/bin/zsh"); + assert_eq!(TargetShell::detect_from_env(), TargetShell::Zsh); + + std::env::set_var("SHELL", "/bin/bash"); + assert_eq!(TargetShell::detect_from_env(), TargetShell::Bash); + + std::env::set_var("SHELL", "/usr/bin/fish"); + assert_eq!(TargetShell::detect_from_env(), TargetShell::Fish); + + std::env::set_var("SHELL", "/usr/bin/tcsh"); + assert_eq!(TargetShell::detect_from_env(), TargetShell::Unknown); + + std::env::remove_var("SHELL"); + assert_eq!(TargetShell::detect_from_env(), TargetShell::Unknown); + } + + #[test] + fn hook_scripts_reference_bread_emit_not_bread_emit_cli() { + // Guard against accidentally shelling out to the slow `bread emit` + // subcommand instead of the lightweight `bread-emit` binary. + for script in [BASH_ZSH_HOOK_SCRIPT, FISH_HOOK_SCRIPT] { + assert!(script.contains("bread-emit ")); + assert!(!script.contains("bread emit ")); + } + } + + #[test] + fn hook_scripts_background_every_emit_call() { + // Every bread-emit invocation must be backgrounded with output + // discarded so a stalled daemon can never block the shell. A single + // invocation can span multiple physical lines via a trailing `\` + // continuation, so continuation lines are joined into one logical + // statement before checking, rather than inspecting each line in + // isolation. + for script in [BASH_ZSH_HOOK_SCRIPT, FISH_HOOK_SCRIPT] { + for statement in join_line_continuations(script) { + if statement.contains("bread-emit ") { + assert!( + statement.contains(">/dev/null 2>&1 &"), + "statement not backgrounded/discarded: {statement}" + ); + } + } + } + } + + /// Joins physical lines ending in a trailing `\` continuation into a + /// single logical statement, so multi-line shell invocations can be + /// checked as a whole rather than line-by-line. + fn join_line_continuations(script: &str) -> Vec { + let mut statements = Vec::new(); + let mut current = String::new(); + for line in script.lines() { + let trimmed_end = line.trim_end(); + if let Some(rest) = trimmed_end.strip_suffix('\\') { + current.push_str(rest); + current.push(' '); + } else { + current.push_str(trimmed_end); + statements.push(std::mem::take(&mut current)); + } + } + if !current.is_empty() { + statements.push(current); + } + statements + } +} diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index f0703dc..843e758 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -1,3 +1,5 @@ +mod hooks_git; +mod hooks_shell; mod modules_mgmt; use anyhow::Result; @@ -58,6 +60,11 @@ enum Commands { #[command(subcommand)] subcommand: ModulesCommand, }, + /// Install shell/git hook integrations that feed events into breadd + Hooks { + #[command(subcommand)] + subcommand: HooksCommand, + }, /// List available profiles ProfileList, /// Activate a profile @@ -67,6 +74,13 @@ enum Commands { event: String, #[arg(short, long, default_value = "{}")] data: String, + /// Source to tag the event with (terminal/git/remote); routes through + /// the normalizer instead of being tagged System. Requires --kind. + #[arg(long)] + source: Option, + /// Adapter-specific raw kind (e.g. "command.started"); only used with --source. + #[arg(long)] + kind: Option, }, /// Health check daemon connectivity Ping, @@ -80,6 +94,19 @@ enum Commands { }, } +#[derive(Subcommand, Debug)] +enum HooksCommand { + /// Install shell integration hooks (precmd/preexec/chpwd + SSH session + /// detection) for the current or a named shell + InstallShell { + /// Force bash/zsh/fish instead of auto-detecting from $SHELL + shell: Option, + }, + /// Install git hooks (post-commit, post-checkout, post-merge) into the + /// current repository + InstallGit, +} + #[derive(Subcommand, Debug)] enum ModulesCommand { /// Install a module from a local directory @@ -137,6 +164,10 @@ async fn main() -> Result<()> { Commands::Modules { subcommand } => { handle_modules_cmd(subcommand, &socket).await?; } + Commands::Hooks { subcommand } => match subcommand { + HooksCommand::InstallShell { shell } => hooks_shell::install_shell(shell)?, + HooksCommand::InstallGit => hooks_git::install_git()?, + }, Commands::ProfileList => { let response = send_request(&socket, "profile.list", json!({})).await?; print_json(&response)?; @@ -146,17 +177,24 @@ async fn main() -> Result<()> { send_request(&socket, "profile.activate", json!({ "name": name })).await?; print_json(&response)?; } - Commands::Emit { event, data } => { + Commands::Emit { + event, + data, + source, + kind, + } => { let parsed = serde_json::from_str::(&data).unwrap_or_else(|_| json!({})); - let response = send_request( - &socket, - "emit", - json!({ - "event": event, - "data": parsed, - }), - ) - .await?; + let mut params = json!({ + "event": event, + "data": parsed, + }); + if let Some(source) = source { + params["source"] = json!(source); + } + if let Some(kind) = kind { + params["kind"] = json!(kind); + } + let response = send_request(&socket, "emit", params).await?; print_json(&response)?; } Commands::Ping => { diff --git a/bread-emit/Cargo.toml b/bread-emit/Cargo.toml new file mode 100644 index 0000000..63c5119 --- /dev/null +++ b/bread-emit/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "bread-emit" +version = "0.6.6" +edition = "2021" + +[[bin]] +name = "bread-emit" +path = "src/main.rs" + +[dependencies] +bread-shared = { path = "../bread-shared" } +serde_json.workspace = true diff --git a/bread-emit/src/main.rs b/bread-emit/src/main.rs new file mode 100644 index 0000000..cd39c68 --- /dev/null +++ b/bread-emit/src/main.rs @@ -0,0 +1,75 @@ +//! Minimal fire-and-forget emitter for breadd's IPC socket. +//! +//! This exists because shell precmd/preexec hooks (and git hooks) fire on +//! every command / commit — spawning the full `bread` CLI (clap parsing + +//! a Tokio runtime + a round trip) on that path is perceptible latency in +//! an interactive shell. This binary skips all of that: no async runtime, +//! argv is parsed by hand, and it never waits for or reads a reply. A +//! down or slow daemon must never delay or hang the caller's shell. + +use serde_json::{json, Value}; +use std::io::Write; +use std::os::unix::net::UnixStream; +use std::time::Duration; + +fn parse_args(args: &[String]) -> Option<(String, Option, Option, String)> { + let mut event = None; + let mut source = None; + let mut kind = None; + let mut data = "{}".to_string(); + + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--source" => { + source = args.get(i + 1).cloned(); + i += 2; + } + "--kind" => { + kind = args.get(i + 1).cloned(); + i += 2; + } + "--data" => { + data = args.get(i + 1).cloned().unwrap_or_else(|| "{}".to_string()); + i += 2; + } + other => { + if event.is_none() { + event = Some(other.to_string()); + } + i += 1; + } + } + } + + event.map(|event| (event, source, kind, data)) +} + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let Some((event, source, kind, data)) = parse_args(&args) else { + eprintln!("usage: bread-emit [--source --kind ] [--data ]"); + std::process::exit(1); + }; + + let parsed_data: Value = serde_json::from_str(&data).unwrap_or_else(|_| json!({})); + let mut params = json!({ "event": event, "data": parsed_data }); + if let Some(source) = source { + params["source"] = json!(source); + } + if let Some(kind) = kind { + params["kind"] = json!(kind); + } + + let request = json!({ "id": "0", "method": "emit", "params": params }); + let Ok(line) = serde_json::to_string(&request) else { + return; + }; + + // Best-effort: connect, write, exit. Never read a reply, never retry, + // never block longer than the write timeout below. + if let Ok(mut stream) = UnixStream::connect(bread_shared::resolve_socket_path()) { + let _ = stream.set_write_timeout(Some(Duration::from_millis(200))); + let _ = writeln!(stream, "{line}"); + } +} diff --git a/bread-shared/Cargo.toml b/bread-shared/Cargo.toml index 43e17e8..c4179df 100644 --- a/bread-shared/Cargo.toml +++ b/bread-shared/Cargo.toml @@ -6,3 +6,5 @@ edition = "2021" [dependencies] serde.workspace = true serde_json.workspace = true +dirs.workspace = true +toml = "0.8" diff --git a/bread-shared/src/apps.rs b/bread-shared/src/apps.rs new file mode 100644 index 0000000..7e6b18c --- /dev/null +++ b/bread-shared/src/apps.rs @@ -0,0 +1,113 @@ +//! The known-apps registry for sibling `bread*` application integration. +//! +//! Adding a new sibling app is a one-line edit to [`KNOWN_APPS`] — no new +//! `AdapterSource` variant, no new normalizer arm, no recompile-driven +//! exhaustiveness churn. This is what makes the sibling-app integration +//! model extensible: see `Documentation.md`'s "Namespaces" section and +//! "Integrating a bread* app" recipe. + +/// Registered sibling `bread*` app ids. Each id is also the app's reserved +/// segment in the `bread..*` (events) and `bread.command..*` +/// (commands) namespaces. +pub const KNOWN_APPS: &[&str] = &[ + "clip", "pad", "bar", "box", "lock", "mon", "paper", "search", "shot", "arr", "crumbs", "help", + "bakery", +]; + +/// Daemon-internal domains that are reserved and can never be claimed as an +/// app id, even if a future `bread*` app would otherwise want that name — +/// these are the top-level segments the normalizer and built-in event +/// families already use. +const RESERVED_DOMAINS: &[&str] = &[ + "terminal", + "git", + "hyprland", + "device", + "power", + "network", + "service", + "container", + "project", + "remote", + "system", + "profile", + "notify", + "command", + "workflow", +]; + +/// Whether `id` is a registered sibling-app id. +pub fn is_known_app(id: &str) -> bool { + KNOWN_APPS.contains(&id) +} + +/// Whether `id` is reserved for daemon-internal use and can never be +/// registered as a sibling-app id. +pub fn is_reserved_domain(id: &str) -> bool { + RESERVED_DOMAINS.contains(&id) +} + +/// Whether `event` is a well-formed event name for `app` — i.e. it starts +/// with `bread..`. An app may only publish within its own namespace +/// segment; this is what the IPC boundary checks before constructing a +/// `RawEvent` tagged `AdapterSource::App(app)`. +pub fn validate_app_namespace(app: &str, event: &str) -> bool { + event.starts_with(&format!("bread.{app}.")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_apps_are_recognized() { + assert!(is_known_app("clip")); + assert!(is_known_app("bakery")); + } + + #[test] + fn unknown_app_is_not_recognized() { + assert!(!is_known_app("notanapp")); + assert!(!is_known_app("")); + } + + #[test] + fn reserved_domains_are_never_known_apps() { + for domain in RESERVED_DOMAINS { + assert!( + !is_known_app(domain), + "reserved domain '{domain}' must not double as a known app id" + ); + } + } + + #[test] + fn reserved_domains_are_recognized() { + assert!(is_reserved_domain("power")); + assert!(is_reserved_domain("hyprland")); + assert!(!is_reserved_domain("clip")); + } + + #[test] + fn validate_app_namespace_accepts_own_namespace() { + assert!(validate_app_namespace("clip", "bread.clip.copied")); + assert!(validate_app_namespace( + "clip", + "bread.clip.stack_trace.captured" + )); + } + + #[test] + fn validate_app_namespace_rejects_other_namespaces() { + assert!(!validate_app_namespace("clip", "bread.pad.reminder.due")); + assert!(!validate_app_namespace("clip", "bread.power.ac.connected")); + assert!(!validate_app_namespace("clip", "bread.clipboard.copied")); + } + + #[test] + fn validate_app_namespace_rejects_bare_prefix_without_trailing_dot() { + // "bread.clipx..." must not satisfy the "clip" namespace just + // because it shares a string prefix. + assert!(!validate_app_namespace("clip", "bread.clipx.copied")); + } +} diff --git a/bread-shared/src/glob.rs b/bread-shared/src/glob.rs index 7440d9b..ec574c0 100644 --- a/bread-shared/src/glob.rs +++ b/bread-shared/src/glob.rs @@ -155,12 +155,18 @@ mod tests { #[test] fn dot_double_star_does_not_match_sibling_prefix() { assert!(!matches_pattern("bread.device.**", "bread.devicex")); - assert!(!matches_pattern("bread.device.**", "bread.network.connected")); + assert!(!matches_pattern( + "bread.device.**", + "bread.network.connected" + )); } #[test] fn mid_pattern_star_does_not_cross_dots() { - assert!(matches_pattern("bread.*.connected", "bread.alpha.connected")); + assert!(matches_pattern( + "bread.*.connected", + "bread.alpha.connected" + )); assert!(!matches_pattern( "bread.*.connected", "bread.alpha.beta.connected" diff --git a/bread-shared/src/lib.rs b/bread-shared/src/lib.rs index 06c2e83..f5982d8 100644 --- a/bread-shared/src/lib.rs +++ b/bread-shared/src/lib.rs @@ -8,13 +8,19 @@ use serde::{Deserialize, Serialize}; +pub mod apps; pub mod glob; /// 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)] +/// +/// Not `Copy`: the [`App`](AdapterSource::App) variant carries an owned +/// `String` (a sibling `bread*` app id), so callers that used to copy a +/// `AdapterSource` by value now `.clone()` it — see `breadd/src/core/normalizer.rs` +/// for the (small, compiler-driven) set of call sites this touches. +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash)] #[serde(rename_all = "snake_case")] pub enum AdapterSource { /// The Hyprland compositor IPC socket. @@ -30,6 +36,23 @@ pub enum AdapterSource { System, /// BlueZ Bluetooth stack via D-Bus. Bluetooth, + /// Shell precmd/preexec hooks (terminal command lifecycle, cwd changes). + Terminal, + /// Git hooks (commit/branch) and the in-daemon dirty-state poller. + Git, + /// Project-root file watches (via `notify`/inotify). + Filesystem, + /// systemd --user unit state, via the session D-Bus. + Systemd, + /// Podman container lifecycle, via `podman events`. + Podman, + /// SSH/remote session detection, via the shell hook. + Remote, + /// A sibling `bread*` application (breadclip, breadpad, ...), identified + /// by its registered app id (see [`apps::KNOWN_APPS`]). Confined to the + /// `bread..*` event namespace — enforced at the IPC boundary via + /// [`apps::validate_app_namespace`], not by this type itself. + App(String), } /// An unnormalized event as emitted by an adapter. @@ -91,6 +114,44 @@ pub fn now_unix_ms() -> u64 { .as_millis() as u64 } +#[derive(Deserialize, Default)] +struct DaemonSection { + #[serde(default)] + socket_path: String, +} + +#[derive(Deserialize, Default)] +struct SocketPathConfig { + #[serde(default)] + daemon: DaemonSection, +} + +/// Resolve breadd's Unix socket path exactly as `breadd::core::config::Config::socket_path` +/// resolves its own: an explicit `daemon.socket_path` in `~/.config/bread/breadd.toml` wins, +/// otherwise `$XDG_RUNTIME_DIR/bread/breadd.sock`, falling back to `/tmp/bread/breadd.sock`. +/// +/// Shared by every socket client that lives outside the daemon itself (`bread-emit`, and +/// `bread-client` in `bread-ecosystem/bread-utils`) so they can't drift from how the daemon +/// actually resolves its own socket — before this existed, `bread-emit` carried its own +/// hand-rolled copy of this exact logic. +pub fn resolve_socket_path() -> std::path::PathBuf { + if let Some(home) = dirs::home_dir() { + let config_path = home.join(".config/bread/breadd.toml"); + if let Ok(contents) = std::fs::read_to_string(&config_path) { + if let Ok(cfg) = toml::from_str::(&contents) { + if !cfg.daemon.socket_path.is_empty() { + return expand_path(&cfg.daemon.socket_path); + } + } + } + } + + let runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string()); + std::path::PathBuf::from(runtime_dir) + .join("bread") + .join("breadd.sock") +} + /// Expand a leading `~` or `~/` in a path string to the user's home directory. /// /// Falls back to returning the path unchanged if `$HOME` is unset, which keeps @@ -164,6 +225,38 @@ mod tests { serde_json::to_string(&AdapterSource::Bluetooth).unwrap(), "\"bluetooth\"" ); + assert_eq!( + serde_json::to_string(&AdapterSource::Terminal).unwrap(), + "\"terminal\"" + ); + assert_eq!( + serde_json::to_string(&AdapterSource::Git).unwrap(), + "\"git\"" + ); + assert_eq!( + serde_json::to_string(&AdapterSource::Filesystem).unwrap(), + "\"filesystem\"" + ); + assert_eq!( + serde_json::to_string(&AdapterSource::Systemd).unwrap(), + "\"systemd\"" + ); + assert_eq!( + serde_json::to_string(&AdapterSource::Podman).unwrap(), + "\"podman\"" + ); + assert_eq!( + serde_json::to_string(&AdapterSource::Remote).unwrap(), + "\"remote\"" + ); + } + + #[test] + fn adapter_source_app_serializes_as_externally_tagged_object() { + assert_eq!( + serde_json::to_string(&AdapterSource::App("clip".to_string())).unwrap(), + "{\"app\":\"clip\"}" + ); } #[test] @@ -175,6 +268,13 @@ mod tests { AdapterSource::Network, AdapterSource::System, AdapterSource::Bluetooth, + AdapterSource::Terminal, + AdapterSource::Git, + AdapterSource::Filesystem, + AdapterSource::Systemd, + AdapterSource::Podman, + AdapterSource::Remote, + AdapterSource::App("clip".to_string()), ] { let s = serde_json::to_string(&source).unwrap(); let back: AdapterSource = serde_json::from_str(&s).unwrap(); diff --git a/breadd/Cargo.toml b/breadd/Cargo.toml index f4ef2fa..7a9b417 100644 --- a/breadd/Cargo.toml +++ b/breadd/Cargo.toml @@ -21,6 +21,7 @@ futures-util = "0.3" netlink-packet-route = "0.11" netlink-packet-core = "0.4" libc = "0.2" +notify = "6.1" [dev-dependencies] tempfile.workspace = true diff --git a/breadd/src/adapters/filesystem.rs b/breadd/src/adapters/filesystem.rs new file mode 100644 index 0000000..8489bb2 --- /dev/null +++ b/breadd/src/adapters/filesystem.rs @@ -0,0 +1,470 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::mpsc as std_mpsc; +use std::time::{Duration, Instant}; + +use anyhow::Result; +use async_trait::async_trait; +use bread_shared::{expand_path, now_unix_ms, AdapterSource, RawEvent}; +use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use serde_json::json; +use tokio::sync::mpsc; +use tracing::{debug, warn}; + +use super::Adapter; + +/// Files/directories directly inside a watched root whose presence marks it +/// as a recognizable project. +const MARKERS: [&str; 4] = [".git", "Cargo.toml", "package.json", "go.mod"]; + +/// Directory names that are excluded from `file.changed` noise anywhere in a +/// watched tree. `.git` and `node_modules` are fully silent; `target`, +/// `dist`, and `build` additionally get a `build_artifact.created` signal +/// when a new file appears inside them. +const EXCLUDED_DIRS: [&str; 5] = [".git", "target", "node_modules", "dist", "build"]; + +/// Debounce window for `file.changed` — editors frequently perform several +/// writes (temp file + rename, fsync, etc.) for a single logical save. +const DEBOUNCE_WINDOW: Duration = Duration::from_millis(300); + +/// Watches a set of project-root glob patterns for filesystem activity and +/// reports project detection, plain file changes, and build-artifact +/// creation as [`RawEvent`]s. +#[derive(Clone)] +pub struct FilesystemAdapter { + /// Raw, unexpanded root patterns as configured (e.g. `["~/Projects/*"]`). + roots: Vec, +} + +impl FilesystemAdapter { + pub fn new(roots: Vec) -> Self { + Self { roots } + } + + /// Expand `~` and a single `*` glob segment in each configured pattern + /// into concrete directory paths. Patterns are not required to exist on + /// disk yet — existence is checked by the caller. + fn resolve_roots(&self) -> Vec { + let mut out = Vec::new(); + for pattern in &self.roots { + let expanded = expand_path(pattern); + out.extend(expand_glob(&expanded)); + } + out + } + + /// Scan each concrete root for project markers and emit a `"detected"` + /// event for any root that has at least one. Mirrors the + /// `enumerate_existing` convention used by the udev/bluetooth adapters: + /// called once before `run()`, best-effort, never fails the daemon. + pub async fn enumerate_existing(&self, tx: &mpsc::Sender) { + for root in self.resolve_roots() { + if !root.is_dir() { + debug!( + "filesystem: root {} does not exist, skipping enumeration", + root.display() + ); + continue; + } + + let markers = detect_markers(&root); + if markers.is_empty() { + continue; + } + + let _ = tx + .send(RawEvent { + source: AdapterSource::Filesystem, + kind: "detected".to_string(), + payload: json!({ + "root": root.to_string_lossy(), + "markers": markers, + }), + timestamp: now_unix_ms(), + }) + .await; + } + } +} + +#[async_trait] +impl Adapter for FilesystemAdapter { + fn name(&self) -> &'static str { + "filesystem" + } + + async fn run(&self, tx: mpsc::Sender) -> Result<()> { + let mut existing_roots = Vec::new(); + for root in self.resolve_roots() { + if root.is_dir() { + existing_roots.push(root); + } else { + warn!( + "filesystem: configured root {} does not exist, skipping", + root.display() + ); + } + } + + if existing_roots.is_empty() { + debug!("filesystem adapter: no existing roots to watch"); + return Ok(()); + } + + run_watch(existing_roots, tx).await + } +} + +/// Sets up a recursive `notify` watch on each root and bridges its +/// synchronous callback into the async `tx` channel via a blocking task. +/// +/// Roots that fail to watch (e.g. inotify watch-limit exhaustion) are logged +/// and skipped; the adapter keeps watching whatever roots succeeded. +async fn run_watch(roots: Vec, tx: mpsc::Sender) -> Result<()> { + let (std_tx, std_rx) = std_mpsc::channel::>(); + + let mut watcher: RecommendedWatcher = notify::recommended_watcher(move |res| { + let _ = std_tx.send(res); + })?; + + let mut watched_roots = Vec::new(); + for root in &roots { + match watcher.watch(root, RecursiveMode::Recursive) { + Ok(()) => watched_roots.push(root.clone()), + Err(e) => { + warn!( + "filesystem: failed to watch {} ({e}), skipping this root", + root.display() + ); + } + } + } + + if watched_roots.is_empty() { + warn!("filesystem adapter: no roots could be watched, exiting"); + return Ok(()); + } + + // The blocking task owns the receiving end and the debounce table; it + // runs until `std_rx` disconnects (watcher dropped) or `tx` is closed + // (daemon shutting down). `watcher` is kept alive in this async fn's + // stack across the await below so its background thread keeps feeding + // `std_rx` for as long as this future is polled. + tokio::task::spawn_blocking(move || { + let mut last_seen: HashMap = HashMap::new(); + while let Ok(res) = std_rx.recv() { + let event = match res { + Ok(event) => event, + Err(e) => { + debug!("filesystem watch error: {e}"); + continue; + } + }; + + for path in &event.paths { + let Some(root) = watched_roots.iter().find(|r| path.starts_with(r)) else { + continue; + }; + + if let Some(raw) = classify(root, path, &event.kind, &mut last_seen) { + if tx.blocking_send(raw).is_err() { + return; + } + } + } + } + }) + .await?; + + drop(watcher); + Ok(()) +} + +/// Classifies a single notify path event into a `RawEvent`, or `None` if it +/// should be silent (inside `.git`/`node_modules`, or debounced). +fn classify( + root: &Path, + path: &Path, + kind: &EventKind, + last_seen: &mut HashMap, +) -> Option { + let relative = path.strip_prefix(root).unwrap_or(path); + + match excluded_dir_component(relative) { + Some("target" | "dist" | "build") => { + if matches!(kind, EventKind::Create(_)) { + Some(RawEvent { + source: AdapterSource::Filesystem, + kind: "build_artifact.created".to_string(), + payload: json!({ + "path": path.to_string_lossy(), + "project_root": root.to_string_lossy(), + }), + timestamp: now_unix_ms(), + }) + } else { + None + } + } + // `.git` / `node_modules`: fully silent, never emit. + Some(_) => None, + None => { + let now = Instant::now(); + if let Some(last) = last_seen.get(path) { + if now.duration_since(*last) < DEBOUNCE_WINDOW { + return None; + } + } + last_seen.insert(path.to_path_buf(), now); + + Some(RawEvent { + source: AdapterSource::Filesystem, + kind: "file.changed".to_string(), + payload: json!({ + "path": path.to_string_lossy(), + "project_root": root.to_string_lossy(), + }), + timestamp: now_unix_ms(), + }) + } + } +} + +/// Returns the first excluded directory name found anywhere among the +/// components of `relative`, or `None` if it isn't under any of them. +fn excluded_dir_component(relative: &Path) -> Option<&'static str> { + for component in relative.components() { + if let std::path::Component::Normal(name) = component { + let name = name.to_str().unwrap_or(""); + if let Some(excluded) = EXCLUDED_DIRS.iter().find(|e| **e == name) { + return Some(excluded); + } + } + } + None +} + +/// Checks `root` for the presence of any recognized project marker +/// directly inside it (not recursively). +fn detect_markers(root: &Path) -> Vec { + MARKERS + .iter() + .filter(|marker| root.join(marker).exists()) + .map(|marker| marker.to_string()) + .collect() +} + +/// Expands a single `*` path component (if present) into every directory +/// entry of its parent. Patterns without a `*` are returned unchanged. +/// Only one level of globbing is supported, matching the config contract +/// (e.g. `~/Projects/*`, not `~/Projects/**`). +fn expand_glob(path: &Path) -> Vec { + let components: Vec<_> = path.components().collect(); + let Some(star_idx) = components.iter().position(|c| c.as_os_str() == "*") else { + return vec![path.to_path_buf()]; + }; + + let parent: PathBuf = components[..star_idx].iter().collect(); + let suffix: PathBuf = components[star_idx + 1..].iter().collect(); + + let Ok(entries) = std::fs::read_dir(&parent) else { + debug!( + "filesystem: cannot read {} to expand glob pattern", + parent.display() + ); + return Vec::new(); + }; + + let mut out: Vec = entries + .flatten() + .map(|entry| entry.path()) + .filter(|p| p.is_dir()) + .map(|p| { + if suffix.as_os_str().is_empty() { + p + } else { + p.join(&suffix) + } + }) + .collect(); + out.sort(); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn excluded_dir_component_finds_git_at_top_level() { + assert_eq!(excluded_dir_component(Path::new(".git/HEAD")), Some(".git")); + } + + #[test] + fn excluded_dir_component_finds_target_nested_deeply() { + assert_eq!( + excluded_dir_component(Path::new("crates/foo/target/debug/build/out.o")), + Some("target") + ); + } + + #[test] + fn excluded_dir_component_finds_node_modules() { + assert_eq!( + excluded_dir_component(Path::new("web/node_modules/lodash/index.js")), + Some("node_modules") + ); + } + + #[test] + fn excluded_dir_component_none_for_ordinary_source_file() { + assert_eq!(excluded_dir_component(Path::new("src/main.rs")), None); + } + + #[test] + fn classify_silent_under_git() { + let mut last_seen = HashMap::new(); + let root = Path::new("/proj"); + let path = Path::new("/proj/.git/HEAD"); + let result = classify( + root, + path, + &EventKind::Create(notify::event::CreateKind::File), + &mut last_seen, + ); + assert!(result.is_none()); + } + + #[test] + fn classify_silent_under_node_modules() { + let mut last_seen = HashMap::new(); + let root = Path::new("/proj"); + let path = Path::new("/proj/node_modules/foo/index.js"); + let result = classify( + root, + path, + &EventKind::Modify(notify::event::ModifyKind::Any), + &mut last_seen, + ); + assert!(result.is_none()); + } + + #[test] + fn classify_build_artifact_created_in_target() { + let mut last_seen = HashMap::new(); + let root = Path::new("/proj"); + let path = Path::new("/proj/target/debug/breadd"); + let result = classify( + root, + path, + &EventKind::Create(notify::event::CreateKind::File), + &mut last_seen, + ); + let event = result.expect("expected build_artifact.created event"); + assert_eq!(event.kind, "build_artifact.created"); + assert_eq!(event.payload["path"], json!("/proj/target/debug/breadd")); + assert_eq!(event.payload["project_root"], json!("/proj")); + } + + #[test] + fn classify_silent_for_modify_in_target_not_create() { + let mut last_seen = HashMap::new(); + let root = Path::new("/proj"); + let path = Path::new("/proj/target/debug/breadd"); + let result = classify( + root, + path, + &EventKind::Modify(notify::event::ModifyKind::Data( + notify::event::DataChange::Any, + )), + &mut last_seen, + ); + assert!(result.is_none()); + } + + #[test] + fn classify_file_changed_for_ordinary_source_file() { + let mut last_seen = HashMap::new(); + let root = Path::new("/proj"); + let path = Path::new("/proj/src/main.rs"); + let result = classify( + root, + path, + &EventKind::Modify(notify::event::ModifyKind::Any), + &mut last_seen, + ); + let event = result.expect("expected file.changed event"); + assert_eq!(event.kind, "file.changed"); + assert_eq!(event.payload["path"], json!("/proj/src/main.rs")); + assert_eq!(event.payload["project_root"], json!("/proj")); + } + + #[test] + fn classify_debounces_rapid_repeat_events_for_same_path() { + let mut last_seen = HashMap::new(); + let root = Path::new("/proj"); + let path = Path::new("/proj/src/main.rs"); + let kind = EventKind::Modify(notify::event::ModifyKind::Any); + + let first = classify(root, path, &kind, &mut last_seen); + assert!(first.is_some()); + + let second = classify(root, path, &kind, &mut last_seen); + assert!(second.is_none(), "second rapid event should be debounced"); + } + + #[test] + fn detect_markers_finds_cargo_toml() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap(); + let markers = detect_markers(dir.path()); + assert_eq!(markers, vec!["Cargo.toml".to_string()]); + } + + #[test] + fn detect_markers_finds_multiple() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join(".git")).unwrap(); + fs::write(dir.path().join("package.json"), "{}").unwrap(); + let mut markers = detect_markers(dir.path()); + markers.sort(); + let mut expected = vec![".git".to_string(), "package.json".to_string()]; + expected.sort(); + assert_eq!(markers, expected); + } + + #[test] + fn detect_markers_empty_for_plain_directory() { + let dir = tempfile::tempdir().unwrap(); + assert!(detect_markers(dir.path()).is_empty()); + } + + #[test] + fn expand_glob_returns_single_path_unchanged_without_star() { + let path = Path::new("/home/user/Projects/bread"); + assert_eq!(expand_glob(path), vec![path.to_path_buf()]); + } + + #[test] + fn expand_glob_expands_star_to_subdirectories() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join("alpha")).unwrap(); + fs::create_dir(dir.path().join("beta")).unwrap(); + fs::write(dir.path().join("not-a-dir.txt"), "x").unwrap(); + + let pattern = dir.path().join("*"); + let mut results = expand_glob(&pattern); + results.sort(); + + let mut expected = vec![dir.path().join("alpha"), dir.path().join("beta")]; + expected.sort(); + assert_eq!(results, expected); + } + + #[test] + fn expand_glob_returns_empty_for_nonexistent_parent() { + let pattern = Path::new("/definitely/does/not/exist/*"); + assert!(expand_glob(pattern).is_empty()); + } +} diff --git a/breadd/src/adapters/git.rs b/breadd/src/adapters/git.rs new file mode 100644 index 0000000..27edcdf --- /dev/null +++ b/breadd/src/adapters/git.rs @@ -0,0 +1,524 @@ +//! Polls a configured set of project roots for git dirty/clean transitions +//! and ahead/behind-upstream changes. +//! +//! Scope note: this adapter owns exactly two event families — +//! `state.dirty`/`state.clean` and `ahead_behind.changed`. It deliberately +//! does **not** emit anything for HEAD changes as such (commits, checkouts, +//! branch switches), even though `git status`/`rev-list` are re-run against +//! every tracked repo on every tick and will absolutely see those +//! transitions too. A separate, hook-based path (`post-commit`/ +//! `post-checkout` invoking a CLI tool) owns `bread.git.commit.created` / +//! `bread.git.branch.changed`; if this poller also emitted on the same +//! transitions, both paths would fire for one real-world event. (An earlier +//! version of this file tried to skip the subprocess check on ticks where +//! `.git/HEAD`/`.git/index` hadn't changed mtime, as an optimization — that +//! silently broke the primary use case, since a plain working-tree edit +//! never touches either file. Every repo is checked every tick now; see the +//! comment at the top of the poll loop below.) +//! +//! Structurally this mirrors `power.rs`: a plain `tokio::time::interval` +//! poll loop with no external socket, looping forever and relying on the +//! supervisor's outer `tokio::select!` (in `Manager::spawn_adapter`) against +//! `shutdown_rx` to cancel the future — this adapter does not check +//! `tx.is_closed()` itself, and channel-send failures are propagated with +//! `?` exactly as `power.rs` does. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use bread_shared::{expand_path, now_unix_ms, AdapterSource, RawEvent}; +use serde_json::json; +use tokio::process::Command; +use tokio::sync::{mpsc, Semaphore}; +use tokio::time::{interval, Duration}; +use tracing::{debug, warn}; + +use crate::adapters::Adapter; + +/// Default poll interval: frequent enough to feel responsive for a dirty/ +/// clean or ahead/behind indicator, infrequent enough that idle repos cost +/// nothing beyond a couple of `stat(2)` calls per tick. +const DEFAULT_POLL_INTERVAL_SECS: u64 = 2; + +/// Concurrency cap: caps how many `git` subprocesses may be running at once +/// across all repos that changed in a given tick. A `Semaphore` is used +/// (rather than fully sequential processing) because typical dev machines +/// have many idle repos under e.g. `~/Projects/*`, and processing a burst of +/// simultaneously-touched repos (e.g. right after a `git fetch --all` across +/// a monorepo forest, or a batch checkout script) one at a time would add +/// unnecessary tail latency; 4 keeps subprocess fan-out modest without +/// meaningfully serializing typical ticks (where usually 0-1 repos changed). +const MAX_CONCURRENT_GIT_OPS: usize = 4; + +/// Polls a set of project roots for git dirty/clean and ahead/behind-upstream +/// transitions. +/// +/// Construct with [`GitAdapter::new`], passing root path patterns such as +/// `["~/Projects/*"]`. Patterns are expanded via [`bread_shared::expand_path`] +/// (for a leading `~`) and, if the final path segment is a literal `*`, +/// glob-expanded one level deep via `std::fs::read_dir` on the parent +/// directory (no glob crate dependency; only a single trailing `*` segment +/// is supported, matching the spec this adapter was built against). +#[derive(Clone)] +pub struct GitAdapter { + root_patterns: Vec, + poll_interval: Duration, +} + +impl GitAdapter { + /// Uses [`DEFAULT_POLL_INTERVAL_SECS`]. + pub fn new(root_patterns: Vec) -> Self { + Self::with_interval( + root_patterns, + Duration::from_secs(DEFAULT_POLL_INTERVAL_SECS), + ) + } + + /// Same as [`GitAdapter::new`] but with an explicit poll interval — + /// primarily for tests, but also available if a future config key wants + /// to expose it. + pub fn with_interval(root_patterns: Vec, poll_interval: Duration) -> Self { + Self { + root_patterns, + poll_interval, + } + } +} + +#[async_trait] +impl Adapter for GitAdapter { + fn name(&self) -> &'static str { + "git" + } + + async fn run(&self, tx: mpsc::Sender) -> Result<()> { + debug!("git adapter started"); + + let mut tracks = discover_repos(&self.root_patterns); + if tracks.is_empty() { + debug!("git adapter: no git repositories found under configured roots"); + } + + let semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT_GIT_OPS)); + let mut ticker = interval(self.poll_interval); + + loop { + ticker.tick().await; + + // Every tracked repo is checked every tick. An earlier version of + // this gated the check on `.git/HEAD`/`.git/index` mtime changing + // first, but that misses the single most common transition this + // adapter exists to report: plain working-tree edits (creating, + // editing, or deleting a file) make `git status --porcelain` + // dirty without ever touching HEAD or the index, so that gate + // silently never fired for it. Subprocess fan-out is still + // bounded by the semaphore below, and `state.dirty`/`state.clean`/ + // `ahead_behind.changed` are only actually emitted on a real + // transition (see the apply pass), so an unchanged repo costs a + // `git status --porcelain` (and occasionally `rev-list`) per + // tick, not an emitted event. + let needs_check: Vec = (0..tracks.len()).collect(); + + // --- Check pass: bounded-concurrency git subprocesses. --- + let mut handles = Vec::with_capacity(needs_check.len()); + for idx in needs_check { + let repo_path = tracks[idx].path.clone(); + let permit = semaphore.clone(); + handles.push(tokio::spawn(async move { + let _permit = permit.acquire_owned().await; + let dirty = check_dirty(&repo_path).await; + let ahead_behind = check_ahead_behind(&repo_path).await; + (idx, dirty, ahead_behind) + })); + } + + // --- Apply pass: sequential, so per-repo last-known state + // doesn't need its own lock. --- + for handle in handles { + let (idx, dirty_result, ahead_behind_result) = match handle.await { + Ok(result) => result, + Err(e) => { + warn!("git adapter: check task failed to join: {e}"); + continue; + } + }; + + let track = &mut tracks[idx]; + + match dirty_result { + Ok(dirty) => { + if track.last_dirty != Some(dirty) { + let kind = if dirty { "state.dirty" } else { "state.clean" }; + track.last_dirty = Some(dirty); + tx.send(RawEvent { + source: AdapterSource::Git, + kind: kind.to_string(), + payload: json!({ "repo": track.path.to_string_lossy() }), + timestamp: now_unix_ms(), + }) + .await?; + } + } + Err(e) => warn!( + "git adapter: status check failed for {}: {e}", + track.path.display() + ), + } + + match ahead_behind_result { + Ok(Some((branch, ahead, behind))) => { + if track.last_ahead_behind != Some((ahead, behind)) { + track.last_ahead_behind = Some((ahead, behind)); + tx.send(RawEvent { + source: AdapterSource::Git, + kind: "ahead_behind.changed".to_string(), + payload: json!({ + "repo": track.path.to_string_lossy(), + "ahead": ahead, + "behind": behind, + "branch": branch, + }), + timestamp: now_unix_ms(), + }) + .await?; + } + } + // No upstream configured for the current branch — not an + // error, just nothing to report for this repo this tick. + Ok(None) => {} + Err(e) => debug!( + "git adapter: ahead/behind check failed for {}: {e}", + track.path.display() + ), + } + } + } + } +} + +/// Per-repo tracking state carried between poll ticks. +struct RepoTrack { + /// Worktree root — what gets passed to `git -C ` and reported in + /// event payloads. + path: PathBuf, + last_dirty: Option, + last_ahead_behind: Option<(u32, u32)>, +} + +impl RepoTrack { + fn new(path: PathBuf) -> Self { + Self { + path, + last_dirty: None, + last_ahead_behind: None, + } + } +} + +/// Expands configured root patterns and resolves each concrete root to a +/// [`RepoTrack`] if (and only if) it's a recognizable git checkout. Roots +/// that aren't git repos are skipped with a debug log, not an error — the +/// configured glob is expected to sweep up non-repo directories routinely +/// (e.g. `~/Projects/*` catching a README-only folder). +fn discover_repos(root_patterns: &[String]) -> Vec { + let mut tracks = Vec::new(); + for root in expand_roots(root_patterns) { + match resolve_git_dir(&root) { + Some(_git_dir) => tracks.push(RepoTrack::new(root)), + None => debug!( + "git adapter: {} is not a git repository, skipping", + root.display() + ), + } + } + tracks +} + +/// Expands `~` (via `bread_shared::expand_path`) and a single trailing `*` +/// path segment (via a one-level `std::fs::read_dir` on the parent +/// directory — no glob crate). Patterns without a trailing `*` are used +/// literally. Only directories are kept when expanding a `*`. +fn expand_roots(patterns: &[String]) -> Vec { + let mut out = Vec::new(); + for pattern in patterns { + let expanded = expand_path(pattern); + if expanded.file_name().map(|n| n == "*").unwrap_or(false) { + let parent = expanded.parent().unwrap_or_else(|| Path::new(".")); + match std::fs::read_dir(parent) { + Ok(entries) => { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + out.push(path); + } + } + } + Err(e) => debug!("git adapter: cannot glob {}: {e}", parent.display()), + } + } else { + out.push(expanded); + } + } + out +} + +/// Resolves `/.git` to the actual git directory to use for `HEAD`/ +/// `index` stats, handling both shapes: +/// - a plain directory (the common case) — used as-is; +/// - a `.git` *file* containing `gitdir: ` (worktrees and submodules) +/// — the pointer is read and resolved (relative to `root` if not +/// absolute), and canonicalized on a best-effort basis. +/// +/// Chose to fully resolve the worktree/submodule case rather than skip it, +/// since it's a common setup (git worktrees especially) and the extra +/// parsing is small. Returns `None` if `/.git` doesn't exist or is +/// some other unrecognized shape. +fn resolve_git_dir(root: &Path) -> Option { + let dotgit = root.join(".git"); + let meta = std::fs::symlink_metadata(&dotgit).ok()?; + + if meta.is_dir() { + return Some(dotgit); + } + + if meta.is_file() { + let content = std::fs::read_to_string(&dotgit).ok()?; + let gitdir_str = parse_gitdir_file(&content)?; + let gitdir_path = PathBuf::from(gitdir_str); + let resolved = if gitdir_path.is_absolute() { + gitdir_path + } else { + root.join(gitdir_path) + }; + return Some(std::fs::canonicalize(&resolved).unwrap_or(resolved)); + } + + None +} + +/// Parses the contents of a worktree/submodule `.git` file, e.g. +/// `gitdir: /home/user/Projects/repo/.git/worktrees/feature\n`, returning +/// the path portion. +fn parse_gitdir_file(content: &str) -> Option<&str> { + content + .lines() + .find_map(|line| line.trim().strip_prefix("gitdir:")) + .map(|rest| rest.trim()) +} + +/// Runs `git -C status --porcelain` and reports whether the repo is +/// dirty (any output) or clean (empty output). +async fn check_dirty(repo: &Path) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(["status", "--porcelain"]) + .output() + .await + .map_err(|e| anyhow!("failed to spawn git status: {e}"))?; + + if !output.status.success() { + return Err(anyhow!( + "git status exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(!stdout.trim().is_empty()) +} + +/// Runs `git -C rev-list --left-right --count HEAD...@{upstream}` and +/// `git -C rev-parse --abbrev-ref HEAD`, returning +/// `Some((branch, ahead, behind))`. Returns `Ok(None)` (not an error) when +/// there's no upstream configured for the current branch, since that's the +/// expected/common state for plenty of repos, not a failure. +async fn check_ahead_behind(repo: &Path) -> Result> { + let rev_list = Command::new("git") + .arg("-C") + .arg(repo) + .args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]) + .output() + .await + .map_err(|e| anyhow!("failed to spawn git rev-list: {e}"))?; + + if !rev_list.status.success() { + // Most commonly: "no upstream configured for branch". Treat any + // non-zero exit here as "nothing to report", not an adapter error. + return Ok(None); + } + + let stdout = String::from_utf8_lossy(&rev_list.stdout); + let Some((ahead, behind)) = parse_ahead_behind(&stdout) else { + return Err(anyhow!("unexpected git rev-list output: {stdout:?}")); + }; + + let branch_output = Command::new("git") + .arg("-C") + .arg(repo) + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .output() + .await + .map_err(|e| anyhow!("failed to spawn git rev-parse: {e}"))?; + + let branch = if branch_output.status.success() { + String::from_utf8_lossy(&branch_output.stdout) + .trim() + .to_string() + } else { + "HEAD".to_string() + }; + + Ok(Some((branch, ahead, behind))) +} + +/// Parses `git rev-list --left-right --count HEAD...@{upstream}` output. +/// +/// With `HEAD...@{upstream}`, `HEAD` is the left side and `@{upstream}` is +/// the right side, so `--left-right --count` prints " +/// " — i.e. " ", in that order, +/// whitespace-separated (typically a single tab). +fn parse_ahead_behind(output: &str) -> Option<(u32, u32)> { + let mut parts = output.split_whitespace(); + let ahead = parts.next()?.parse::().ok()?; + let behind = parts.next()?.parse::().ok()?; + Some((ahead, behind)) +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- parse_ahead_behind --- + + #[test] + fn parse_ahead_behind_parses_tab_separated_counts() { + assert_eq!(parse_ahead_behind("3\t2\n"), Some((3, 2))); + } + + #[test] + fn parse_ahead_behind_parses_space_separated_counts() { + assert_eq!(parse_ahead_behind("0 5"), Some((0, 5))); + } + + #[test] + fn parse_ahead_behind_handles_zero_zero() { + assert_eq!(parse_ahead_behind("0\t0"), Some((0, 0))); + } + + #[test] + fn parse_ahead_behind_rejects_malformed_output() { + assert_eq!(parse_ahead_behind(""), None); + assert_eq!(parse_ahead_behind("only-one"), None); + assert_eq!(parse_ahead_behind("not a number\t2"), None); + } + + // --- parse_gitdir_file --- + + #[test] + fn parse_gitdir_file_extracts_path() { + assert_eq!( + parse_gitdir_file("gitdir: /home/user/repo/.git/worktrees/feature\n"), + Some("/home/user/repo/.git/worktrees/feature") + ); + } + + #[test] + fn parse_gitdir_file_trims_whitespace() { + assert_eq!( + parse_gitdir_file("gitdir: ../.git/modules/sub \n"), + Some("../.git/modules/sub") + ); + } + + #[test] + fn parse_gitdir_file_rejects_unrecognized_content() { + assert_eq!(parse_gitdir_file("not a gitdir pointer\n"), None); + assert_eq!(parse_gitdir_file(""), None); + } + + // --- expand_roots --- + + #[test] + fn expand_roots_uses_literal_path_without_trailing_star() { + let dir = tempfile::tempdir().expect("tempdir"); + let repo = dir.path().join("myrepo"); + std::fs::create_dir(&repo).unwrap(); + + let pattern = repo.to_string_lossy().to_string(); + let roots = expand_roots(&[pattern]); + + assert_eq!(roots, vec![repo]); + } + + #[test] + fn expand_roots_globs_single_trailing_star() { + let dir = tempfile::tempdir().expect("tempdir"); + let repo_a = dir.path().join("repo-a"); + let repo_b = dir.path().join("repo-b"); + std::fs::create_dir(&repo_a).unwrap(); + std::fs::create_dir(&repo_b).unwrap(); + // A stray file alongside the directories must not be treated as a root. + std::fs::write(dir.path().join("not-a-dir.txt"), b"hi").unwrap(); + + let pattern = format!("{}/*", dir.path().to_string_lossy()); + let mut roots = expand_roots(&[pattern]); + roots.sort(); + + let mut expected = vec![repo_a, repo_b]; + expected.sort(); + assert_eq!(roots, expected); + } + + #[test] + fn expand_roots_skips_unreadable_glob_parent_without_panicking() { + let pattern = "/definitely/does/not/exist/*".to_string(); + let roots = expand_roots(&[pattern]); + assert!(roots.is_empty()); + } + + // --- resolve_git_dir --- + + #[test] + fn resolve_git_dir_finds_standard_directory_git() { + let dir = tempfile::tempdir().expect("tempdir"); + let repo = dir.path().join("repo"); + let dotgit = repo.join(".git"); + std::fs::create_dir_all(&dotgit).unwrap(); + + assert_eq!(resolve_git_dir(&repo), Some(dotgit)); + } + + #[test] + fn resolve_git_dir_resolves_worktree_style_git_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let main_repo_gitdir = dir.path().join("main-repo").join(".git"); + std::fs::create_dir_all(&main_repo_gitdir).unwrap(); + + let worktree = dir.path().join("worktree-checkout"); + std::fs::create_dir_all(&worktree).unwrap(); + std::fs::write( + worktree.join(".git"), + format!("gitdir: {}\n", main_repo_gitdir.to_string_lossy()), + ) + .unwrap(); + + let resolved = resolve_git_dir(&worktree).expect("should resolve worktree gitdir"); + assert_eq!( + std::fs::canonicalize(&resolved).unwrap(), + std::fs::canonicalize(&main_repo_gitdir).unwrap() + ); + } + + #[test] + fn resolve_git_dir_returns_none_for_non_repo() { + let dir = tempfile::tempdir().expect("tempdir"); + let not_a_repo = dir.path().join("just-a-folder"); + std::fs::create_dir(¬_a_repo).unwrap(); + + assert_eq!(resolve_git_dir(¬_a_repo), None); + } +} diff --git a/breadd/src/adapters/mod.rs b/breadd/src/adapters/mod.rs index 18e8b98..91c22c4 100644 --- a/breadd/src/adapters/mod.rs +++ b/breadd/src/adapters/mod.rs @@ -11,11 +11,15 @@ use crate::core::config::Config; use crate::core::supervisor::spawn_supervised; pub mod bluetooth; +pub mod filesystem; +pub mod git; pub mod hyprland; pub mod network; pub mod network_rtnetlink; +pub mod podman; pub mod power; pub mod power_upower; +pub mod systemd; pub mod udev; #[derive(Debug, Clone, Serialize)] @@ -104,6 +108,32 @@ impl Manager { } } + // Filesystem/git/systemd all default to "no roots/units configured", + // in which case they'd have nothing to do — skip spawning them + // entirely rather than running an adapter that can never emit. + if self.config.adapters.filesystem.enabled + && !self.config.adapters.filesystem.roots.is_empty() + { + let adapter = + filesystem::FilesystemAdapter::new(self.config.adapters.filesystem.roots.clone()); + adapter.enumerate_existing(&self.raw_tx).await; + self.spawn_adapter(adapter); + } + + if self.config.adapters.git.enabled && !self.config.adapters.git.roots.is_empty() { + let adapter = git::GitAdapter::new(self.config.adapters.git.roots.clone()); + self.spawn_adapter(adapter); + } + + if self.config.adapters.systemd.enabled && !self.config.adapters.systemd.units.is_empty() { + let adapter = systemd::SystemdAdapter::new(self.config.adapters.systemd.units.clone()); + self.spawn_adapter(adapter); + } + + if self.config.adapters.podman.enabled { + self.spawn_adapter(podman::PodmanAdapter::new()); + } + Ok(()) } diff --git a/breadd/src/adapters/podman.rs b/breadd/src/adapters/podman.rs new file mode 100644 index 0000000..e71851a --- /dev/null +++ b/breadd/src/adapters/podman.rs @@ -0,0 +1,304 @@ +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use bread_shared::{now_unix_ms, AdapterSource, RawEvent}; +use serde_json::{json, Value}; +use std::process::Stdio; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; +use tokio::sync::mpsc; +use tracing::{debug, info}; + +use crate::adapters::Adapter; + +/// Watches `podman events --format json` for container lifecycle changes and +/// forwards them as [`RawEvent`]s. +/// +/// This is the first adapter in the codebase wrapping a child process rather +/// than a socket/D-Bus/netlink connection, so the process-lifecycle handling +/// here (kill-on-drop, treating any exit as an error so the supervisor +/// retries) is bespoke rather than following an existing pattern. +#[derive(Clone, Debug)] +pub struct PodmanAdapter; + +impl PodmanAdapter { + pub fn new() -> Self { + Self + } +} + +impl Default for PodmanAdapter { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Adapter for PodmanAdapter { + fn name(&self) -> &'static str { + "podman" + } + + async fn run(&self, tx: mpsc::Sender) -> Result<()> { + info!("podman adapter starting"); + + // kill_on_drop(true) ensures that if this future is cancelled (e.g. the + // supervisor tears the adapter down on daemon shutdown, or `tokio::select!` + // in Manager::spawn_adapter races it against the shutdown signal), the + // `podman events` child is killed rather than left running as an orphan + // with its stdout pipe silently discarded. + let mut child = match Command::new("podman") + .args(["events", "--format", "json"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + { + Ok(child) => child, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + info!("podman binary not found; will retry on backoff"); + return Err(anyhow!("podman binary not found: {e}")); + } + Err(e) => { + return Err(anyhow!("failed to spawn podman events: {e}")); + } + }; + + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow!("podman events: child had no stdout"))?; + let mut lines = BufReader::new(stdout).lines(); + + loop { + let line = match lines.next_line().await { + Ok(Some(line)) => line, + Ok(None) => { + // EOF: the child's stdout closed, meaning the process exited. + // Never surface this as Ok(()) — the supervisor treats a clean + // `run()` return as "stop forever," but a dead `podman events` + // process is exactly the kind of thing we want retried. + let _ = child.kill().await; + return Err(anyhow!("podman events exited")); + } + Err(e) => { + let _ = child.kill().await; + return Err(anyhow!("podman events read error: {e}")); + } + }; + + if line.trim().is_empty() { + continue; + } + + let value: Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(e) => { + debug!("podman events: skipping unparseable line: {e}"); + continue; + } + }; + + if let Some((kind, payload)) = map_podman_event(&value) { + if tx + .send(RawEvent { + source: AdapterSource::Podman, + kind, + payload, + timestamp: now_unix_ms(), + }) + .await + .is_err() + { + let _ = child.kill().await; + return Err(anyhow!("podman adapter: downstream channel closed")); + } + } + } + } +} + +/// Parses a single `podman events --format json` line and maps it to a +/// `(kind, payload)` pair for `RawEvent`, or `None` if the event should be +/// ignored. +/// +/// Only `Type == "container"` events are handled. Action mapping: +/// - `start` -> `container.started` +/// - `died` -> `container.stopped` +/// - `stop` / `remove` -> ignored (see de-dup note below) +/// - `health_status` -> `container.health_status` +/// - anything else -> ignored +/// +/// De-dup choice: podman commonly emits `died` followed by `stop` (and +/// sometimes `remove`) for a single container exit. Emitting on all three +/// would fire `container.stopped` multiple times for one real-world +/// transition, which is worse for Lua module authors (who'd need to +/// de-duplicate themselves) than missing the rare case where a container is +/// stopped without ever having been in a running state that produced `died`. +/// `died` fires in the overwhelmingly common paths (normal exit, kill, crash), +/// so it's used as the sole trigger for `container.stopped` and `stop`/`remove` +/// are dropped. +fn map_podman_event(value: &Value) -> Option<(String, Value)> { + let event_type = value.get("Type").and_then(|v| v.as_str())?; + if event_type != "container" { + return None; + } + + let action = value.get("Action").and_then(|v| v.as_str())?; + + let actor = value.get("Actor"); + let id = actor + .and_then(|a| a.get("ID")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + let attributes = actor.and_then(|a| a.get("Attributes")); + let name = attributes + .and_then(|a| a.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + let image = attributes + .and_then(|a| a.get("image")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + + match action { + "start" => Some(( + "container.started".to_string(), + json!({ + "id": id, + "name": name, + "image": image, + }), + )), + "died" => Some(( + "container.stopped".to_string(), + json!({ + "id": id, + "name": name, + }), + )), + "stop" | "remove" => { + // Intentionally ignored — see de-dup note on map_podman_event above. + None + } + "health_status" => { + let health = attributes + .and_then(|a| a.get("health_status")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + Some(( + "container.health_status".to_string(), + json!({ + "id": id, + "name": name, + "health": health, + }), + )) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn container_event(action: &str, extra_attrs: Value) -> Value { + let mut attributes = json!({ + "name": "my-container", + "image": "docker.io/library/nginx:latest", + }); + if let (Some(attrs_obj), Some(extra_obj)) = + (attributes.as_object_mut(), extra_attrs.as_object()) + { + for (k, v) in extra_obj { + attrs_obj.insert(k.clone(), v.clone()); + } + } + + json!({ + "Type": "container", + "Action": action, + "Actor": { + "ID": "abc123fullid", + "Attributes": attributes, + }, + "Status": action, + "time": 1_700_000_000, + }) + } + + #[test] + fn maps_start_event() { + let event = container_event("start", json!({})); + let (kind, payload) = map_podman_event(&event).expect("should map start event"); + assert_eq!(kind, "container.started"); + assert_eq!(payload["id"], "abc123fullid"); + assert_eq!(payload["name"], "my-container"); + assert_eq!(payload["image"], "docker.io/library/nginx:latest"); + } + + #[test] + fn maps_died_event() { + let event = container_event("died", json!({})); + let (kind, payload) = map_podman_event(&event).expect("should map died event"); + assert_eq!(kind, "container.stopped"); + assert_eq!(payload["id"], "abc123fullid"); + assert_eq!(payload["name"], "my-container"); + } + + #[test] + fn ignores_stop_event_to_avoid_double_emit_with_died() { + let event = container_event("stop", json!({})); + assert!(map_podman_event(&event).is_none()); + } + + #[test] + fn ignores_remove_event() { + let event = container_event("remove", json!({})); + assert!(map_podman_event(&event).is_none()); + } + + #[test] + fn maps_health_status_event() { + let event = container_event("health_status", json!({ "health_status": "healthy" })); + let (kind, payload) = map_podman_event(&event).expect("should map health_status event"); + assert_eq!(kind, "container.health_status"); + assert_eq!(payload["id"], "abc123fullid"); + assert_eq!(payload["name"], "my-container"); + assert_eq!(payload["health"], "healthy"); + } + + #[test] + fn ignores_unknown_action() { + let event = container_event("exec_die", json!({})); + assert!(map_podman_event(&event).is_none()); + } + + #[test] + fn ignores_non_container_type() { + let event = json!({ + "Type": "network", + "Action": "start", + "Actor": { "ID": "netid", "Attributes": {} }, + }); + assert!(map_podman_event(&event).is_none()); + } + + #[test] + fn missing_fields_fall_back_to_unknown() { + let event = json!({ + "Type": "container", + "Action": "start", + "Actor": { "ID": "onlyid" }, + }); + let (kind, payload) = map_podman_event(&event).expect("should still map"); + assert_eq!(kind, "container.started"); + assert_eq!(payload["id"], "onlyid"); + assert_eq!(payload["name"], "unknown"); + assert_eq!(payload["image"], "unknown"); + } +} diff --git a/breadd/src/adapters/systemd.rs b/breadd/src/adapters/systemd.rs new file mode 100644 index 0000000..6213c43 --- /dev/null +++ b/breadd/src/adapters/systemd.rs @@ -0,0 +1,293 @@ +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, warn}; +use zbus::zvariant::{OwnedObjectPath, OwnedValue}; +use zbus::{Connection, Message, MessageStream}; + +use super::Adapter; + +const MANAGER_DEST: &str = "org.freedesktop.systemd1"; +const MANAGER_PATH: &str = "/org/freedesktop/systemd1"; +const MANAGER_IFACE: &str = "org.freedesktop.systemd1.Manager"; +const UNIT_IFACE: &str = "org.freedesktop.systemd1.Unit"; +const PROPS_IFACE: &str = "org.freedesktop.DBus.Properties"; + +/// Watches an allowlist of `systemd --user` units on the session bus and emits +/// start/stop/failure lifecycle events. +/// +/// Only units named in the allowlist are tracked — subscribing to every user +/// unit's transitions is noisy (timers, transient scopes, etc. fire +/// constantly), so we filter down to what the user's config explicitly named. +#[derive(Clone, Debug)] +pub struct SystemdAdapter { + units: Vec, +} + +impl SystemdAdapter { + pub fn new(units: Vec) -> Self { + Self { units } + } +} + +#[async_trait] +impl Adapter for SystemdAdapter { + fn name(&self) -> &'static str { + "systemd" + } + + async fn run(&self, tx: mpsc::Sender) -> Result<()> { + info!("systemd adapter starting"); + + let conn = Connection::session() + .await + .map_err(|e| anyhow!("systemd session bus unavailable: {e}"))?; + + // Job/property signals aren't delivered until a client asks the manager + // to start tracking them. + conn.call_method( + Some(MANAGER_DEST), + MANAGER_PATH, + Some(MANAGER_IFACE), + "Subscribe", + &(), + ) + .await + .map_err(|e| anyhow!("systemd Manager.Subscribe failed: {e}"))?; + + // Resolve each allowlisted unit name to its object path up front, so + // PropertiesChanged messages (which arrive addressed by path, not name) + // can be matched back to a unit without a lookup on every message. A + // unit that fails to resolve (not currently loaded, typo'd name, etc.) + // is skipped rather than failing the whole adapter — it simply won't be + // watched for `unit.failed` until the adapter restarts. + let mut path_to_unit: HashMap = HashMap::new(); + for unit in &self.units { + match get_unit_path(&conn, unit).await { + Ok(path) => { + debug!("systemd resolved unit '{unit}' -> {path}"); + path_to_unit.insert(path, unit.clone()); + } + Err(e) => { + warn!("systemd: could not resolve unit '{unit}' (not loaded?): {e}"); + } + } + } + + let mut stream = MessageStream::from(&conn); + while let Some(result) = stream.next().await { + match result { + Ok(message) => { + if let Some(event) = + handle_message(&conn, &message, &self.units, &path_to_unit).await + { + if tx.send(event).await.is_err() { + return Ok(()); + } + } + } + Err(e) => debug!("systemd stream error: {e}"), + } + } + + Ok(()) + } +} + +/// Resolve a unit name to its `/org/freedesktop/systemd1/unit/...` object path +/// via `Manager.GetUnit`. +async fn get_unit_path(conn: &Connection, unit_name: &str) -> Result { + let msg = conn + .call_method( + Some(MANAGER_DEST), + MANAGER_PATH, + Some(MANAGER_IFACE), + "GetUnit", + &(unit_name,), + ) + .await?; + let path: OwnedObjectPath = msg.body()?; + Ok(path.as_str().to_string()) +} + +/// Read the current `ActiveState` property (`"active"`, `"inactive"`, +/// `"failed"`, ...) off a resolved unit object path. +async fn query_active_state(conn: &Connection, unit_path: &str) -> Option { + let msg = conn + .call_method( + Some(MANAGER_DEST), + unit_path, + Some(PROPS_IFACE), + "Get", + &(UNIT_IFACE, "ActiveState"), + ) + .await + .ok()?; + let value: OwnedValue = msg.body().ok()?; + serde_json::to_value(&value) + .ok()? + .as_str() + .map(|s| s.to_string()) +} + +async fn handle_message( + conn: &Connection, + message: &Message, + units: &[String], + path_to_unit: &HashMap, +) -> Option { + 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(); + + // Start/stop: a job affecting one of our allowlisted units has completed. + // JobRemoved alone doesn't say whether the job was a start or a stop (or + // what it settled on), so we re-query ActiveState once the job is done to + // find out what actually happened. "failed" is deliberately left to the + // PropertiesChanged branch below, so each transition is only emitted once. + if interface == MANAGER_IFACE && member == "JobRemoved" { + let (_id, _job_path, unit_name, _result): (u32, OwnedObjectPath, String, String) = + message.body().ok()?; + if !units.iter().any(|u| u == &unit_name) { + return None; + } + let unit_path = get_unit_path(conn, &unit_name).await.ok()?; + let state = query_active_state(conn, &unit_path).await?; + let kind = active_state_to_kind(&state)?; + return Some(RawEvent { + source: AdapterSource::Systemd, + kind: kind.to_string(), + payload: json!({ "unit": unit_name }), + timestamp: now_unix_ms(), + }); + } + + // Failed: ActiveState flipped to "failed" on a unit we resolved at startup. + // This covers unit failures that occur without an explicit job completing + // from our point of view (e.g. a crash detected asynchronously). + if interface == PROPS_IFACE && member == "PropertiesChanged" { + let unit_name = path_to_unit.get(&path)?; + let (iface, changed, _invalidated): (String, HashMap, Vec) = + message.body().ok()?; + if iface != UNIT_IFACE { + return None; + } + let changed_json = serde_json::to_value(&changed).ok()?; + if !is_failed_transition(&changed_json) { + return None; + } + let result = failure_result(&changed_json); + return Some(RawEvent { + source: AdapterSource::Systemd, + kind: "unit.failed".to_string(), + payload: json!({ "unit": unit_name, "result": result }), + timestamp: now_unix_ms(), + }); + } + + None +} + +/// Map a unit's `ActiveState` to the lifecycle event kind it represents. +/// `"failed"` is intentionally excluded — that transition is reported via the +/// dedicated `PropertiesChanged` branch instead, so it isn't double-reported +/// once here and once there. Intermediate states (`activating`, `deactivating`, +/// `reloading`) aren't a resting state yet, so they're ignored too. +fn active_state_to_kind(state: &str) -> Option<&'static str> { + match state { + "active" => Some("unit.started"), + "inactive" | "dead" => Some("unit.stopped"), + _ => None, + } +} + +/// Whether a decoded `PropertiesChanged` payload represents a transition into +/// the `failed` active state. +fn is_failed_transition(changed: &serde_json::Value) -> bool { + changed + .get("ActiveState") + .and_then(|v| v.as_str()) + .map(|s| s == "failed") + .unwrap_or(false) +} + +/// Extract the `Result` property (e.g. `"exit-code"`, `"timeout"`) from a +/// decoded `PropertiesChanged` payload, if it was included in this batch. +fn failure_result(changed: &serde_json::Value) -> Option { + changed + .get("Result") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn active_state_to_kind_maps_active_to_started() { + assert_eq!(active_state_to_kind("active"), Some("unit.started")); + } + + #[test] + fn active_state_to_kind_maps_inactive_and_dead_to_stopped() { + assert_eq!(active_state_to_kind("inactive"), Some("unit.stopped")); + assert_eq!(active_state_to_kind("dead"), Some("unit.stopped")); + } + + #[test] + fn active_state_to_kind_excludes_failed() { + // Failed transitions are reported via PropertiesChanged instead, so + // JobRemoved handling must not also emit for this state. + assert_eq!(active_state_to_kind("failed"), None); + } + + #[test] + fn active_state_to_kind_ignores_transitional_states() { + assert_eq!(active_state_to_kind("activating"), None); + assert_eq!(active_state_to_kind("deactivating"), None); + assert_eq!(active_state_to_kind("reloading"), None); + } + + #[test] + fn is_failed_transition_detects_failed_active_state() { + let changed = json!({ "ActiveState": "failed", "SubState": "failed" }); + assert!(is_failed_transition(&changed)); + } + + #[test] + fn is_failed_transition_ignores_other_active_states() { + let changed = json!({ "ActiveState": "active" }); + assert!(!is_failed_transition(&changed)); + } + + #[test] + fn is_failed_transition_ignores_unrelated_property_changes() { + // A PropertiesChanged batch that doesn't touch ActiveState at all + // (e.g. just MemoryCurrent ticking) must not be treated as a failure. + let changed = json!({ "MemoryCurrent": 12345 }); + assert!(!is_failed_transition(&changed)); + } + + #[test] + fn failure_result_extracts_reason_when_present() { + let changed = json!({ "ActiveState": "failed", "Result": "exit-code" }); + assert_eq!(failure_result(&changed), Some("exit-code".to_string())); + } + + #[test] + fn failure_result_is_none_when_absent() { + let changed = json!({ "ActiveState": "failed" }); + assert_eq!(failure_result(&changed), None); + } +} diff --git a/breadd/src/core/config.rs b/breadd/src/core/config.rs index b1be12c..3ce75c5 100644 --- a/breadd/src/core/config.rs +++ b/breadd/src/core/config.rs @@ -57,6 +57,14 @@ pub struct AdaptersConfig { pub network: AdapterToggle, #[serde(default)] pub bluetooth: AdapterToggle, + #[serde(default)] + pub filesystem: RootsConfig, + #[serde(default)] + pub systemd: SystemdConfig, + #[serde(default)] + pub podman: AdapterToggle, + #[serde(default)] + pub git: RootsConfig, } #[derive(Debug, Clone, Deserialize)] @@ -81,6 +89,30 @@ pub struct PowerConfig { pub poll_interval_secs: u64, } +/// Shared shape for adapters scoped to a list of project-root glob patterns +/// (e.g. `~/Projects/*`) — used by both the filesystem and git adapters. +/// `roots` defaults to empty: these adapters do nothing until the user opts +/// in with actual paths, since there's no universally-safe default directory +/// to watch. +#[derive(Debug, Clone, Deserialize)] +pub struct RootsConfig { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub roots: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SystemdConfig { + #[serde(default = "default_true")] + pub enabled: bool, + /// Allowlist of `systemd --user` unit names to watch. Empty by default — + /// subscribing to every user unit's transitions is noisy, so nothing is + /// watched until the user names specific units. + #[serde(default)] + pub units: Vec, +} + #[derive(Debug, Clone, Deserialize)] pub struct EventsConfig { #[serde(default = "default_dedup_window")] @@ -150,6 +182,24 @@ impl Default for PowerConfig { } } +impl Default for RootsConfig { + fn default() -> Self { + Self { + enabled: default_true(), + roots: Vec::new(), + } + } +} + +impl Default for SystemdConfig { + fn default() -> Self { + Self { + enabled: default_true(), + units: Vec::new(), + } + } +} + impl Default for EventsConfig { fn default() -> Self { Self { @@ -206,7 +256,20 @@ fn config_path() -> PathBuf { expand_home("~/.config/bread/breadd.toml") } +/// Expands a leading `~/`. `~/.config/...` paths specifically prefer +/// `$XDG_CONFIG_HOME` when it's set, consistent with `config_path()`'s own +/// resolution of `breadd.toml` itself — otherwise the default `lua.entry_point` +/// / `lua.module_path` values (`"~/.config/bread/init.lua"` and +/// `"~/.config/bread/modules"`) would silently ignore `XDG_CONFIG_HOME` even +/// though the config file that sets them was found via that same variable, +/// which is exactly the kind of inconsistency that made init.lua/module +/// loading silently no-op for a XDG_CONFIG_HOME-only test setup. fn expand_home(input: &str) -> PathBuf { + if let Some(stripped) = input.strip_prefix("~/.config/") { + if let Ok(xdg_config) = env::var("XDG_CONFIG_HOME") { + return Path::new(&xdg_config).join(stripped); + } + } if let Some(stripped) = input.strip_prefix("~/") { if let Ok(home) = env::var("HOME") { return Path::new(&home).join(stripped); @@ -451,8 +514,9 @@ log_level = "trace" #[test] fn lua_entry_point_and_module_path_expand_tilde() { - let _g = EnvGuard::new(&["HOME"]); + let _g = EnvGuard::new(&["HOME", "XDG_CONFIG_HOME"]); std::env::set_var("HOME", "/synthetic/home"); + std::env::remove_var("XDG_CONFIG_HOME"); let cfg = Config::default(); assert_eq!( cfg.lua_entry_point(), @@ -464,6 +528,27 @@ log_level = "trace" ); } + #[test] + fn lua_entry_point_and_module_path_prefer_xdg_config_home_when_set() { + // config_path() (finding breadd.toml itself) already prefers + // XDG_CONFIG_HOME over HOME; the `~/.config/...` defaults for + // entry_point/module_path must resolve consistently with it, or a + // XDG_CONFIG_HOME-only setup (no matching $HOME/.config layout) + // silently fails to find its own init.lua/modules. + let _g = EnvGuard::new(&["HOME", "XDG_CONFIG_HOME"]); + std::env::set_var("HOME", "/synthetic/home"); + std::env::set_var("XDG_CONFIG_HOME", "/synthetic/xdg-config"); + let cfg = Config::default(); + assert_eq!( + cfg.lua_entry_point(), + PathBuf::from("/synthetic/xdg-config/bread/init.lua") + ); + assert_eq!( + cfg.lua_module_path(), + PathBuf::from("/synthetic/xdg-config/bread/modules") + ); + } + #[test] fn lua_entry_point_returns_absolute_path_unchanged() { let mut cfg = Config::default(); diff --git a/breadd/src/core/normalizer.rs b/breadd/src/core/normalizer.rs index 9961aed..96e40c8 100644 --- a/breadd/src/core/normalizer.rs +++ b/breadd/src/core/normalizer.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::sync::RwLock; -use bread_shared::{AdapterSource, BreadEvent, RawEvent}; +use bread_shared::{apps::validate_app_namespace, AdapterSource, BreadEvent, RawEvent}; use serde_json::{json, Value}; /// How many multiples of `dedup_window_ms` an entry must be idle before eviction. @@ -26,16 +26,23 @@ impl EventNormalizer { } pub fn normalize(&self, raw: &RawEvent) -> Vec { - let mut out = match raw.source { + let mut out = match &raw.source { AdapterSource::Udev => self.normalize_udev(raw), AdapterSource::Hyprland => self.normalize_hyprland(raw), AdapterSource::Power => self.normalize_power(raw), AdapterSource::Network => self.normalize_network(raw), AdapterSource::Bluetooth => self.normalize_bluetooth(raw), + AdapterSource::Terminal => self.normalize_terminal(raw), + AdapterSource::Git => self.normalize_git(raw), + AdapterSource::Filesystem => self.normalize_filesystem(raw), + AdapterSource::Systemd => self.normalize_systemd(raw), + AdapterSource::Podman => self.normalize_podman(raw), + AdapterSource::Remote => self.normalize_remote(raw), + AdapterSource::App(_) => self.normalize_app(raw), AdapterSource::System => vec![BreadEvent { event: raw.kind.clone(), timestamp: raw.timestamp, - source: raw.source, + source: raw.source.clone(), data: raw.payload.clone(), }], }; @@ -418,7 +425,105 @@ impl EventNormalizer { }] } + // Adapter contracts: each of these adapters emits `RawEvent.kind` already + // namespaced for its family (e.g. filesystem emits "file.changed", + // "detected", "build_artifact.created"), so normalization here is just a + // `bread..` prefix — except systemd (`unit.*` -> `service.*`) and + // podman's health-status rename, which need a small rewrite. + fn normalize_terminal(&self, raw: &RawEvent) -> Vec { + vec![BreadEvent { + event: format!("bread.terminal.{}", raw.kind), + timestamp: raw.timestamp, + source: raw.source.clone(), + data: raw.payload.clone(), + }] + } + + fn normalize_remote(&self, raw: &RawEvent) -> Vec { + vec![BreadEvent { + event: format!("bread.remote.{}", raw.kind), + timestamp: raw.timestamp, + source: raw.source.clone(), + data: raw.payload.clone(), + }] + } + + fn normalize_git(&self, raw: &RawEvent) -> Vec { + vec![BreadEvent { + event: format!("bread.git.{}", raw.kind), + timestamp: raw.timestamp, + source: raw.source.clone(), + data: raw.payload.clone(), + }] + } + + fn normalize_filesystem(&self, raw: &RawEvent) -> Vec { + vec![BreadEvent { + event: format!("bread.project.{}", raw.kind), + timestamp: raw.timestamp, + source: raw.source.clone(), + data: raw.payload.clone(), + }] + } + + fn normalize_systemd(&self, raw: &RawEvent) -> Vec { + // Adapter emits "unit.started"/"unit.stopped"/"unit.failed"; the public + // namespace is `service.*`, not `unit.*`. + let suffix = raw.kind.strip_prefix("unit.").unwrap_or(raw.kind.as_str()); + vec![BreadEvent { + event: format!("bread.service.{suffix}"), + timestamp: raw.timestamp, + source: raw.source.clone(), + data: raw.payload.clone(), + }] + } + + fn normalize_podman(&self, raw: &RawEvent) -> Vec { + // Adapter emits "container.started"/"container.stopped"/"container.health_status"; + // the public name for the latter is `container.health.changed`. + let event = if raw.kind == "container.health_status" { + "bread.container.health.changed".to_string() + } else { + format!("bread.{}", raw.kind) + }; + vec![BreadEvent { + event, + timestamp: raw.timestamp, + source: raw.source.clone(), + data: raw.payload.clone(), + }] + } + + /// Sibling `bread*` app events. Unlike the other sources, `raw.kind` + /// already carries the full dotted event name (the IPC boundary builds + /// it that way before construction), so this is validate-and-wrap, not + /// a transform. The namespace check is defense in depth — the IPC layer + /// already validates before constructing the `RawEvent` — so a + /// malformed event here is dropped silently rather than treated as an + /// adapter failure. + fn normalize_app(&self, raw: &RawEvent) -> Vec { + let AdapterSource::App(app) = &raw.source else { + return vec![]; + }; + if !validate_app_namespace(app, &raw.kind) { + return vec![]; + } + vec![BreadEvent { + event: raw.kind.clone(), + timestamp: raw.timestamp, + source: raw.source.clone(), + data: raw.payload.clone(), + }] + } + fn accept(&self, event: &BreadEvent) -> bool { + // Terminal commands legitimately repeat (running the same command twice + // in quick succession); the dedup window exists for noisy hardware + // signals, not user-initiated terminal activity, so exempt it. + if matches!(&event.source, AdapterSource::Terminal) { + return true; + } + let key = format!("{}:{}", event.event, event.data); let now = event.timestamp; diff --git a/breadd/src/core/types.rs b/breadd/src/core/types.rs index ad03fa4..a521cf6 100644 --- a/breadd/src/core/types.rs +++ b/breadd/src/core/types.rs @@ -14,6 +14,7 @@ pub struct RuntimeState { pub power: PowerState, pub profile: ProfileState, pub modules: Vec, + pub workflows: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -128,3 +129,34 @@ pub enum ModuleLoadState { Degraded, Disabled, } + +/// Introspectable state for a `bread.workflow` instance, surfaced via the +/// `workflows.list` IPC method. One entry per workflow *name* — starting a +/// workflow with a name that's already running replaces its entry (this is +/// a live-status registry, not a run history). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkflowStatus { + pub name: String, + pub state: WorkflowState, + /// The most recent `bread.workflow.step(label)` call inside the body, + /// if any. + pub step: Option, + pub started_at: u64, + pub updated_at: u64, + /// Set when `state` is `Failed`: the captured Lua error message. + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkflowState { + Running, + Done, + Failed, + /// The `opts.deadline` timer fired before the workflow reached a + /// terminal state. Note: since a suspended coroutine isn't forcibly + /// killed, it's possible (rare) for a workflow to still complete after + /// this and overwrite the status again — this is a status marker, not + /// a hard cancellation. + TimedOut, +} diff --git a/breadd/src/ipc/mod.rs b/breadd/src/ipc/mod.rs index d0482d8..df0afa4 100644 --- a/breadd/src/ipc/mod.rs +++ b/breadd/src/ipc/mod.rs @@ -8,7 +8,8 @@ use std::sync::Arc; use std::time::Instant; use anyhow::{anyhow, Result}; -use bread_shared::{now_unix_ms, AdapterSource, BreadEvent}; +use bread_shared::apps::{is_known_app, validate_app_namespace}; +use bread_shared::{now_unix_ms, AdapterSource, BreadEvent, RawEvent}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; @@ -20,6 +21,14 @@ use crate::adapters::AdapterStatus; use crate::core::state_engine::StateHandle; use crate::lua::RuntimeHandle; +/// The Bread Automation API version (Lua API surface + IPC methods + event +/// vocabulary + runtime-state schema), per `Documentation.md`'s "API +/// Stability & Versioning" section. Bump the minor version when adding +/// something new-but-additive (a binding, an event, an IPC param); bump the +/// major version only for a breaking change, which should not happen inside +/// this daemon's v1 lifetime per that section's stated policy. +const API_VERSION: &str = "1.2.0"; + #[derive(Clone)] pub struct Server { socket_path: PathBuf, @@ -27,6 +36,7 @@ pub struct Server { event_tx: broadcast::Sender, lua_runtime: RuntimeHandle, emit_tx: mpsc::UnboundedSender, + raw_tx: mpsc::Sender, adapter_status: Arc>>, subscription_count: Arc, event_buffer: Arc>>, @@ -61,6 +71,7 @@ impl Server { event_tx: broadcast::Sender, lua_runtime: RuntimeHandle, emit_tx: mpsc::UnboundedSender, + raw_tx: mpsc::Sender, adapter_status: Arc>>, subscription_count: Arc, event_buffer: Arc>>, @@ -71,6 +82,7 @@ impl Server { event_tx, lua_runtime, emit_tx, + raw_tx, adapter_status, subscription_count, event_buffer, @@ -141,9 +153,7 @@ impl Server { error: Some(format!("parse error: {e}")), }; write_half - .write_all( - format!("{}\n", serde_json::to_string(&err_resp)?).as_bytes(), - ) + .write_all(format!("{}\n", serde_json::to_string(&err_resp)?).as_bytes()) .await?; continue; } @@ -208,6 +218,10 @@ impl Server { let full = self.state_handle.state_dump().await; Ok(full.get("modules").cloned().unwrap_or_else(|| json!([]))) } + "workflows.list" => { + let full = self.state_handle.state_dump().await; + Ok(full.get("workflows").cloned().unwrap_or_else(|| json!([]))) + } "modules.reload" => { let started = Instant::now(); if let Err(err) = self.lua_runtime.reload().await { @@ -252,18 +266,71 @@ impl Server { Ok(json!({ "active": name })) } "emit" => { - let Some(event) = req.params.get("event").and_then(Value::as_str) else { - return Err((id, "missing event name".to_string())); - }; let data = req.params.get("data").cloned().unwrap_or_else(|| json!({})); - if self - .emit_tx - .send(BreadEvent::new(event, AdapterSource::System, data)) - .is_err() - { - return Err((id, "emit channel closed".to_string())); + + // Sourced emit: hook-originated events (shell/git/ssh) and + // sibling bread* app events both go through the same + // RawEvent -> normalizer pipeline as in-process adapters, + // instead of being tagged System. `source` is restricted to + // the fixed hook-fed set plus registered app ids — allowing + // arbitrary sources here would let any socket client spoof + // e.g. a power/bluetooth event, or another app's namespace. + if let Some(source_str) = req.params.get("source").and_then(Value::as_str) { + let source = match source_str { + "terminal" => AdapterSource::Terminal, + "git" => AdapterSource::Git, + "remote" => AdapterSource::Remote, + other if is_known_app(other) => AdapterSource::App(other.to_string()), + other => { + return Err(( + id, + format!("source '{other}' is not externally injectable"), + )); + } + }; + let Some(kind) = req.params.get("kind").and_then(Value::as_str) else { + return Err((id, "missing kind for sourced emit".to_string())); + }; + // For a sibling-app source, `kind` is the full dotted event + // name (e.g. "bread.clip.copied"), not a bare suffix — it + // must live inside that app's own namespace. + if let AdapterSource::App(app) = &source { + if !validate_app_namespace(app, kind) { + return Err(( + id, + format!( + "event '{kind}' is not in the '{app}' namespace (must start with 'bread.{app}.')" + ), + )); + } + } + if self + .raw_tx + .send(RawEvent { + source, + kind: kind.to_string(), + payload: data, + timestamp: now_unix_ms(), + }) + .await + .is_err() + { + return Err((id, "raw channel closed".to_string())); + } + Ok(json!({ "emitted": true })) + } else { + let Some(event) = req.params.get("event").and_then(Value::as_str) else { + return Err((id, "missing event name".to_string())); + }; + if self + .emit_tx + .send(BreadEvent::new(event, AdapterSource::System, data)) + .is_err() + { + return Err((id, "emit channel closed".to_string())); + } + Ok(json!({ "emitted": true })) } - Ok(json!({ "emitted": true })) } "health" => { let uptime_ms = self.started_at.elapsed().as_millis(); @@ -278,6 +345,7 @@ impl Server { "ok": true, "pid": self.pid, "version": env!("CARGO_PKG_VERSION"), + "api_version": API_VERSION, "uptime_ms": uptime_ms, "socket": self.socket_path.to_string_lossy(), "adapters": adapters, diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index 6d3a4a5..43ed598 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -20,7 +20,9 @@ use tracing::{error, info, warn}; use crate::core::config::{Config, ModulesConfig, NotificationsConfig}; use crate::core::state_engine::StateHandle; use crate::core::subscriptions::SubscriptionId; -use crate::core::types::{DeviceRule, MatchCondition, ModuleLoadState, RuntimeState}; +use crate::core::types::{ + DeviceRule, MatchCondition, ModuleLoadState, RuntimeState, WorkflowState, WorkflowStatus, +}; use bread_shared::now_unix_ms; pub enum LuaMessage { @@ -1006,6 +1008,7 @@ impl LuaEngine { globals.set("bread", bread)?; self.install_require_loader()?; self.install_wait_helper()?; + self.install_workflow_helpers()?; self.install_log_helpers()?; self.install_debounce()?; Ok(()) @@ -1135,10 +1138,7 @@ impl LuaEngine { let (ordered, dep_errors) = order_module_decls(decls); - let mut decl_map = self - .module_decls - .lock() - .unwrap_or_else(|e| e.into_inner()); + let mut decl_map = self.module_decls.lock().unwrap_or_else(|e| e.into_inner()); decl_map.clear(); for decl in &ordered { decl_map.insert(decl.name.clone(), decl.clone()); @@ -1173,10 +1173,7 @@ impl LuaEngine { } } - *self - .module_order - .lock() - .unwrap_or_else(|e| e.into_inner()) = load_order; + *self.module_order.lock().unwrap_or_else(|e| e.into_inner()) = load_order; Ok(()) } @@ -1685,6 +1682,359 @@ impl LuaEngine { .exec()?; Ok(()) } + + /// `bread.workflow` (define/start/step/status/list) and the multi-condition + /// wait helpers `bread.wait_any`/`bread.wait_all`. Composition (spawning, + /// yielding, timeouts) is plain Lua built on the existing `bread.spawn`/ + /// `bread.on`/`bread.once`/`bread.after`/`bread.cancel`/`bread.off` + /// primitives from [`install_wait_helper`](Self::install_wait_helper) — + /// mirroring how that method itself works. Only the *introspectable + /// status* piece needs a Rust host bridge (the `__workflow_*` functions + /// below), since `workflows.list` is served over IPC from the async side + /// while the workflow body runs as a Lua coroutine on this dedicated Lua + /// thread; both sides read/write the same `Arc>` + /// that `module_store_get`/`module_store_set` already use for exactly + /// this kind of cross-thread bridging. + fn install_workflow_helpers(&self) -> Result<()> { + let globals = self.lua.globals(); + let bread: Table = globals.get("bread")?; + + let state_arc = self.state_handle.state_arc(); + let register_fn = self.lua.create_function(move |_lua, name: String| { + workflow_register(&state_arc, &name); + Ok(()) + })?; + bread.set("__workflow_register", register_fn)?; + + let state_arc = self.state_handle.state_arc(); + let step_fn = self + .lua + .create_function(move |_lua, (name, label): (String, String)| { + workflow_step(&state_arc, &name, &label); + Ok(()) + })?; + bread.set("__workflow_step", step_fn)?; + + let state_arc = self.state_handle.state_arc(); + let finish_fn = self.lua.create_function(move |_lua, name: String| { + workflow_finish(&state_arc, &name); + Ok(()) + })?; + bread.set("__workflow_finish", finish_fn)?; + + let state_arc = self.state_handle.state_arc(); + let fail_fn = self + .lua + .create_function(move |_lua, (name, error): (String, String)| { + workflow_fail(&state_arc, &name, &error); + Ok(()) + })?; + bread.set("__workflow_fail", fail_fn)?; + + let state_arc = self.state_handle.state_arc(); + let timeout_fn = self.lua.create_function(move |_lua, name: String| { + workflow_timeout(&state_arc, &name); + Ok(()) + })?; + bread.set("__workflow_timeout", timeout_fn)?; + + let state_arc = self.state_handle.state_arc(); + let status_fn = self.lua.create_function(move |lua, name: String| { + match workflow_status_json(&state_arc, &name) { + Some(json) => lua + .to_value(&json) + .map_err(|e| LuaError::external(e.to_string())), + None => Ok(Value::Nil), + } + })?; + bread.set("__workflow_status", status_fn)?; + + let state_arc = self.state_handle.state_arc(); + let list_fn = self.lua.create_function(move |lua, ()| { + let json = workflow_list_json(&state_arc); + lua.to_value(&json) + .map_err(|e| LuaError::external(e.to_string())) + })?; + bread.set("__workflow_list", list_fn)?; + + self.lua + .load( + r#" + bread.wait_any = function(patterns, opts) + if type(patterns) ~= "table" then + error("bread.wait_any requires a table of patterns") + end + opts = opts or {} + local co = coroutine.running() + if not co then + error("bread.wait_any must be called inside a coroutine") + end + local ids = {} + local timer + local resumed = false + local function cleanup() + for _, id in ipairs(ids) do + bread.off(id) + end + if timer then + bread.cancel(timer) + end + end + for _, pattern in ipairs(patterns) do + local id = bread.once(pattern, function(event) + if resumed then return end + resumed = true + cleanup() + coroutine.resume(co, event, pattern) + end) + table.insert(ids, id) + end + if opts.timeout then + timer = bread.after(opts.timeout, function() + if resumed then return end + resumed = true + cleanup() + coroutine.resume(co, nil, nil) + end) + end + return coroutine.yield() + end + + bread.wait_all = function(patterns, opts) + if type(patterns) ~= "table" then + error("bread.wait_all requires a table of patterns") + end + opts = opts or {} + local co = coroutine.running() + if not co then + error("bread.wait_all must be called inside a coroutine") + end + local remaining = {} + local count = 0 + for _, p in ipairs(patterns) do + if remaining[p] == nil then + remaining[p] = true + count = count + 1 + end + end + local results = {} + local got = 0 + local timer + local resumed = false + local ids = {} + local function finish(timed_out) + if resumed then return end + resumed = true + for _, id in ipairs(ids) do + bread.off(id) + end + if timer then + bread.cancel(timer) + end + if timed_out then + results.timed_out = true + end + coroutine.resume(co, results) + end + for _, pattern in ipairs(patterns) do + local id = bread.once(pattern, function(event) + if remaining[pattern] then + remaining[pattern] = nil + results[pattern] = event + got = got + 1 + if got >= count then + finish(false) + end + end + end) + table.insert(ids, id) + end + if opts.timeout then + timer = bread.after(opts.timeout, function() + finish(true) + end) + end + return coroutine.yield() + end + + bread.workflow = {} + local __workflow_bodies = {} + local __co_to_workflow = setmetatable({}, { __mode = "k" }) + + bread.workflow.define = function(name, fn) + if type(name) ~= "string" then + error("bread.workflow.define requires a name string") + end + __workflow_bodies[name] = fn + end + + bread.workflow.start = function(name, opts) + local fn = __workflow_bodies[name] + if not fn then + error("bread.workflow.start: no workflow defined with name '" .. tostring(name) .. "'") + end + opts = opts or {} + + bread.__workflow_register(name) + + local deadline_timer + if opts.deadline then + deadline_timer = bread.after(opts.deadline, function() + bread.__workflow_timeout(name) + end) + end + + local co = coroutine.create(function() + local ok, err = pcall(fn, opts.args) + if deadline_timer then + bread.cancel(deadline_timer) + end + if ok then + bread.__workflow_finish(name) + else + bread.__workflow_fail(name, tostring(err)) + end + end) + __co_to_workflow[co] = name + + local ok, err = coroutine.resume(co) + if not ok then + bread.__workflow_fail(name, tostring(err)) + end + end + + bread.workflow.step = function(label) + local co = coroutine.running() + local name = co and __co_to_workflow[co] + if not name then + error("bread.workflow.step must be called inside a running workflow body") + end + bread.__workflow_step(name, label) + end + + bread.workflow.status = function(name) + return bread.__workflow_status(name) + end + + bread.workflow.list = function() + return bread.__workflow_list() + end + "#, + ) + .exec()?; + Ok(()) + } +} + +fn workflow_register(state_arc: &Arc>, name: &str) { + let mut guard = loop { + if let Ok(g) = state_arc.try_write() { + break g; + } + std::hint::spin_loop(); + std::thread::yield_now(); + }; + let now = now_unix_ms(); + if let Some(entry) = guard.workflows.iter_mut().find(|w| w.name == name) { + entry.state = WorkflowState::Running; + entry.step = None; + entry.started_at = now; + entry.updated_at = now; + entry.error = None; + } else { + guard.workflows.push(WorkflowStatus { + name: name.to_string(), + state: WorkflowState::Running, + step: None, + started_at: now, + updated_at: now, + error: None, + }); + } +} + +fn workflow_step(state_arc: &Arc>, name: &str, label: &str) { + let mut guard = loop { + if let Ok(g) = state_arc.try_write() { + break g; + } + std::hint::spin_loop(); + std::thread::yield_now(); + }; + if let Some(entry) = guard.workflows.iter_mut().find(|w| w.name == name) { + entry.step = Some(label.to_string()); + entry.updated_at = now_unix_ms(); + } +} + +fn workflow_finish(state_arc: &Arc>, name: &str) { + let mut guard = loop { + if let Ok(g) = state_arc.try_write() { + break g; + } + std::hint::spin_loop(); + std::thread::yield_now(); + }; + if let Some(entry) = guard.workflows.iter_mut().find(|w| w.name == name) { + entry.state = WorkflowState::Done; + entry.updated_at = now_unix_ms(); + } +} + +fn workflow_fail(state_arc: &Arc>, name: &str, error: &str) { + let mut guard = loop { + if let Ok(g) = state_arc.try_write() { + break g; + } + std::hint::spin_loop(); + std::thread::yield_now(); + }; + if let Some(entry) = guard.workflows.iter_mut().find(|w| w.name == name) { + entry.state = WorkflowState::Failed; + entry.error = Some(error.to_string()); + entry.updated_at = now_unix_ms(); + } +} + +fn workflow_timeout(state_arc: &Arc>, name: &str) { + let mut guard = loop { + if let Ok(g) = state_arc.try_write() { + break g; + } + std::hint::spin_loop(); + std::thread::yield_now(); + }; + if let Some(entry) = guard.workflows.iter_mut().find(|w| w.name == name) { + // Don't clobber a workflow that already reached a terminal state + // between the deadline firing and this callback running. + if entry.state == WorkflowState::Running { + entry.state = WorkflowState::TimedOut; + entry.updated_at = now_unix_ms(); + } + } +} + +fn workflow_status_json(state_arc: &Arc>, name: &str) -> Option { + let guard = loop { + if let Ok(g) = state_arc.try_read() { + break g; + } + std::hint::spin_loop(); + std::thread::yield_now(); + }; + let entry = guard.workflows.iter().find(|w| w.name == name)?; + serde_json::to_value(entry).ok() +} + +fn workflow_list_json(state_arc: &Arc>) -> JsonValue { + let guard = loop { + if let Ok(g) = state_arc.try_read() { + break g; + } + std::hint::spin_loop(); + std::thread::yield_now(); + }; + serde_json::to_value(&guard.workflows).unwrap_or_else(|_| JsonValue::Array(vec![])) } fn order_module_decls(decls: Vec) -> (Vec, Vec<(String, String)>) { @@ -2312,7 +2662,9 @@ where .build() { Ok(rt) => rt.block_on(factory()), - Err(e) => Err(anyhow::anyhow!("bluetooth query: failed to build tokio runtime: {e}")), + Err(e) => Err(anyhow::anyhow!( + "bluetooth query: failed to build tokio runtime: {e}" + )), }; let _ = tx.send(result); }); diff --git a/breadd/src/main.rs b/breadd/src/main.rs index e445773..8ca1c67 100644 --- a/breadd/src/main.rs +++ b/breadd/src/main.rs @@ -80,6 +80,7 @@ async fn main() -> Result<()> { }); } + let ipc_raw_tx = raw_tx.clone(); let adapter_manager = adapters::Manager::new(raw_tx, config.clone(), shutdown_rx.clone()); adapter_manager.start_all().await?; @@ -111,6 +112,7 @@ async fn main() -> Result<()> { event_stream_tx, lua_runtime.clone(), normalized_tx, + ipc_raw_tx, adapter_status, subscription_count, event_buffer, diff --git a/breadd/tests/ipc_integration.rs b/breadd/tests/ipc_integration.rs index 4af40a9..7e7129b 100644 --- a/breadd/tests/ipc_integration.rs +++ b/breadd/tests/ipc_integration.rs @@ -99,6 +99,138 @@ async fn emit_without_event_errors() -> Result<()> { Ok(()) } +#[tokio::test] +async fn emit_with_internal_source_is_rejected() -> Result<()> { + let harness = TestHarness::spawn()?; + harness.wait_until_ready().await?; + + // "power" is a real internal AdapterSource — a socket client must not be + // able to spoof it via sourced emit. + let result = harness + .send_request( + "emit", + json!({ "source": "power", "kind": "ac.connected", "data": {} }), + ) + .await; + assert!( + result.is_err(), + "spoofing an internal source must be rejected" + ); + let msg = result.err().unwrap().to_string(); + assert!(msg.contains("not externally injectable"), "got: {msg}"); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn emit_with_unregistered_app_source_is_rejected() -> Result<()> { + let harness = TestHarness::spawn()?; + harness.wait_until_ready().await?; + + let result = harness + .send_request( + "emit", + json!({ "source": "notanapp", "kind": "bread.notanapp.thing", "data": {} }), + ) + .await; + assert!(result.is_err(), "unregistered app id must be rejected"); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn emit_with_known_app_source_routes_through_normalizer() -> 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-app", + "method": "events.subscribe", + "params": { "filter": "bread.clip.**" } + }); + write_half + .write_all(format!("{}\n", serde_json::to_string(&subscribe)?).as_bytes()) + .await?; + + let mut reader = BufReader::new(read_half).lines(); + reader + .next_line() + .await? + .ok_or_else(|| anyhow!("missing subscribe ack"))?; + + harness + .send_request( + "emit", + json!({ + "source": "clip", + "kind": "bread.clip.copied", + "data": { "kind": "url", "len": 42 } + }), + ) + .await?; + + let deadline = Instant::now() + Duration::from_secs(5); + let mut received: Option = None; + while Instant::now() < deadline { + let Some(line) = reader.next_line().await? else { + break; + }; + let event: Value = serde_json::from_str(&line)?; + if event.get("event").and_then(Value::as_str) == Some("bread.clip.copied") { + received = Some(event); + break; + } + } + + let event = received.expect("did not receive bread.clip.copied on the stream"); + assert_eq!( + event + .get("source") + .and_then(|s| s.get("app")) + .and_then(Value::as_str), + Some("clip") + ); + assert_eq!( + event.get("data").and_then(|d| d.get("len")), + Some(&json!(42)) + ); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn emit_with_app_source_rejects_wrong_namespace() -> Result<()> { + let harness = TestHarness::spawn()?; + harness.wait_until_ready().await?; + + // "clip" is a registered app id, but the event name belongs to "pad" — + // an app may only publish within its own namespace segment. + let result = harness + .send_request( + "emit", + json!({ + "source": "clip", + "kind": "bread.pad.reminder.due", + "data": {} + }), + ) + .await; + assert!( + result.is_err(), + "cross-app namespace claim must be rejected" + ); + let msg = result.err().unwrap().to_string(); + assert!(msg.contains("namespace"), "got: {msg}"); + + harness.shutdown(); + Ok(()) +} + #[tokio::test] async fn state_get_returns_specific_subtree() -> Result<()> { let harness = TestHarness::spawn()?; @@ -389,6 +521,129 @@ async fn events_stream_receives_emitted_events() -> Result<()> { Ok(()) } +#[tokio::test] +async fn workflow_reaches_done_via_wait_any_happy_path() -> Result<()> { + let harness = TestHarness::spawn_with_init( + r#" + bread.workflow.define("test-flow", function() + bread.workflow.step("started") + local event = bread.wait_any({"bread.test.a", "bread.test.b"}, { timeout = 5000 }) + bread.workflow.step("waited") + if not event then + error("did not receive expected event") + end + end) + + bread.on("bread.test.trigger", function() + bread.workflow.start("test-flow") + end) + "#, + )?; + harness.wait_until_ready().await?; + + harness + .send_request("emit", json!({ "event": "bread.test.trigger", "data": {} })) + .await?; + // Give the workflow a moment to register and reach the wait_any point + // before firing the event it's blocked on. + sleep(Duration::from_millis(200)).await; + harness + .send_request("emit", json!({ "event": "bread.test.a", "data": {} })) + .await?; + + let status = poll_workflow_status(&harness, "test-flow", "done").await?; + assert_eq!(status.get("step").and_then(Value::as_str), Some("waited")); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn workflow_times_out_when_deadline_exceeded() -> Result<()> { + let harness = TestHarness::spawn_with_init( + r#" + bread.workflow.define("timeout-flow", function() + bread.workflow.step("waiting") + bread.wait("bread.test.never") + bread.workflow.step("unreachable") + end) + + bread.on("bread.test.trigger", function() + bread.workflow.start("timeout-flow", { deadline = 300 }) + end) + "#, + )?; + harness.wait_until_ready().await?; + + harness + .send_request("emit", json!({ "event": "bread.test.trigger", "data": {} })) + .await?; + + let status = poll_workflow_status(&harness, "timeout-flow", "timed_out").await?; + assert_eq!(status.get("step").and_then(Value::as_str), Some("waiting")); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn workflow_captures_error_on_failure() -> Result<()> { + let harness = TestHarness::spawn_with_init( + r#" + bread.workflow.define("fail-flow", function() + bread.workflow.step("about-to-fail") + error("boom") + end) + + bread.on("bread.test.trigger", function() + bread.workflow.start("fail-flow") + end) + "#, + )?; + harness.wait_until_ready().await?; + + harness + .send_request("emit", json!({ "event": "bread.test.trigger", "data": {} })) + .await?; + + let status = poll_workflow_status(&harness, "fail-flow", "failed").await?; + let error = status + .get("error") + .and_then(Value::as_str) + .unwrap_or_default(); + assert!(error.contains("boom"), "got error: {error}"); + + harness.shutdown(); + Ok(()) +} + +/// Polls `workflows.list` until `name` is present with `expected_state`, or +/// times out after 5 seconds. Returns the matching entry. +async fn poll_workflow_status( + harness: &TestHarness, + name: &str, + expected_state: &str, +) -> Result { + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + let list = harness.send_request("workflows.list", json!({})).await?; + if let Some(entries) = list.as_array() { + if let Some(entry) = entries + .iter() + .find(|e| e.get("name").and_then(Value::as_str) == Some(name)) + { + if entry.get("state").and_then(Value::as_str) == Some(expected_state) { + return Ok(entry.clone()); + } + } + } + sleep(Duration::from_millis(50)).await; + } + Err(anyhow!( + "workflow '{name}' did not reach state '{expected_state}' in time" + )) +} + struct TestHarness { _temp: TempDir, child: Child, @@ -397,6 +652,10 @@ struct TestHarness { impl TestHarness { fn spawn() -> Result { + Self::spawn_with_init("bread.on('bread.system.startup', function() end)\n") + } + + fn spawn_with_init(init_lua: &str) -> Result { let temp = tempfile::tempdir()?; let runtime_dir = temp.path().join("runtime"); let config_home = temp.path().join("config"); @@ -408,10 +667,7 @@ impl TestHarness { let bread_cfg = config_home.join("bread"); fs::create_dir_all(bread_cfg.join("modules"))?; - fs::write( - bread_cfg.join("init.lua"), - "bread.on('bread.system.startup', function() end)\n", - )?; + fs::write(bread_cfg.join("init.lua"), init_lua)?; fs::write( bread_cfg.join("breadd.toml"), diff --git a/examples/modules/dock-workflow.lua b/examples/modules/dock-workflow.lua new file mode 100644 index 0000000..4a1bba2 --- /dev/null +++ b/examples/modules/dock-workflow.lua @@ -0,0 +1,53 @@ +-- dock-workflow — a worked example of bread's workflow engine +-- (bread.workflow + bread.wait_any), not just a single-event reaction like +-- the other examples in this directory. +-- +-- Scenario: when a dock is connected, apply a monitor layout, wait (with a +-- timeout) for Hyprland to actually report a new monitor, activate a +-- "docked" profile, wait for the workspace to settle, then notify. Each +-- step is recorded via bread.workflow.step() so `bread workflows` (or the +-- `workflows.list` IPC method) can show exactly where a run is — useful for +-- diagnosing a dock that isn't behaving, since you can see whether it got +-- stuck waiting for the monitor or the workspace change. +-- +-- Drop-in: copy into ~/.config/bread/modules/ and adjust the dock device +-- name (see devices.lua) and profile name for your setup. + +local M = bread.module({ name = "dock-workflow", version = "1.0.0" }) + +bread.workflow.define("dock-connected", function() + bread.workflow.step("applying layout") + bread.hyprland.keyword("monitor", "HDMI-A-1, preferred, 1920x0, 1") + + bread.workflow.step("waiting for monitor") + local event = bread.wait_any( + { "bread.monitor.connected", "bread.hyprland.event" }, + { timeout = 5000 } + ) + if not event then + -- Hyprland never reported the new monitor within 5s — leave a + -- breadcrumb rather than silently continuing as if it worked. + bread.warn("dock-workflow: timed out waiting for monitor to appear") + error("monitor did not appear in time") + end + + bread.workflow.step("activating profile") + bread.profile.activate("docked") + + bread.workflow.step("waiting for workspace") + bread.wait("bread.workspace.changed", { timeout = 3000 }) + + bread.workflow.step("notifying") + bread.notify("Dock connected", { title = "bread" }) +end) + +function M.on_load() + bread.on("bread.device.dock.connected", function() + -- deadline covers the whole workflow, independent of each step's + -- own wait timeout — a safety net in case something hangs + -- unexpectedly rather than failing cleanly. + bread.workflow.start("dock-connected", { deadline = 15000 }) + end) +end + +return M diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 64b0933..b3af1c0 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -1,11 +1,11 @@ -# Maintainer: Breadway +# Maintainer: Breadway pkgname=bread pkgver=0.6.6 pkgrel=1 pkgdesc="A reactive automation fabric for Linux desktops" arch=('x86_64') -url="https://github.com/Breadway/bread" +url="https://git.breadway.dev/Breadway/bread" license=('MIT') # mlua builds Lua from vendored C source. makepkg's default -flto=auto would # emit GCC LTO bitcode into liblua5.4.a, which the Rust (lld) link can't read,