Compare commits
12 commits
v0.8.0-rc.
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c97029f654 | ||
|
|
1fce1979c3 | ||
|
|
72afe790fd | ||
|
|
e073a17353 | ||
|
|
3865327c66 | ||
|
|
670cf22f2c | ||
|
|
cdd5de8f58 | ||
|
|
6063eb901c | ||
|
|
d3517d1433 | ||
|
|
a6973360bd | ||
|
|
da889d6f6a | ||
|
|
81fbc46e4f |
99 changed files with 114353 additions and 34685 deletions
24
.forgejo/workflows/check.yml
Normal file
24
.forgejo/workflows/check.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
name: check
|
||||||
|
|
||||||
|
# Fast-fail lint/test on short-lived work branches, before it ever reaches
|
||||||
|
# main and triggers a dev-track release build.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['feature/**', 'fix/**']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: [self-hosted, hestia]
|
||||||
|
steps:
|
||||||
|
- name: checkout
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf src && mkdir src
|
||||||
|
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
|
||||||
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||||
|
|
||||||
|
- name: clippy
|
||||||
|
run: cd src && bash ci/build.sh cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||||
|
|
||||||
|
- name: test
|
||||||
|
run: cd src && bash ci/build.sh cargo test --workspace --locked
|
||||||
|
|
@ -18,11 +18,18 @@ jobs:
|
||||||
git clone --branch main --depth 1 \
|
git clone --branch main --depth 1 \
|
||||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||||
|
|
||||||
|
# Fails fast, before the expensive release build/test below, on any
|
||||||
|
# drift between api-schema.toml, the actual bread.*/IPC/CLI surface,
|
||||||
|
# Documentation.md, and README.md — see api-schema.toml's header and
|
||||||
|
# CONTRIBUTING.md's "Keeping the API docs honest" section.
|
||||||
|
- name: check-docs
|
||||||
|
run: cd src && cargo run -p xtask --locked -- check-docs
|
||||||
|
|
||||||
- name: build
|
- name: build
|
||||||
run: cd src && cargo build --release --locked
|
run: cd src && bash ci/build.sh cargo build --release --locked
|
||||||
|
|
||||||
- name: test
|
- name: test
|
||||||
run: cd src && cargo test --release --locked --workspace
|
run: cd src && bash ci/build.sh cargo test --release --locked --workspace
|
||||||
|
|
||||||
# breadd/Cargo.toml is the canonical version source (this workspace has
|
# breadd/Cargo.toml is the canonical version source (this workspace has
|
||||||
# no shared workspace.package.version — breadd and bread-cli are kept
|
# no shared workspace.package.version — breadd and bread-cli are kept
|
||||||
|
|
@ -54,7 +61,7 @@ jobs:
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
PKG_DIR="/srv/breadway-dl/dev/bread/${VERSION}"
|
PKG_DIR="/srv/breadway-dl/dev/bread/${VERSION}"
|
||||||
mkdir -p "${PKG_DIR}"
|
mkdir -p "${PKG_DIR}"
|
||||||
for bin in breadd bread; do
|
for bin in breadd bread bread-emit bread-module-host; do
|
||||||
cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64"
|
cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64"
|
||||||
strip "${PKG_DIR}/${bin}-x86_64"
|
strip "${PKG_DIR}/${bin}-x86_64"
|
||||||
sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \
|
sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,10 @@ jobs:
|
||||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||||
|
|
||||||
- name: build
|
- name: build
|
||||||
run: cd src && cargo build --release --locked
|
run: cd src && bash ci/build.sh cargo build --release --locked
|
||||||
|
|
||||||
- name: test
|
- name: test
|
||||||
run: cd src && cargo test --release --locked --workspace
|
run: cd src && bash ci/build.sh cargo test --release --locked --workspace
|
||||||
|
|
||||||
- name: prepare artifacts
|
- name: prepare artifacts
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -32,7 +32,7 @@ jobs:
|
||||||
VERSION="${GITHUB_REF_NAME#v}"
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
PKG_DIR="/srv/breadway-dl/beta/bread/${VERSION}"
|
PKG_DIR="/srv/breadway-dl/beta/bread/${VERSION}"
|
||||||
mkdir -p "${PKG_DIR}"
|
mkdir -p "${PKG_DIR}"
|
||||||
for bin in breadd bread; do
|
for bin in breadd bread bread-emit bread-module-host; do
|
||||||
cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64"
|
cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64"
|
||||||
strip "${PKG_DIR}/${bin}-x86_64"
|
strip "${PKG_DIR}/${bin}-x86_64"
|
||||||
sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \
|
sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,10 @@ jobs:
|
||||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||||
|
|
||||||
- name: build
|
- name: build
|
||||||
run: cd src && cargo build --release --locked
|
run: cd src && bash ci/build.sh cargo build --release --locked
|
||||||
|
|
||||||
- name: test
|
- name: test
|
||||||
run: cd src && cargo test --release --locked --workspace
|
run: cd src && bash ci/build.sh cargo test --release --locked --workspace
|
||||||
|
|
||||||
- name: prepare artifacts
|
- name: prepare artifacts
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -28,7 +28,7 @@ jobs:
|
||||||
VERSION="${GITHUB_REF_NAME#v}"
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
PKG_DIR="/srv/breadway-dl/bread/${VERSION}"
|
PKG_DIR="/srv/breadway-dl/bread/${VERSION}"
|
||||||
mkdir -p "${PKG_DIR}"
|
mkdir -p "${PKG_DIR}"
|
||||||
for bin in breadd bread; do
|
for bin in breadd bread bread-emit bread-module-host; do
|
||||||
cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64"
|
cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64"
|
||||||
strip "${PKG_DIR}/${bin}-x86_64"
|
strip "${PKG_DIR}/${bin}-x86_64"
|
||||||
sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \
|
sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \
|
||||||
|
|
@ -48,9 +48,13 @@ jobs:
|
||||||
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone)"
|
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone)"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
rm -rf /tmp/bread-ecosystem-ci
|
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
|
||||||
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci
|
# mktemp: a fixed clone path races when multiple repos' release
|
||||||
bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh
|
# workflows run close together on the same self-hosted runner.
|
||||||
|
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
|
||||||
|
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
|
||||||
|
bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
|
||||||
|
rm -rf "${ECOSYSTEM_CI_DIR}"
|
||||||
|
|
||||||
- name: upload to GitHub Release
|
- name: upload to GitHub Release
|
||||||
env:
|
env:
|
||||||
|
|
@ -64,6 +68,10 @@ jobs:
|
||||||
gh release upload "${GITHUB_REF_NAME}" --repo Breadway/bread \
|
gh release upload "${GITHUB_REF_NAME}" --repo Breadway/bread \
|
||||||
"${PKG_DIR}/breadd-x86_64" \
|
"${PKG_DIR}/breadd-x86_64" \
|
||||||
"${PKG_DIR}/bread-x86_64" \
|
"${PKG_DIR}/bread-x86_64" \
|
||||||
|
"${PKG_DIR}/bread-emit-x86_64" \
|
||||||
|
"${PKG_DIR}/bread-module-host-x86_64" \
|
||||||
"${PKG_DIR}/breadd-x86_64.sha256" \
|
"${PKG_DIR}/breadd-x86_64.sha256" \
|
||||||
"${PKG_DIR}/bread-x86_64.sha256" \
|
"${PKG_DIR}/bread-x86_64.sha256" \
|
||||||
|
"${PKG_DIR}/bread-emit-x86_64.sha256" \
|
||||||
|
"${PKG_DIR}/bread-module-host-x86_64.sha256" \
|
||||||
--clobber
|
--clobber
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -36,4 +36,3 @@ DAEMON.md
|
||||||
LUA_RUNTIME.md
|
LUA_RUNTIME.md
|
||||||
CLAUDE_SPEC.md
|
CLAUDE_SPEC.md
|
||||||
.claude
|
.claude
|
||||||
CLAUDE.md
|
|
||||||
|
|
|
||||||
12
.grok/config.toml
Normal file
12
.grok/config.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Copied from .claude/settings.local.json
|
||||||
|
[permission]
|
||||||
|
allow = [
|
||||||
|
"Bash(cargo test *)",
|
||||||
|
"Bash(/home/breadway/Projects/bread/target/debug/bread health *)",
|
||||||
|
"Bash(/home/breadway/Projects/bread/target/debug/bread sync *)",
|
||||||
|
"Bash(python3 *)",
|
||||||
|
"Bash(/home/breadway/Projects/bread/target/debug/bread modules *)",
|
||||||
|
"Bash(/home/breadway/Projects/bread/target/debug/bread doctor *)",
|
||||||
|
"Bash(/home/breadway/Projects/bread/target/debug/bread reload *)",
|
||||||
|
"Bash(cargo build *)",
|
||||||
|
]
|
||||||
42
AGENTS.md
Normal file
42
AGENTS.md
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
# AGENTS.md — Repo hygiene
|
||||||
|
|
||||||
|
This repo follows the branch/release workflow in `CONTRIBUTING.md`.
|
||||||
|
Read that before any git, branch, or release work. Do not invent a
|
||||||
|
different workflow.
|
||||||
|
|
||||||
|
Day-to-day: one long-lived branch (`main`). New work goes on
|
||||||
|
`feature/<name>` or `fix/<name>`, then back into `main`. There is no
|
||||||
|
`dev` or `beta` branch — those names are **bakery tracks**, published
|
||||||
|
from `main` (dev) and from tags (`vX.Y.Z-rc.N` = beta, `vX.Y.Z` = stable).
|
||||||
|
|
||||||
|
API surface, event names, and IPC contracts live in `Documentation.md`
|
||||||
|
(kept honest by `api-schema.toml` + `cargo run -p xtask -- check-docs`).
|
||||||
|
Treat that file, not this one, as the source of truth.
|
||||||
|
|
||||||
|
## Remotes
|
||||||
|
|
||||||
|
- `origin` — Forgejo (`git.breadway.dev`) — authoritative.
|
||||||
|
- `github` — mirror. Push tags/releases per `CONTRIBUTING.md`; day-to-day
|
||||||
|
work pushes to `origin` only.
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
- `dev-release.yml` — push to `main` (includes `check-docs`).
|
||||||
|
- `rc-release.yml` — `vX.Y.Z-rc.N` tag.
|
||||||
|
- `release.yml` — other `v*` tags.
|
||||||
|
|
||||||
|
All three run on the self-hosted hestia runner. Nothing else runs on
|
||||||
|
plain commits or PRs. Distribution is `bakery`, not a PKGBUILD.
|
||||||
|
|
||||||
|
## Local architecture (still true)
|
||||||
|
|
||||||
|
- `breadd` — daemon (adapters → normalizer → state engine → Lua / IPC).
|
||||||
|
- `bread` — CLI over `$XDG_RUNTIME_DIR/bread/breadd.sock`.
|
||||||
|
- `bread-emit` — fire-and-forget IPC emit (hooks + command bus).
|
||||||
|
- `bread-module-host` — sandboxed out-of-process module runtime.
|
||||||
|
- Known-app registry and reserved domains: `bread-shared/src/apps.rs`.
|
||||||
|
|
||||||
|
## Don't
|
||||||
|
|
||||||
|
- Don't embed credentials in remote URLs — SSH or a credential helper only.
|
||||||
|
- Don't commit straight to `main`.
|
||||||
20
CLAUDE.md
Normal file
20
CLAUDE.md
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# CLAUDE.md — Repo hygiene
|
||||||
|
|
||||||
|
This repo follows the branch/release workflow documented in `CONTRIBUTING.md`
|
||||||
|
— read and follow it for any git, branch, or release work here (the
|
||||||
|
dev/beta/main lifecycle, `feature/x`/`fix/x` branch naming, when to cut or
|
||||||
|
reset `beta`, etc). Don't improvise a different workflow.
|
||||||
|
When starting work on a new feature, create branch "feature/<feature-name>" \
|
||||||
|
When working on a bug or issue, create branch "fix/<issue you are fixing>"
|
||||||
|
|
||||||
|
## Remotes
|
||||||
|
- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative.
|
||||||
|
- `github` — GitHub mirror. Push both when publishing.
|
||||||
|
|
||||||
|
## CI
|
||||||
|
- `dev-release.yml` triggers on `push: branches: ['dev']`; `beta-release.yml`
|
||||||
|
on `push: branches: ['beta']`; `release.yml` on a `v*` tag push. `package.yml` triggers the same way for the pacman-channel package.
|
||||||
|
None of these run on plain commits or PRs beyond what's listed.
|
||||||
|
|
||||||
|
## Don't
|
||||||
|
- Don't embed credentials in remote URLs — SSH or a credential helper only.
|
||||||
|
|
@ -69,21 +69,27 @@ cargo test --release --workspace
|
||||||
|
|
||||||
### Keeping the API docs honest
|
### Keeping the API docs honest
|
||||||
|
|
||||||
`Documentation.md`'s Lua API and IPC protocol sections are hand-written and
|
`Documentation.md`'s Lua API/IPC protocol sections and README's CLI
|
||||||
have drifted from the actual code before — there's a checked-in registry,
|
reference are hand-written and have drifted from the actual code before —
|
||||||
`api-schema.toml`, plus an `xtask` checker that catches it happening again.
|
there's a checked-in registry, `api-schema.toml`, plus an `xtask` checker
|
||||||
|
(`cargo run -p xtask -- check-docs`) that catches it happening again, and
|
||||||
|
it's enforced in CI (`dev-release.yml` fails the build on any drift).
|
||||||
|
|
||||||
Whenever you add, rename, or remove a `bread.*` Lua binding
|
Whenever you add, rename, or remove a `bread.*` Lua binding
|
||||||
(`breadd/src/lua/mod.rs`) or an IPC method (`breadd/src/ipc/mod.rs`):
|
(`breadd/src/lua/mod.rs`), an IPC method (`breadd/src/ipc/mod.rs`), or a
|
||||||
|
`bread` CLI command (`bread-cli/src/main.rs`):
|
||||||
|
|
||||||
1. Add/update/remove its entry in `api-schema.toml` to match.
|
1. Add/update/remove its entry in `api-schema.toml` to match.
|
||||||
2. Add/update the corresponding section in `Documentation.md` (a
|
2. Add/update the corresponding section — a `#### bread.<name>` heading in
|
||||||
`#### bread.<name>` heading for a Lua binding, or a row in the IPC
|
`Documentation.md` for a Lua binding, a row in `Documentation.md`'s IPC
|
||||||
Methods table for an IPC method).
|
Methods table for an IPC method, or a `bread <name>` line in `README.md`'s
|
||||||
|
"CLI reference" section for a CLI command.
|
||||||
3. Run `cargo run -p xtask -- check-docs` before committing. It fails with
|
3. Run `cargo run -p xtask -- check-docs` before committing. It fails with
|
||||||
a non-zero exit and a list of exactly what's out of sync — added but
|
a non-zero exit and a list of exactly what's out of sync — added but
|
||||||
undocumented, stale in the schema, or missing a doc heading/row — if
|
undocumented, stale in the schema, or missing a doc line — if
|
||||||
`api-schema.toml`, the code, and `Documentation.md` don't all agree.
|
`api-schema.toml`, the code, `Documentation.md`, and `README.md` don't
|
||||||
|
all agree. CI runs this too, so anything that slips past a local run
|
||||||
|
still fails the build rather than landing on `main`.
|
||||||
|
|
||||||
## CI
|
## CI
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,9 @@ compositor backend — see `Documentation.md`'s
|
||||||
|
|
||||||
**Current behavior:** both names fire by default (`[compat]
|
**Current behavior:** both names fire by default (`[compat]
|
||||||
legacy_hyprland_event_names = true`). Setting that flag to `false` suppresses
|
legacy_hyprland_event_names = true`). Setting that flag to `false` suppresses
|
||||||
the legacy names; only `bread.hyprland.*` fires.
|
the legacy names; only `bread.hyprland.*` fires. The state engine applies
|
||||||
|
*both* names (since v1.7.1), so disabling the legacy emit no longer freezes
|
||||||
|
`bread.state` monitors / active workspace / active window.
|
||||||
|
|
||||||
**Deferred follow-up (not yet scheduled):**
|
**Deferred follow-up (not yet scheduled):**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ The Lua API surface, the IPC method set, the event-name vocabulary, and the runt
|
||||||
- **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.
|
- **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.
|
- **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.
|
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 `AGENTS.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
|
## Getting started
|
||||||
|
|
||||||
|
|
@ -1466,6 +1466,7 @@ Every Hyprland-sourced event below is dual-emitted: the daemon fires both the le
|
||||||
| `bread.window.moved` *(Deprecated: v1.5 — use `bread.hyprland.window.moved`)* | `{ address, workspace }` |
|
| `bread.window.moved` *(Deprecated: v1.5 — use `bread.hyprland.window.moved`)* | `{ address, workspace }` |
|
||||||
| `bread.hyprland.window.moved` *(Since: v1.5)* | `{ address, workspace }` |
|
| `bread.hyprland.window.moved` *(Since: v1.5)* | `{ address, workspace }` |
|
||||||
| `bread.hyprland.event` | `{ kind, raw, data }` (unhandled kinds — already namespaced, not part of this migration) |
|
| `bread.hyprland.event` | `{ kind, raw, data }` (unhandled kinds — already namespaced, not part of this migration) |
|
||||||
|
| `bread.hyprland.snapshot` *(Since: v1.7.1)* | `{ monitors, workspaces, active_workspace, active_window }` — emitted once after the Hyprland event socket connects (and again after a reconnect). `bread.state` applies this event to replace compositor topology so monitors/workspaces/focus are populated before the next live event. Not dual-emitted under a legacy name. |
|
||||||
|
|
||||||
##### Compatibility: `[compat]` config
|
##### Compatibility: `[compat]` config
|
||||||
|
|
||||||
|
|
@ -1588,8 +1589,8 @@ Rides the same shell-hook transport as Terminal events (`bread hooks install she
|
||||||
Two dotted-name segments are reserved, permanent parts of the schema — not one-off conventions:
|
Two dotted-name segments are reserved, permanent parts of the schema — not one-off conventions:
|
||||||
|
|
||||||
- **`bread.<app>.*`** — 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.<app>.*`** — 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.<app>.<verb>`** — 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.
|
- **`bread.command.<app>.<verb>`** — 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. *Since: v1.7 — well-formed `bread.command.<known-app>.<verb>` names (`known-app` ∈ `KNOWN_APPS`, verb a non-empty extra dotted segment) are allowed on the unsourced/`bread-emit` path and via sourced `AdapterSource::App` emit (an app may publish a command to another known app). `BreadClient::command` in bread-utils is the typed helper for the same path. `command` remains in `RESERVED_DOMAINS` so it cannot be claimed as an app id; `bread.command.power.off` and `bread.command.notanapp.x` are still rejected. See [Dictionary: IPC protocol](#dictionary-ipc-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`, `bluetooth`, `workspace`, `window`, `monitor`, `service`, `container`, `project`, `remote`, `system`, `profile`, `notify`, `command`, `workflow`) are reserved and cannot be claimed as app ids. *Since: v1.5 — `bluetooth`, `workspace`, `window`, and `monitor` added to this list (event families the Bluetooth and Hyprland adapters already published under, but that were missing from it); this same list is now also the boundary the IPC `emit` method's no-`source` path checks event names against, see [Dictionary: IPC protocol](#dictionary-ipc-protocol).*
|
- The second dotted segment is drawn from a small known-apps registry (`bread_shared::apps::KNOWN_APPS` in `bread-shared/src/apps.rs`); daemon-internal domains (`terminal`, `git`, `hyprland`, `device`, `power`, `network`, `bluetooth`, `workspace`, `window`, `monitor`, `service`, `container`, `project`, `remote`, `system`, `profile`, `notify`, `command`, `workflow`) are reserved and cannot be claimed as app ids. *Since: v1.5 — `bluetooth`, `workspace`, `window`, and `monitor` added to this list (event families the Bluetooth and Hyprland adapters already published under, but that were missing from it); this same list is now also the boundary the IPC `emit` method's no-`source` path checks event names against, see [Dictionary: IPC protocol](#dictionary-ipc-protocol).*
|
||||||
- **Commands are best-effort.** Publishing `bread.command.<app>.<verb>` 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.<app>.<verb>.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.
|
- **Commands are best-effort.** Publishing `bread.command.<app>.<verb>` 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.<app>.<verb>.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("<cli> ...")`** remains the zero-infrastructure fallback for triggering a sibling app that has a synchronous CLI and no need for a structured response.
|
- **`bread.exec("<cli> ...")`** remains the zero-infrastructure fallback for triggering a sibling app that has a synchronous CLI and no need for a structured response.
|
||||||
|
|
||||||
|
|
@ -1599,10 +1600,11 @@ Two dotted-name segments are reserved, permanent parts of the schema — not one
|
||||||
|
|
||||||
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.
|
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.
|
1. **Register your app id.** Add it to `KNOWN_APPS` in `bread-shared/src/apps.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`:
|
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).
|
- `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.<app_id>.*` 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.emit(event, data)` — publish within your own `bread.<app_id>.*` 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.command(target, verb, data)` — publish `bread.command.<target>.<verb>` to another known app. Same fire-and-forget socket write as `emit`; this is the typed helper for the command-bus path that `bread-emit bread.command.<app>.<verb>` uses. *Since: v1.7 — the daemon actually accepts these on the unsourced and sourced-app emit paths; see [Namespaces](#namespaces).*
|
||||||
- `client.subscribe("bread.command.<app_id>.**", |event| { ... })` — receive commands addressed to you, on a background thread with its own reconnect/backoff loop.
|
- `client.subscribe("bread.command.<app_id>.**", |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).
|
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.<app_id>.<verb>.done` or `.failed` after acting on a `bread.command.<app_id>.<verb>` — 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.
|
4. **Emit confirmations for commands you honor.** `bread.<app_id>.<verb>.done` or `.failed` after acting on a `bread.command.<app_id>.<verb>` — 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.
|
||||||
|
|
@ -1726,7 +1728,7 @@ Available methods:
|
||||||
| `profile.activate` | `name` | Switch active profile |
|
| `profile.activate` | `name` | Switch active profile |
|
||||||
| `events.subscribe` | — | Upgrade to streaming mode; pushes events line by line |
|
| `events.subscribe` | — | Upgrade to streaming mode; pushes events line by line |
|
||||||
| `events.replay` | `since_ms` | Replay buffered events from the last N ms |
|
| `events.replay` | `since_ms` | Replay buffered events from the last N ms |
|
||||||
| `emit` | `event`, `data`, optional `source`, `kind` | Inject an event. Without `source`, builds a `BreadEvent` directly, tagged `Manual` *(Since: v1.5 — previously tagged `System`; see below)*, for manually testing Lua handlers (this is what `bread emit <event>` and `bread-emit` use). 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. |
|
| `emit` | `event`, `data`, optional `source`, `kind` | Inject an event. Without `source`, builds a `BreadEvent` directly, tagged `Manual` *(Since: v1.5 — previously tagged `System`; see below)*, for manually testing Lua handlers (this is what `bread emit <event>` and `bread-emit` use). Well-formed `bread.command.<known-app>.<verb>` is allowed on this path *(Since: v1.7)*; other reserved domains stay rejected. 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. A sourced app may also publish a well-formed command to another known app. 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)* |
|
| `workflows.list` | — | List running/completed workflow instances and their step/status *(Since: v1.2)* |
|
||||||
| `widgets.list` | — | List all registered widgets across every module *(Since: v1.3)* |
|
| `widgets.list` | — | List all registered widgets across every module *(Since: v1.3)* |
|
||||||
|
|
||||||
|
|
@ -1763,4 +1765,6 @@ The `health` response's `api_version` field lets a client — the CLI, a Lua mod
|
||||||
*Since: v1.5 — `emit` without `source` closed a spoofing gap: previously any event name was accepted with zero validation and tagged `System`, the same tag the daemon uses internally for events it originates itself in Rust code (`bread.system.startup`, `bread.profile.activated`, ...). That made a manually-injected event indistinguishable from a trusted, daemon-originated one. Now:*
|
*Since: v1.5 — `emit` without `source` closed a spoofing gap: previously any event name was accepted with zero validation and tagged `System`, the same tag the daemon uses internally for events it originates itself in Rust code (`bread.system.startup`, `bread.profile.activated`, ...). That made a manually-injected event indistinguishable from a trusted, daemon-originated one. Now:*
|
||||||
- *The unsourced path is tagged `AdapterSource::Manual`, not `System` — `System` is reserved for the daemon's own Rust-originated sends and can no longer be produced from data that arrived over the IPC socket.*
|
- *The unsourced path is tagged `AdapterSource::Manual`, not `System` — `System` is reserved for the daemon's own Rust-originated sends and can no longer be produced from data that arrived over the IPC socket.*
|
||||||
- *The event name is rejected if its top-level dotted segment (the part right after `bread.`) is one of the reserved, adapter-owned domains in `bread_shared::apps::RESERVED_DOMAINS` — `terminal`, `git`, `hyprland`, `device`, `power`, `network`, `bluetooth`, `workspace`, `window`, `monitor`, `service`, `container`, `project`, `remote`, `system`, `profile`, `notify`, `command`, `workflow` (see [Namespaces](#namespaces)) — since a socket client emitting e.g. `bread.power.ac.connected` this way would otherwise be indistinguishable from the real power adapter observing it.*
|
- *The event name is rejected if its top-level dotted segment (the part right after `bread.`) is one of the reserved, adapter-owned domains in `bread_shared::apps::RESERVED_DOMAINS` — `terminal`, `git`, `hyprland`, `device`, `power`, `network`, `bluetooth`, `workspace`, `window`, `monitor`, `service`, `container`, `project`, `remote`, `system`, `profile`, `notify`, `command`, `workflow` (see [Namespaces](#namespaces)) — since a socket client emitting e.g. `bread.power.ac.connected` this way would otherwise be indistinguishable from the real power adapter observing it.*
|
||||||
|
- *Since: v1.7 — well-formed `bread.command.<known-app>.<verb>` is an explicit exception to that reserved-domain reject (`command` stays reserved so it cannot be claimed as an app id). `bread.command.clip.clear` is accepted unsourced and as a sourced `AdapterSource::App` emit from another known app; `bread.command.power.off`, `bread.command.notanapp.x`, and `bread.hyprland.*` are still rejected. `API_VERSION` bumped from `1.6.0` to `1.7.0` for this addition.*
|
||||||
|
- *Since: v1.7.1 — the state engine applies both legacy Hyprland names and `bread.hyprland.*` (so flipping `[compat] legacy_hyprland_event_names = false` no longer freezes monitors/workspace/window). `RuntimeState.workspaces` is written on `workspace.created`/`destroyed` and replaced by `bread.hyprland.snapshot`. `API_VERSION` bumped from `1.7.0` to `1.7.1`.*
|
||||||
- *Freely-named custom/test event names (anything outside those reserved domains, including names with no `bread.` prefix at all) remain unrestricted — this is what keeps `bread emit <name>` useful for testing Lua handlers without unplugging cables, and what `bread-emit`'s fire-and-forget, no-reply-wait design still works against unchanged (a single JSON line write is still sufficient; no handshake was added).*
|
- *Freely-named custom/test event names (anything outside those reserved domains, including names with no `bread.` prefix at all) remain unrestricted — this is what keeps `bread emit <name>` useful for testing Lua handlers without unplugging cables, and what `bread-emit`'s fire-and-forget, no-reply-wait design still works against unchanged (a single JSON line write is still sufficient; no handshake was added).*
|
||||||
|
|
|
||||||
43
README.md
43
README.md
|
|
@ -44,8 +44,10 @@ return M
|
||||||
```
|
```
|
||||||
breadd/ Rust daemon — event pipeline, state engine, IPC, adapter supervision
|
breadd/ Rust daemon — event pipeline, state engine, IPC, adapter supervision
|
||||||
bread-cli/ CLI frontend — talks to breadd over a Unix socket
|
bread-cli/ CLI frontend — talks to breadd over a Unix socket
|
||||||
|
bread-emit/ Tiny fire-and-forget IPC emitter (hooks / command bus)
|
||||||
|
bread-module-host/ Out-of-process sandboxed Lua module runtime
|
||||||
bread-shared/ Shared types — RawEvent, BreadEvent, AdapterSource
|
bread-shared/ Shared types — RawEvent, BreadEvent, AdapterSource
|
||||||
packaging/ Arch PKGBUILD and systemd user service
|
packaging/ systemd user service unit (bakery installs this)
|
||||||
```
|
```
|
||||||
|
|
||||||
The daemon is structured in four layers:
|
The daemon is structured in four layers:
|
||||||
|
|
@ -80,7 +82,7 @@ git clone https://git.breadway.dev/Breadway/bread.git
|
||||||
cd bread
|
cd bread
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the install script — it builds, symlinks `breadd` and `bread` into `~/.local/bin` (override with `BIN_DIR=…`), installs the systemd user service, and starts the daemon:
|
Run the install script — it builds, symlinks `breadd`, `bread`, `bread-emit`, and `bread-module-host` into `~/.local/bin` (override with `BIN_DIR=…`), installs the systemd user service, and starts the daemon:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash scripts/install.sh
|
bash scripts/install.sh
|
||||||
|
|
@ -92,13 +94,16 @@ Or step by step (system-wide install):
|
||||||
cargo build --release
|
cargo build --release
|
||||||
sudo install -Dm755 target/release/breadd /usr/bin/breadd
|
sudo install -Dm755 target/release/breadd /usr/bin/breadd
|
||||||
sudo install -Dm755 target/release/bread /usr/bin/bread
|
sudo install -Dm755 target/release/bread /usr/bin/bread
|
||||||
|
sudo install -Dm755 target/release/bread-emit /usr/bin/bread-emit
|
||||||
|
sudo install -Dm755 target/release/bread-module-host /usr/bin/bread-module-host
|
||||||
```
|
```
|
||||||
|
|
||||||
### Arch Linux (PKGBUILD)
|
### Via bakery
|
||||||
|
|
||||||
|
Prebuilt binaries ship through `bakery` (the bread-ecosystem package manager), not a PKGBUILD / pacman package:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd packaging/arch
|
bakery install bread
|
||||||
makepkg -si
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### systemd user service
|
### systemd user service
|
||||||
|
|
@ -215,6 +220,7 @@ bread events bread.device.* # Stream filtered events
|
||||||
bread events --since 60 # Replay events from the last 60 seconds
|
bread events --since 60 # Replay events from the last 60 seconds
|
||||||
bread events --fields event,data # Limit output to specific fields
|
bread events --fields event,data # Limit output to specific fields
|
||||||
bread events --json # Output raw JSON
|
bread events --json # Output raw JSON
|
||||||
|
bread events --tree # Render as a causality tree (caused_by) instead of a flat stream
|
||||||
bread emit <event> # Manually fire an event (for testing)
|
bread emit <event> # Manually fire an event (for testing)
|
||||||
|
|
||||||
# Profiles
|
# Profiles
|
||||||
|
|
@ -226,6 +232,17 @@ bread modules list # List installed modules and daemon status
|
||||||
bread modules install /local/path # Install from a local module directory
|
bread modules install /local/path # Install from a local module directory
|
||||||
bread modules remove <name> # Remove an installed module (--yes skips confirmation)
|
bread modules remove <name> # Remove an installed module (--yes skips confirmation)
|
||||||
bread modules info <name> # Show full manifest and daemon status
|
bread modules info <name> # Show full manifest and daemon status
|
||||||
|
bread modules audit <name> # Scan a module's Lua source and suggest a [[permissions]] block
|
||||||
|
|
||||||
|
# Hooks
|
||||||
|
bread hooks install-shell [shell] # Install precmd/preexec/chpwd shell hooks (auto-detects $SHELL)
|
||||||
|
bread hooks install-git # Install git hooks (post-commit/checkout/merge) in the current repo
|
||||||
|
|
||||||
|
# Compositor integration
|
||||||
|
bread init # Install breadbar's Hyprland layer-rule integration (shows a diff, asks to confirm)
|
||||||
|
bread init --dry-run # Print the proposed diff only, change nothing
|
||||||
|
bread init --yes # Skip the confirmation prompt (scripted/image builds)
|
||||||
|
bread init --undo # Remove the previously installed integration
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -236,10 +253,18 @@ Modules are Lua files (or directories) installed to `~/.config/bread/modules/`.
|
||||||
|
|
||||||
### Installing modules
|
### Installing modules
|
||||||
|
|
||||||
Modules install from a local directory only. Modules run with full
|
Modules install from a local directory only. By default a module runs
|
||||||
`bread.exec()` privileges and are **not** sandboxed, so to use a module
|
in-process with full, ungated `bread.exec()` privileges — the same trust
|
||||||
published on a git host, clone it yourself and review the Lua before
|
model as before — so to use a module published on a git host, clone it
|
||||||
installing from the local checkout:
|
yourself and review the Lua before installing from the local checkout.
|
||||||
|
A module can opt into a smaller, enforced footprint by declaring
|
||||||
|
`[[permissions]]` in its manifest: it then runs out-of-process under an
|
||||||
|
OS-level (Landlock) sandbox limited to exactly what it declared, with a
|
||||||
|
`bread` table that only exposes the granted namespaces. See
|
||||||
|
[Capability-scoped modules](Documentation.md#capability-scoped-modules-since-v15) and
|
||||||
|
[Out-of-process module sandboxing](Documentation.md#out-of-process-module-sandboxing-since-v16)
|
||||||
|
for the full permission taxonomy and what's enforced at the kernel level
|
||||||
|
versus what isn't yet.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/someuser/bread-wifi ~/src/bread-wifi
|
git clone https://github.com/someuser/bread-wifi ~/src/bread-wifi
|
||||||
|
|
|
||||||
155
api-schema.toml
155
api-schema.toml
|
|
@ -1,13 +1,14 @@
|
||||||
# Bread Automation API schema — checked-in source-of-truth registry.
|
# Bread Automation API schema — checked-in source-of-truth registry.
|
||||||
#
|
#
|
||||||
# This is Workstream F (scoped down) from the governance-hardening report:
|
# This is Workstream F from the governance-hardening report: a *drift
|
||||||
# a *drift detector*, not a doc generator. `Documentation.md`'s "Dictionary:
|
# detector*, not a doc generator. `Documentation.md`'s "Dictionary: Lua API"
|
||||||
# Lua API" section is hand-written prose (one `#### bread.<name>(...)`
|
# section is hand-written prose (one `#### bread.<name>(...)` heading per
|
||||||
# heading per binding, with worked examples and edge-case notes) — nothing
|
# binding, with worked examples and edge-case notes) — nothing here
|
||||||
# here regenerates or reformats that prose. Instead, this file is the
|
# regenerates or reformats that prose. Instead, this file is the checked-in
|
||||||
# checked-in list of every `bread.*` Lua binding and IPC method that is
|
# list of every `bread.*` Lua binding, IPC method, and `bread` CLI command
|
||||||
# supposed to exist right now, and `cargo run -p xtask -- check-docs`
|
# that is supposed to exist right now, and `cargo run -p xtask -- check-docs`
|
||||||
# cross-checks it against:
|
# (wired into CI via .forgejo/workflows/dev-release.yml — fails the build on
|
||||||
|
# any drift) cross-checks it against:
|
||||||
#
|
#
|
||||||
# 1. The actual bindings registered in breadd/src/lua/mod.rs
|
# 1. The actual bindings registered in breadd/src/lua/mod.rs
|
||||||
# (`bread.set("name", ...)` calls, the nested `<x>_tbl.set(...)` calls
|
# (`bread.set("name", ...)` calls, the nested `<x>_tbl.set(...)` calls
|
||||||
|
|
@ -18,26 +19,49 @@
|
||||||
# 2. The actual IPC methods dispatched in breadd/src/ipc/mod.rs's
|
# 2. The actual IPC methods dispatched in breadd/src/ipc/mod.rs's
|
||||||
# `match req.method.as_str() { ... }` block, plus the specially-cased
|
# `match req.method.as_str() { ... }` block, plus the specially-cased
|
||||||
# `events.subscribe` streaming upgrade.
|
# `events.subscribe` streaming upgrade.
|
||||||
# 3. Documentation.md itself, to make sure each entry here still has a
|
# 3. The actual `bread` CLI commands declared in bread-cli/src/main.rs's
|
||||||
# `#### bread.<name>` heading (Lua) or a row in the IPC Methods table
|
# `Commands`/`ModulesCommand`/`HooksCommand` enums.
|
||||||
# (IPC methods).
|
# 4. Documentation.md, to make sure each lua_function/lua_table/ipc_method
|
||||||
|
# entry here still has a `#### bread.<name>` heading (Lua) or a row in
|
||||||
|
# the IPC Methods table (IPC methods).
|
||||||
|
# 5. README.md's "CLI reference" section, to make sure each cli_command
|
||||||
|
# entry here still has a `bread <name>` line there. This check exists
|
||||||
|
# because that section drifted from Documentation.md/the real CLI
|
||||||
|
# surface before check-docs covered it at all (missing `modules audit`,
|
||||||
|
# `hooks install-shell`/`install-git`, `events --tree`) — found and
|
||||||
|
# fixed by hand, then closed here so it can't recur silently.
|
||||||
#
|
#
|
||||||
# Whenever you add, rename, or remove a `bread.*` binding or IPC method:
|
# Whenever you add, rename, or remove a `bread.*` binding, an IPC method, or
|
||||||
|
# a `bread` CLI command:
|
||||||
# 1. Update this file to match.
|
# 1. Update this file to match.
|
||||||
# 2. Update/add the corresponding section in Documentation.md.
|
# 2. Update/add the corresponding section in Documentation.md (Lua/IPC)
|
||||||
|
# or README.md's CLI reference (CLI commands).
|
||||||
# 3. Run `cargo run -p xtask -- check-docs` before committing — it fails
|
# 3. Run `cargo run -p xtask -- check-docs` before committing — it fails
|
||||||
# loudly (non-zero exit) if the three are out of sync.
|
# loudly (non-zero exit) if the schema, the code, and the docs are out
|
||||||
|
# of sync. CI runs this too (dev-release.yml), so drift that slips past
|
||||||
|
# a local run still fails the build.
|
||||||
#
|
#
|
||||||
# `kind` is one of: "lua_function", "lua_table", "ipc_method".
|
# `kind` is one of: "lua_function", "lua_table", "ipc_method", "cli_command".
|
||||||
# `since` is the API version (Documentation.md's "API Stability &
|
#
|
||||||
# Versioning" section) the binding/method was introduced in. Anything from
|
# `since` means two different things depending on `kind`, because CLI
|
||||||
# the original v1.0 baseline (no `*(Since: vX.Y)*` marker in Documentation.md)
|
# commands were never part of the Bread Automation API's own versioned
|
||||||
# is listed as "1.0" here.
|
# contract (see Documentation.md's "API Stability & Versioning" section —
|
||||||
|
# it's explicitly scoped to "Lua API surface + IPC methods + event
|
||||||
|
# vocabulary + runtime-state schema", not the CLI):
|
||||||
|
# - lua_function / lua_table / ipc_method: the Bread Automation API
|
||||||
|
# version (breadd/src/ipc/mod.rs's `API_VERSION`) the binding/method was
|
||||||
|
# introduced in. Anything from the original v1.0 baseline (no
|
||||||
|
# `*(Since: vX.Y)*` marker in Documentation.md) is listed as "1.0" here.
|
||||||
|
# - cli_command: the `bread`/`breadd` package version (the workspace
|
||||||
|
# crates' `Cargo.toml` `version`, kept in lockstep — see CONTRIBUTING.md)
|
||||||
|
# the command was introduced in. Pre-existing commands as of this
|
||||||
|
# registry's creation are listed as "0.7" (the release before this one);
|
||||||
|
# no attempt was made to date them more precisely than that.
|
||||||
#
|
#
|
||||||
# Format chosen: a single checked-in TOML file (this is the "a schema file
|
# Format chosen: a single checked-in TOML file (this is the "a schema file
|
||||||
# that's checked and diffed against the actual API surface, and CI fails the
|
# that's checked and diffed against the actual API surface, and CI fails the
|
||||||
# build if they drift" option the source report names, as opposed to Rust
|
# build if they drift" option the source report names, as opposed to Rust
|
||||||
# attribute macros — overkill for a ~30-binding surface with no existing
|
# attribute macros — overkill for a ~45-entry surface with no existing
|
||||||
# proc-macro infrastructure in this workspace). TOML specifically because
|
# proc-macro infrastructure in this workspace). TOML specifically because
|
||||||
# `toml = "0.8"` is already a dependency of breadd/bread-cli/bread-shared
|
# `toml = "0.8"` is already a dependency of breadd/bread-cli/bread-shared
|
||||||
# (see breadd/src/core/config.rs, bread-cli/src/modules_mgmt.rs) — no new
|
# (see breadd/src/core/config.rs, bread-cli/src/modules_mgmt.rs) — no new
|
||||||
|
|
@ -498,3 +522,94 @@ since = "1.2"
|
||||||
name = "widgets.list"
|
name = "widgets.list"
|
||||||
kind = "ipc_method"
|
kind = "ipc_method"
|
||||||
since = "1.3"
|
since = "1.3"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI commands (bread-cli/src/main.rs) — checked against README.md's "CLI
|
||||||
|
# reference" section, not Documentation.md (which has no CLI section of its
|
||||||
|
# own). `since` here is the package version, not the API_VERSION — see this
|
||||||
|
# file's header.
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "reload"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.7"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "state"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.7"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "events"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.7"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "modules.list"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.7"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "modules.install"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.7"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "modules.remove"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.7"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "modules.info"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.7"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "modules.audit"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.8"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "hooks.install-shell"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.7"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "hooks.install-git"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.7"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "profile-list"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.7"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "profile-activate"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.7"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "emit"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.7"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "ping"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.7"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "health"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.7"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "doctor"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.7"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "init"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.8"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
name = "bread"
|
name = "bread"
|
||||||
description = "Reactive automation daemon and CLI for Linux desktops"
|
description = "Reactive automation daemon and CLI for Linux desktops"
|
||||||
binaries = ["breadd", "bread"]
|
binaries = ["breadd", "bread", "bread-emit", "bread-module-host"]
|
||||||
system_deps = ["systemd-libs", "openssl", "zlib"]
|
system_deps = ["systemd-libs", "openssl", "zlib"]
|
||||||
optional_system_deps = ["bluez", "hyprland"]
|
optional_system_deps = ["bluez", "hyprland"]
|
||||||
bread_deps = []
|
bread_deps = []
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,20 @@
|
||||||
//! user later doesn't want it, an rc-file edit is much harder to notice and
|
//! 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.
|
//! undo than a printed snippet they chose to paste in.
|
||||||
//!
|
//!
|
||||||
|
//! `bread init` (`init.rs`) looks like it breaks this rule — it does
|
||||||
|
//! propose an edit to a user's own `hyprland.lua` — but it's a narrower,
|
||||||
|
//! consent-gated exception, not a reversal of it. The difference is what's
|
||||||
|
//! silent about the failure mode: a missing shell hook is invisible
|
||||||
|
//! enrichment (no telemetry, nothing looks wrong), so leaving it to the
|
||||||
|
//! user to paste in at their own pace is the right default. A missing
|
||||||
|
//! compositor layer rule is not invisible — breadbar renders unblurred and
|
||||||
|
//! with the wrong opacity, which reads as "this app is broken," not
|
||||||
|
//! "optional feature not enabled," and there's no snippet a user could be
|
||||||
|
//! expected to reverse-engineer for that. `init.rs` earns the edit by
|
||||||
|
//! showing the exact diff, requiring explicit consent (prompt, `--yes`, or
|
||||||
|
//! nothing at all without a TTY), backing up the file first, and shipping
|
||||||
|
//! a real `--undo`. See `init.rs`'s module docs for the full rationale.
|
||||||
|
//!
|
||||||
//! # Why bread-emit, not `bread emit`
|
//! # Why bread-emit, not `bread emit`
|
||||||
//!
|
//!
|
||||||
//! The generated hooks shell out to the separate `bread-emit` binary
|
//! The generated hooks shell out to the separate `bread-emit` binary
|
||||||
|
|
|
||||||
1281
bread-cli/src/init.rs
Normal file
1281
bread-cli/src/init.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +1,4 @@
|
||||||
|
mod init;
|
||||||
mod hooks_git;
|
mod hooks_git;
|
||||||
mod hooks_shell;
|
mod hooks_shell;
|
||||||
mod modules_mgmt;
|
mod modules_mgmt;
|
||||||
|
|
@ -99,6 +100,22 @@ enum Commands {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
json: bool,
|
json: bool,
|
||||||
},
|
},
|
||||||
|
/// Install bread's Hyprland compositor layer-rule integration
|
||||||
|
/// (breadbar/breadbox blur, ignore_alpha, animation) into
|
||||||
|
/// ~/.config/hypr/hyprland.lua, with consent, a backup, and full undo.
|
||||||
|
/// See bread-cli/src/init.rs for the design rationale.
|
||||||
|
Init {
|
||||||
|
/// Print the proposed diff and change nothing
|
||||||
|
#[arg(long)]
|
||||||
|
dry_run: bool,
|
||||||
|
/// Skip the confirmation prompt (for scripted/image builds)
|
||||||
|
#[arg(long)]
|
||||||
|
yes: bool,
|
||||||
|
/// Remove the previously installed marked block instead of
|
||||||
|
/// installing it
|
||||||
|
#[arg(long)]
|
||||||
|
undo: bool,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
|
|
@ -224,6 +241,9 @@ async fn main() -> Result<()> {
|
||||||
print_doctor(&socket).await?;
|
print_doctor(&socket).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Commands::Init { dry_run, yes, undo } => {
|
||||||
|
init::run(dry_run, yes, undo)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -691,6 +711,7 @@ async fn print_doctor(socket: &Path) -> Result<()> {
|
||||||
println!();
|
println!();
|
||||||
println!(" start the daemon: systemctl --user start breadd");
|
println!(" start the daemon: systemctl --user start breadd");
|
||||||
println!(" view logs: journalctl --user -u breadd -f");
|
println!(" view logs: journalctl --user -u breadd -f");
|
||||||
|
print_compositor_doctor_section();
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -775,6 +796,20 @@ fn render_doctor(health: &Value) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
print_compositor_doctor_section();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compositor integration status — pure filesystem check, independent of
|
||||||
|
/// the daemon, so it prints the same whether breadd is up or not. Reports
|
||||||
|
/// only; `bread doctor` never installs anything (that's `bread init`'s
|
||||||
|
/// job).
|
||||||
|
fn print_compositor_doctor_section() {
|
||||||
|
println!();
|
||||||
|
println!("compositor");
|
||||||
|
for line in init::doctor_report(&init::hypr_dir()) {
|
||||||
|
println!("{line}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn config_directory() -> PathBuf {
|
fn config_directory() -> PathBuf {
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,10 @@ pub enum IoCommand {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// In-flight RPC calls awaiting a response, keyed by request id: each entry
|
||||||
|
/// is the reply channel for the call that's blocked waiting on it.
|
||||||
|
type PendingReplies = Arc<Mutex<HashMap<String, mpsc::Sender<Result<Value, String>>>>>;
|
||||||
|
|
||||||
/// Something the IO thread has for the Lua-driving thread: either an
|
/// Something the IO thread has for the Lua-driving thread: either an
|
||||||
/// unsolicited push (a subscribed event fired, a timer fired) or "the
|
/// unsolicited push (a subscribed event fired, a timer fired) or "the
|
||||||
/// connection is gone" (breadd exited, socket closed, etc).
|
/// connection is gone" (breadd exited, socket closed, etc).
|
||||||
|
|
@ -148,8 +152,7 @@ pub fn run(
|
||||||
// Lua-side call is blocked waiting for them; a dedicated thread
|
// Lua-side call is blocked waiting for them; a dedicated thread
|
||||||
// bridges the synchronous `cmd_rx` (fed from the Lua thread) onto an
|
// bridges the synchronous `cmd_rx` (fed from the Lua thread) onto an
|
||||||
// async channel this task can select on.
|
// async channel this task can select on.
|
||||||
let pending: Arc<Mutex<HashMap<String, mpsc::Sender<Result<Value, String>>>>> =
|
let pending: PendingReplies = Arc::new(Mutex::new(HashMap::new()));
|
||||||
Arc::new(Mutex::new(HashMap::new()));
|
|
||||||
|
|
||||||
let (async_cmd_tx, mut async_cmd_rx) = tokio::sync::mpsc::unbounded_channel::<IoCommand>();
|
let (async_cmd_tx, mut async_cmd_rx) = tokio::sync::mpsc::unbounded_channel::<IoCommand>();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ fn json_to_lua<'lua>(lua: &'lua Lua, value: &JsonValue) -> mlua::Result<LuaValue
|
||||||
JsonValue::Bool(b) => LuaValue::Boolean(*b),
|
JsonValue::Bool(b) => LuaValue::Boolean(*b),
|
||||||
JsonValue::Number(n) => {
|
JsonValue::Number(n) => {
|
||||||
if let Some(i) = n.as_i64() {
|
if let Some(i) = n.as_i64() {
|
||||||
LuaValue::Integer(i as i64)
|
LuaValue::Integer(i)
|
||||||
} else {
|
} else {
|
||||||
LuaValue::Number(n.as_f64().unwrap_or(0.0))
|
LuaValue::Number(n.as_f64().unwrap_or(0.0))
|
||||||
}
|
}
|
||||||
|
|
@ -243,7 +243,7 @@ impl ModuleHostLua {
|
||||||
let decode_fn = lua.create_function(|lua, s: String| {
|
let decode_fn = lua.create_function(|lua, s: String| {
|
||||||
match serde_json::from_str::<JsonValue>(&s) {
|
match serde_json::from_str::<JsonValue>(&s) {
|
||||||
Ok(v) => Ok((json_to_lua(lua, &v)?, LuaValue::Nil)),
|
Ok(v) => Ok((json_to_lua(lua, &v)?, LuaValue::Nil)),
|
||||||
Err(e) => Ok((LuaValue::Nil, LuaValue::String(lua.create_string(&e.to_string())?))),
|
Err(e) => Ok((LuaValue::Nil, LuaValue::String(lua.create_string(e.to_string())?))),
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
json_tbl.set("decode", decode_fn)?;
|
json_tbl.set("decode", decode_fn)?;
|
||||||
|
|
|
||||||
|
|
@ -24,12 +24,17 @@ pub const KNOWN_APPS: &[&str] = &[
|
||||||
/// socket client may freely emit a custom/test event, but not one whose
|
/// socket client may freely emit a custom/test event, but not one whose
|
||||||
/// top-level segment is one of these, since that would let it impersonate
|
/// top-level segment is one of these, since that would let it impersonate
|
||||||
/// a real adapter (or another daemon-internal event family) rather than
|
/// a real adapter (or another daemon-internal event family) rather than
|
||||||
/// producing an obviously-manual one. See [`is_reserved_domain`] and
|
/// producing an obviously-manual one. The one exception is a well-formed
|
||||||
/// `breadd/src/ipc/mod.rs`'s `emit` handler. *Since: v1.5 — `bluetooth`,
|
/// [`validate_command_event`] name (`bread.command.<known-app>.<verb>`):
|
||||||
|
/// `command` stays reserved so it cannot be claimed as an app id, but the
|
||||||
|
/// command bus itself is meant to be publishable. See [`is_reserved_domain`]
|
||||||
|
/// and `breadd/src/ipc/mod.rs`'s `emit` handler. *Since: v1.5 — `bluetooth`,
|
||||||
/// `workspace`, `window`, and `monitor` added (event families the Hyprland
|
/// `workspace`, `window`, and `monitor` added (event families the Hyprland
|
||||||
/// and Bluetooth adapters already published under, but that were missing
|
/// and Bluetooth adapters already published under, but that were missing
|
||||||
/// from this list) when this became a spoofing-prevention boundary and not
|
/// from this list) when this became a spoofing-prevention boundary and not
|
||||||
/// just an app-id-conflict one.*
|
/// just an app-id-conflict one. Since: v1.7 — command-bus exception.
|
||||||
|
/// `module`, `state`, `widget`, and `reload` are daemon-synthesized
|
||||||
|
/// families and must stay unclaimable.*
|
||||||
const RESERVED_DOMAINS: &[&str] = &[
|
const RESERVED_DOMAINS: &[&str] = &[
|
||||||
"terminal",
|
"terminal",
|
||||||
"git",
|
"git",
|
||||||
|
|
@ -50,6 +55,10 @@ const RESERVED_DOMAINS: &[&str] = &[
|
||||||
"workspace",
|
"workspace",
|
||||||
"window",
|
"window",
|
||||||
"monitor",
|
"monitor",
|
||||||
|
"module",
|
||||||
|
"state",
|
||||||
|
"widget",
|
||||||
|
"reload",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Whether `id` is a registered sibling-app id.
|
/// Whether `id` is a registered sibling-app id.
|
||||||
|
|
@ -66,11 +75,54 @@ pub fn is_reserved_domain(id: &str) -> bool {
|
||||||
/// Whether `event` is a well-formed event name for `app` — i.e. it starts
|
/// Whether `event` is a well-formed event name for `app` — i.e. it starts
|
||||||
/// with `bread.<app>.`. An app may only publish within its own namespace
|
/// with `bread.<app>.`. An app may only publish within its own namespace
|
||||||
/// segment; this is what the IPC boundary checks before constructing a
|
/// segment; this is what the IPC boundary checks before constructing a
|
||||||
/// `RawEvent` tagged `AdapterSource::App(app)`.
|
/// `RawEvent` tagged `AdapterSource::App(app)`. Command events addressed
|
||||||
|
/// to another known app are a separate, explicit exception — see
|
||||||
|
/// [`validate_command_event`].
|
||||||
pub fn validate_app_namespace(app: &str, event: &str) -> bool {
|
pub fn validate_app_namespace(app: &str, event: &str) -> bool {
|
||||||
event.starts_with(&format!("bread.{app}."))
|
event.starts_with(&format!("bread.{app}."))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `event` is in the outbound command namespace
|
||||||
|
/// (`bread.command.*`). Does not check that the target is a known app —
|
||||||
|
/// use [`validate_command_event`] for that.
|
||||||
|
pub fn is_command_event(event: &str) -> bool {
|
||||||
|
event.starts_with("bread.command.")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The app id a command event is addressed to — the segment immediately
|
||||||
|
/// after `bread.command.`. Returns `None` if `event` is not a command
|
||||||
|
/// event or the app-id segment is empty.
|
||||||
|
pub fn command_target(event: &str) -> Option<&str> {
|
||||||
|
let rest = event.strip_prefix("bread.command.")?;
|
||||||
|
let app = rest.split('.').next()?;
|
||||||
|
if app.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `event` is a well-formed command to a registered sibling app:
|
||||||
|
/// `bread.command.<known_app>.<verb>` with `known_app` in [`KNOWN_APPS`]
|
||||||
|
/// and a non-empty verb (at least one extra dotted segment).
|
||||||
|
///
|
||||||
|
/// This is the exception the IPC unsourced/`bread-emit` path (and sourced
|
||||||
|
/// `AdapterSource::App` emit) use so any module or app can publish
|
||||||
|
/// commands without `command` leaving [`is_reserved_domain`] — `command`
|
||||||
|
/// must stay unclaimable as an app id. `bread.command.power.off` and
|
||||||
|
/// `bread.command.notanapp.x` still fail because the target is not in
|
||||||
|
/// [`KNOWN_APPS`].
|
||||||
|
pub fn validate_command_event(event: &str) -> bool {
|
||||||
|
let rest = match event.strip_prefix("bread.command.") {
|
||||||
|
Some(rest) => rest,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
let Some((app, verb)) = rest.split_once('.') else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
is_known_app(app) && !verb.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
/// The top-level dotted segment after `bread.` in an event name — e.g.
|
/// The top-level dotted segment after `bread.` in an event name — e.g.
|
||||||
/// `Some("power")` for `"bread.power.ac.connected"`. Returns `None` for
|
/// `Some("power")` for `"bread.power.ac.connected"`. Returns `None` for
|
||||||
/// event names that don't start with `bread.` at all, which are always
|
/// event names that don't start with `bread.` at all, which are always
|
||||||
|
|
@ -119,8 +171,19 @@ mod tests {
|
||||||
// daemon itself publishes under must be reserved, or a manual/no-source
|
// daemon itself publishes under must be reserved, or a manual/no-source
|
||||||
// `emit` over the IPC socket could impersonate it undetected.
|
// `emit` over the IPC socket could impersonate it undetected.
|
||||||
for domain in [
|
for domain in [
|
||||||
"power", "network", "device", "bluetooth", "hyprland", "workspace", "monitor",
|
"power",
|
||||||
"window", "system",
|
"network",
|
||||||
|
"device",
|
||||||
|
"bluetooth",
|
||||||
|
"hyprland",
|
||||||
|
"workspace",
|
||||||
|
"monitor",
|
||||||
|
"window",
|
||||||
|
"system",
|
||||||
|
"module",
|
||||||
|
"state",
|
||||||
|
"widget",
|
||||||
|
"reload",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
is_reserved_domain(domain),
|
is_reserved_domain(domain),
|
||||||
|
|
@ -164,4 +227,47 @@ mod tests {
|
||||||
// because it shares a string prefix.
|
// because it shares a string prefix.
|
||||||
assert!(!validate_app_namespace("clip", "bread.clipx.copied"));
|
assert!(!validate_app_namespace("clip", "bread.clipx.copied"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_command_event_requires_command_prefix() {
|
||||||
|
assert!(is_command_event("bread.command.clip.clear"));
|
||||||
|
assert!(is_command_event("bread.command.power.off"));
|
||||||
|
assert!(!is_command_event("bread.command"));
|
||||||
|
assert!(!is_command_event("bread.clip.copied"));
|
||||||
|
assert!(!is_command_event("command.clip.clear"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_target_extracts_app_id() {
|
||||||
|
assert_eq!(command_target("bread.command.clip.clear"), Some("clip"));
|
||||||
|
assert_eq!(command_target("bread.command.cast.start.now"), Some("cast"));
|
||||||
|
assert_eq!(command_target("bread.command.clip"), Some("clip"));
|
||||||
|
assert_eq!(command_target("bread.command."), None);
|
||||||
|
assert_eq!(command_target("bread.clip.copied"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_command_event_accepts_known_app_with_verb() {
|
||||||
|
assert!(validate_command_event("bread.command.clip.clear"));
|
||||||
|
assert!(validate_command_event("bread.command.cast.start"));
|
||||||
|
assert!(validate_command_event("bread.command.clip.stack.clear"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_command_event_rejects_unknown_target_or_missing_verb() {
|
||||||
|
assert!(!validate_command_event("bread.command.power.off"));
|
||||||
|
assert!(!validate_command_event("bread.command.notanapp.x"));
|
||||||
|
assert!(!validate_command_event("bread.command.clip"));
|
||||||
|
assert!(!validate_command_event("bread.command.clip."));
|
||||||
|
assert!(!validate_command_event("bread.command."));
|
||||||
|
assert!(!validate_command_event("bread.hyprland.workspace.changed"));
|
||||||
|
assert!(!validate_command_event("bread.clip.copied"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_stays_reserved_and_is_not_a_known_app() {
|
||||||
|
assert!(is_reserved_domain("command"));
|
||||||
|
assert!(!is_known_app("command"));
|
||||||
|
assert!(!validate_command_event("bread.command.command.x"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ use std::path::PathBuf;
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
|
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||||
use tokio::net::UnixStream;
|
use tokio::net::UnixStream;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
|
|
@ -24,6 +24,13 @@ impl Adapter for HyprlandAdapter {
|
||||||
debug!("hyprland adapter started");
|
debug!("hyprland adapter started");
|
||||||
let socket = hyprland_event_socket()?;
|
let socket = hyprland_event_socket()?;
|
||||||
let stream = UnixStream::connect(&socket).await?;
|
let stream = UnixStream::connect(&socket).await?;
|
||||||
|
// Snapshot current compositor topology *after* the event socket is
|
||||||
|
// connected so we don't miss a change that lands during the query,
|
||||||
|
// then apply the snapshot first so bread.state is populated before
|
||||||
|
// the live stream. Failure is non-fatal: live events still work.
|
||||||
|
if let Err(e) = emit_topology_snapshot(&tx).await {
|
||||||
|
warn!("hyprland topology snapshot failed: {e}");
|
||||||
|
}
|
||||||
let reader = BufReader::new(stream);
|
let reader = BufReader::new(stream);
|
||||||
let mut lines = reader.lines();
|
let mut lines = reader.lines();
|
||||||
|
|
||||||
|
|
@ -83,6 +90,53 @@ fn hyprland_event_socket() -> Result<PathBuf> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request socket sits next to `.socket2.sock` as `.socket.sock`.
|
||||||
|
fn hyprland_request_socket() -> Result<PathBuf> {
|
||||||
|
let events = hyprland_event_socket()?;
|
||||||
|
let parent = events
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| anyhow!("hyprland event socket has no parent dir"))?;
|
||||||
|
Ok(parent.join(".socket.sock"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn hyprland_request_json(request: &str) -> Result<serde_json::Value> {
|
||||||
|
let path = hyprland_request_socket()?;
|
||||||
|
let mut stream = UnixStream::connect(&path).await?;
|
||||||
|
stream.write_all(request.as_bytes()).await?;
|
||||||
|
stream.shutdown().await?;
|
||||||
|
let mut buf = String::new();
|
||||||
|
stream.read_to_string(&mut buf).await?;
|
||||||
|
serde_json::from_str(&buf)
|
||||||
|
.map_err(|e| anyhow!("hyprland {request} JSON: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query current monitors / workspaces / focus and emit one
|
||||||
|
/// `hyprland.snapshot` RawEvent. The normalizer turns that into
|
||||||
|
/// `bread.hyprland.snapshot`; the state engine replaces topology from it.
|
||||||
|
async fn emit_topology_snapshot(tx: &mpsc::Sender<RawEvent>) -> Result<()> {
|
||||||
|
let monitors = hyprland_request_json("j/monitors").await.unwrap_or(json!([]));
|
||||||
|
let workspaces = hyprland_request_json("j/workspaces")
|
||||||
|
.await
|
||||||
|
.unwrap_or(json!([]));
|
||||||
|
let active_workspace = hyprland_request_json("j/activeworkspace").await.ok();
|
||||||
|
let active_window = hyprland_request_json("j/activewindow").await.ok();
|
||||||
|
|
||||||
|
tx.send(RawEvent {
|
||||||
|
source: AdapterSource::Hyprland,
|
||||||
|
kind: "hyprland.snapshot".to_string(),
|
||||||
|
payload: json!({
|
||||||
|
"monitors": monitors,
|
||||||
|
"workspaces": workspaces,
|
||||||
|
"active_workspace": active_workspace,
|
||||||
|
"active_window": active_window,
|
||||||
|
}),
|
||||||
|
timestamp: now_unix_ms(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow!("raw channel closed during hyprland snapshot"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_hyprland_line(line: &str) -> (String, String) {
|
fn parse_hyprland_line(line: &str) -> (String, String) {
|
||||||
if let Some((kind, data)) = line.split_once(">>") {
|
if let Some((kind, data)) = line.split_once(">>") {
|
||||||
return (kind.to_string(), data.to_string());
|
return (kind.to_string(), data.to_string());
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ use std::os::unix::io::AsRawFd;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
|
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
|
||||||
use serde_json::json;
|
use serde_json::{json, Value};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
|
|
@ -19,19 +19,12 @@ impl UdevAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn enumerate_existing(&self, tx: &mpsc::Sender<RawEvent>) -> Result<()> {
|
pub async fn enumerate_existing(&self, tx: &mpsc::Sender<RawEvent>) -> Result<()> {
|
||||||
let devices = enumerate_with_udev(&self.subsystems)?;
|
let mut enumerator = udev::Enumerator::new()?;
|
||||||
for device in devices {
|
for subsystem in &self.subsystems {
|
||||||
tx.send(RawEvent {
|
enumerator.match_subsystem(subsystem)?;
|
||||||
source: AdapterSource::Udev,
|
}
|
||||||
kind: "udev.enumerate".to_string(),
|
for device in enumerator.scan_devices()? {
|
||||||
payload: json!({
|
tx.send(build_device_event(&device, "add", "udev.enumerate"))
|
||||||
"action": "add",
|
|
||||||
"id": device.id,
|
|
||||||
"name": device.name,
|
|
||||||
"subsystem": device.subsystem,
|
|
||||||
}),
|
|
||||||
timestamp: now_unix_ms(),
|
|
||||||
})
|
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -50,12 +43,6 @@ impl Adapter for UdevAdapter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ScannedDevice {
|
|
||||||
id: String,
|
|
||||||
name: String,
|
|
||||||
subsystem: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
// udev::MonitorSocket uses a non-blocking socket; calling iter().next() without
|
// udev::MonitorSocket uses a non-blocking socket; calling iter().next() without
|
||||||
// first polling the fd returns None immediately and exits the loop — which is
|
// first polling the fd returns None immediately and exits the loop — which is
|
||||||
// why the old code silently fell back to sysfs on every start. We use poll(2)
|
// why the old code silently fell back to sysfs on every start. We use poll(2)
|
||||||
|
|
@ -110,81 +97,157 @@ fn build_event(event: &udev::Event) -> RawEvent {
|
||||||
.action()
|
.action()
|
||||||
.map(|a| a.to_string_lossy().to_string())
|
.map(|a| a.to_string_lossy().to_string())
|
||||||
.unwrap_or_else(|| "change".to_string());
|
.unwrap_or_else(|| "change".to_string());
|
||||||
let subsystem = event
|
build_device_event(event, &action, "udev.change")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared live/enumerate payload. `udev::Event` deref's to `Device`, so
|
||||||
|
/// boot-time enumerate of an already-plugged device is equivalent to an
|
||||||
|
/// `add` of that same device (same identity + classification fields
|
||||||
|
/// `resolve_device` needs).
|
||||||
|
fn build_device_event(device: &udev::Device, action: &str, kind: &str) -> RawEvent {
|
||||||
|
let subsystem = device
|
||||||
.subsystem()
|
.subsystem()
|
||||||
.map(|s| s.to_string_lossy().to_string())
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
let name = event
|
let name = device
|
||||||
.property_value("ID_MODEL")
|
.property_value("ID_MODEL")
|
||||||
.or_else(|| event.property_value("NAME"))
|
.or_else(|| device.property_value("NAME"))
|
||||||
.map(|v| v.to_string_lossy().to_string())
|
.map(|v| v.to_string_lossy().to_string())
|
||||||
.or_else(|| event.devnode().map(|n| n.display().to_string()))
|
.or_else(|| device.devnode().map(|n| n.display().to_string()))
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
let id = event.syspath().to_string_lossy().to_string();
|
let id = device.syspath().to_string_lossy().to_string();
|
||||||
|
|
||||||
RawEvent {
|
RawEvent {
|
||||||
source: AdapterSource::Udev,
|
source: AdapterSource::Udev,
|
||||||
kind: "udev.change".to_string(),
|
kind: kind.to_string(),
|
||||||
payload: json!({
|
payload: udev_event_payload(
|
||||||
"action": action,
|
action,
|
||||||
"id": id,
|
&id,
|
||||||
"name": name,
|
&name,
|
||||||
"subsystem": subsystem,
|
&subsystem,
|
||||||
"id_input_keyboard": prop_bool(event, "ID_INPUT_KEYBOARD"),
|
UdevClassification::from_device(device),
|
||||||
"id_input_mouse": prop_bool(event, "ID_INPUT_MOUSE"),
|
),
|
||||||
"id_input_joystick": prop_bool(event, "ID_INPUT_JOYSTICK"),
|
|
||||||
"id_input_touchpad": prop_bool(event, "ID_INPUT_TOUCHPAD"),
|
|
||||||
"id_input_tablet": prop_bool(event, "ID_INPUT_TABLET"),
|
|
||||||
"id_usb_class": prop_str(event, "ID_USB_CLASS"),
|
|
||||||
"id_usb_interfaces": prop_str(event, "ID_USB_INTERFACES"),
|
|
||||||
"id_vendor": prop_str(event, "ID_VENDOR"),
|
|
||||||
"id_model": prop_str(event, "ID_MODEL"),
|
|
||||||
"vendor_id": prop_str(event, "ID_VENDOR_ID"),
|
|
||||||
"product_id": prop_str(event, "ID_MODEL_ID"),
|
|
||||||
}),
|
|
||||||
timestamp: now_unix_ms(),
|
timestamp: now_unix_ms(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn enumerate_with_udev(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
|
/// Classification / identity fields copied onto every udev payload so
|
||||||
let mut enumerator = udev::Enumerator::new()?;
|
/// `resolve_device` can name a device after boot the same way it names a
|
||||||
for subsystem in subsystems {
|
/// live plug-in.
|
||||||
enumerator.match_subsystem(subsystem)?;
|
struct UdevClassification {
|
||||||
|
id_input_keyboard: bool,
|
||||||
|
id_input_mouse: bool,
|
||||||
|
id_input_joystick: bool,
|
||||||
|
id_input_touchpad: bool,
|
||||||
|
id_input_tablet: bool,
|
||||||
|
id_usb_class: Option<String>,
|
||||||
|
id_usb_interfaces: Option<String>,
|
||||||
|
id_vendor: Option<String>,
|
||||||
|
id_model: Option<String>,
|
||||||
|
vendor_id: Option<String>,
|
||||||
|
product_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut out = Vec::new();
|
impl UdevClassification {
|
||||||
for dev in enumerator.scan_devices()? {
|
fn from_device(device: &udev::Device) -> Self {
|
||||||
let subsystem = dev
|
Self {
|
||||||
.subsystem()
|
id_input_keyboard: prop_bool(device, "ID_INPUT_KEYBOARD"),
|
||||||
.map(|s| s.to_string_lossy().to_string())
|
id_input_mouse: prop_bool(device, "ID_INPUT_MOUSE"),
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
id_input_joystick: prop_bool(device, "ID_INPUT_JOYSTICK"),
|
||||||
let name = dev
|
id_input_touchpad: prop_bool(device, "ID_INPUT_TOUCHPAD"),
|
||||||
.property_value("ID_MODEL")
|
id_input_tablet: prop_bool(device, "ID_INPUT_TABLET"),
|
||||||
.or_else(|| dev.property_value("NAME"))
|
id_usb_class: prop_str(device, "ID_USB_CLASS"),
|
||||||
.map(|v| v.to_string_lossy().to_string())
|
id_usb_interfaces: prop_str(device, "ID_USB_INTERFACES"),
|
||||||
.or_else(|| dev.sysname().to_str().map(ToString::to_string))
|
id_vendor: prop_str(device, "ID_VENDOR"),
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
id_model: prop_str(device, "ID_MODEL"),
|
||||||
let id = dev.syspath().to_string_lossy().to_string();
|
vendor_id: prop_str(device, "ID_VENDOR_ID"),
|
||||||
out.push(ScannedDevice {
|
product_id: prop_str(device, "ID_MODEL_ID"),
|
||||||
id,
|
}
|
||||||
name,
|
}
|
||||||
subsystem,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(out)
|
fn udev_event_payload(
|
||||||
|
action: &str,
|
||||||
|
id: &str,
|
||||||
|
name: &str,
|
||||||
|
subsystem: &str,
|
||||||
|
class: UdevClassification,
|
||||||
|
) -> Value {
|
||||||
|
json!({
|
||||||
|
"action": action,
|
||||||
|
"id": id,
|
||||||
|
"name": name,
|
||||||
|
"subsystem": subsystem,
|
||||||
|
"id_input_keyboard": class.id_input_keyboard,
|
||||||
|
"id_input_mouse": class.id_input_mouse,
|
||||||
|
"id_input_joystick": class.id_input_joystick,
|
||||||
|
"id_input_touchpad": class.id_input_touchpad,
|
||||||
|
"id_input_tablet": class.id_input_tablet,
|
||||||
|
"id_usb_class": class.id_usb_class,
|
||||||
|
"id_usb_interfaces": class.id_usb_interfaces,
|
||||||
|
"id_vendor": class.id_vendor,
|
||||||
|
"id_model": class.id_model,
|
||||||
|
"vendor_id": class.vendor_id,
|
||||||
|
"product_id": class.product_id,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prop_bool(event: &udev::Event, key: &str) -> bool {
|
fn prop_bool(device: &udev::Device, key: &str) -> bool {
|
||||||
event
|
device
|
||||||
.property_value(key)
|
.property_value(key)
|
||||||
.and_then(|v| v.to_str())
|
.and_then(|v| v.to_str())
|
||||||
.map(|v| v == "1")
|
.map(|v| v == "1")
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prop_str(event: &udev::Event, key: &str) -> Option<String> {
|
fn prop_str(device: &udev::Device, key: &str) -> Option<String> {
|
||||||
event
|
device
|
||||||
.property_value(key)
|
.property_value(key)
|
||||||
.map(|v| v.to_string_lossy().to_string())
|
.map(|v| v.to_string_lossy().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enumerate_payload_includes_classification_fields() {
|
||||||
|
// Boot-time enumerate used to send only {action,id,name,subsystem},
|
||||||
|
// so resolve_device could never match vendor/product/input rules
|
||||||
|
// and bread.state.devices stayed "unknown" until the next unplug.
|
||||||
|
let payload = udev_event_payload(
|
||||||
|
"add",
|
||||||
|
"/sys/devices/pci0000:00/usb1/1-3",
|
||||||
|
"Keychron K2",
|
||||||
|
"usb",
|
||||||
|
UdevClassification {
|
||||||
|
id_input_keyboard: true,
|
||||||
|
id_input_mouse: false,
|
||||||
|
id_input_joystick: false,
|
||||||
|
id_input_touchpad: false,
|
||||||
|
id_input_tablet: false,
|
||||||
|
id_usb_class: None,
|
||||||
|
id_usb_interfaces: None,
|
||||||
|
id_vendor: Some("Keychron".into()),
|
||||||
|
id_model: Some("Keychron K2".into()),
|
||||||
|
vendor_id: Some("3434".into()),
|
||||||
|
product_id: Some("d030".into()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(payload["action"], "add");
|
||||||
|
assert_eq!(payload["id"], "/sys/devices/pci0000:00/usb1/1-3");
|
||||||
|
assert_eq!(payload["name"], "Keychron K2");
|
||||||
|
assert_eq!(payload["subsystem"], "usb");
|
||||||
|
assert_eq!(payload["vendor_id"], "3434");
|
||||||
|
assert_eq!(payload["product_id"], "d030");
|
||||||
|
assert_eq!(payload["id_vendor"], "Keychron");
|
||||||
|
assert_eq!(payload["id_model"], "Keychron K2");
|
||||||
|
assert_eq!(payload["id_input_keyboard"], true);
|
||||||
|
assert_eq!(payload["id_input_mouse"], false);
|
||||||
|
assert_eq!(payload["id_input_joystick"], false);
|
||||||
|
assert_eq!(payload["id_input_touchpad"], false);
|
||||||
|
assert_eq!(payload["id_input_tablet"], false);
|
||||||
|
assert!(payload["id_usb_class"].is_null());
|
||||||
|
assert!(payload["id_usb_interfaces"].is_null());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::RwLock;
|
use std::sync::RwLock;
|
||||||
|
|
||||||
use bread_shared::{apps::validate_app_namespace, AdapterSource, BreadEvent, RawEvent};
|
use bread_shared::{
|
||||||
|
apps::{validate_app_namespace, validate_command_event},
|
||||||
|
AdapterSource, BreadEvent, RawEvent,
|
||||||
|
};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
/// How many multiples of `dedup_window_ms` an entry must be idle before eviction.
|
/// How many multiples of `dedup_window_ms` an entry must be idle before eviction.
|
||||||
|
|
@ -193,6 +196,17 @@ impl EventNormalizer {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_hyprland(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
fn normalize_hyprland(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||||
|
if raw.kind == "hyprland.snapshot" {
|
||||||
|
return vec![BreadEvent {
|
||||||
|
event: "bread.hyprland.snapshot".to_string(),
|
||||||
|
timestamp: raw.timestamp,
|
||||||
|
source: AdapterSource::Hyprland,
|
||||||
|
id: bread_shared::new_event_id(),
|
||||||
|
caused_by: None,
|
||||||
|
data: raw.payload.clone(),
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
let kind = raw
|
let kind = raw
|
||||||
.payload
|
.payload
|
||||||
.get("kind")
|
.get("kind")
|
||||||
|
|
@ -208,9 +222,11 @@ impl EventNormalizer {
|
||||||
"workspace" | "workspacev2" => {
|
"workspace" | "workspacev2" => {
|
||||||
self.emit_hyprland_dual("bread.workspace.changed", raw.payload.clone(), raw)
|
self.emit_hyprland_dual("bread.workspace.changed", raw.payload.clone(), raw)
|
||||||
}
|
}
|
||||||
"createworkspace" => {
|
"createworkspace" => self.emit_hyprland_dual(
|
||||||
self.emit_hyprland_dual("bread.workspace.created", json!({ "workspace": data }), raw)
|
"bread.workspace.created",
|
||||||
}
|
json!({ "workspace": data }),
|
||||||
|
raw,
|
||||||
|
),
|
||||||
"destroyworkspace" => self.emit_hyprland_dual(
|
"destroyworkspace" => self.emit_hyprland_dual(
|
||||||
"bread.workspace.destroyed",
|
"bread.workspace.destroyed",
|
||||||
json!({ "workspace": data }),
|
json!({ "workspace": data }),
|
||||||
|
|
@ -589,7 +605,10 @@ impl EventNormalizer {
|
||||||
let AdapterSource::App(app) = &raw.source else {
|
let AdapterSource::App(app) = &raw.source else {
|
||||||
return vec![];
|
return vec![];
|
||||||
};
|
};
|
||||||
if !validate_app_namespace(app, &raw.kind) {
|
// Own-namespace events plus well-formed commands to another known
|
||||||
|
// app (the command bus). Anything else — including spoofed adapter
|
||||||
|
// namespaces — is dropped here even if it somehow crossed IPC.
|
||||||
|
if !validate_app_namespace(app, &raw.kind) && !validate_command_event(&raw.kind) {
|
||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
vec![BreadEvent {
|
vec![BreadEvent {
|
||||||
|
|
@ -997,7 +1016,11 @@ mod tests {
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
let out = n.normalize(&ev);
|
let out = n.normalize(&ev);
|
||||||
assert_eq!(out.len(), 2, "kind {kind} should dual-emit exactly 2 events");
|
assert_eq!(
|
||||||
|
out.len(),
|
||||||
|
2,
|
||||||
|
"kind {kind} should dual-emit exactly 2 events"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
out.iter().any(|e| &e.event == legacy_event),
|
out.iter().any(|e| &e.event == legacy_event),
|
||||||
"kind {kind} missing legacy event {legacy_event}"
|
"kind {kind} missing legacy event {legacy_event}"
|
||||||
|
|
@ -1006,7 +1029,12 @@ mod tests {
|
||||||
out.iter().any(|e| &e.event == namespaced_event),
|
out.iter().any(|e| &e.event == namespaced_event),
|
||||||
"kind {kind} missing namespaced event {namespaced_event}"
|
"kind {kind} missing namespaced event {namespaced_event}"
|
||||||
);
|
);
|
||||||
let legacy_data = out.iter().find(|e| &e.event == legacy_event).unwrap().data.clone();
|
let legacy_data = out
|
||||||
|
.iter()
|
||||||
|
.find(|e| &e.event == legacy_event)
|
||||||
|
.unwrap()
|
||||||
|
.data
|
||||||
|
.clone();
|
||||||
let namespaced_data = out
|
let namespaced_data = out
|
||||||
.iter()
|
.iter()
|
||||||
.find(|e| &e.event == namespaced_event)
|
.find(|e| &e.event == namespaced_event)
|
||||||
|
|
@ -1452,6 +1480,65 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── App / command bus ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_own_namespace_passes_through() {
|
||||||
|
let n = EventNormalizer::new(0);
|
||||||
|
let out = n.normalize(&raw(
|
||||||
|
AdapterSource::App("clip".into()),
|
||||||
|
"bread.clip.copied",
|
||||||
|
json!({"len": 4}),
|
||||||
|
1,
|
||||||
|
));
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].event, "bread.clip.copied");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_command_to_another_known_app_passes_through() {
|
||||||
|
let n = EventNormalizer::new(0);
|
||||||
|
let out = n.normalize(&raw(
|
||||||
|
AdapterSource::App("cast".into()),
|
||||||
|
"bread.command.clip.clear",
|
||||||
|
json!({}),
|
||||||
|
1,
|
||||||
|
));
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].event, "bread.command.clip.clear");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_wrong_namespace_is_dropped() {
|
||||||
|
let n = EventNormalizer::new(0);
|
||||||
|
let out = n.normalize(&raw(
|
||||||
|
AdapterSource::App("cast".into()),
|
||||||
|
"bread.clip.copied",
|
||||||
|
json!({}),
|
||||||
|
1,
|
||||||
|
));
|
||||||
|
assert!(out.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_command_to_unknown_or_reserved_target_is_dropped() {
|
||||||
|
let n = EventNormalizer::new(0);
|
||||||
|
let power = n.normalize(&raw(
|
||||||
|
AdapterSource::App("cast".into()),
|
||||||
|
"bread.command.power.off",
|
||||||
|
json!({}),
|
||||||
|
1,
|
||||||
|
));
|
||||||
|
assert!(power.is_empty());
|
||||||
|
let spoof = n.normalize(&raw(
|
||||||
|
AdapterSource::App("cast".into()),
|
||||||
|
"bread.hyprland.workspace.changed",
|
||||||
|
json!({}),
|
||||||
|
1,
|
||||||
|
));
|
||||||
|
assert!(spoof.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Helper ────────────────────────────────────────────────────────────
|
// ─── Helper ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -260,7 +260,7 @@ pub async fn run_state_engine(
|
||||||
}
|
}
|
||||||
|
|
||||||
if let (Some(before), Some(after)) = (before_snapshot, after_snapshot) {
|
if let (Some(before), Some(after)) = (before_snapshot, after_snapshot) {
|
||||||
for (_id, path) in watches.iter() {
|
for path in watches.values() {
|
||||||
let old_val = value_at_path(&before, path).unwrap_or(Value::Null);
|
let old_val = value_at_path(&before, path).unwrap_or(Value::Null);
|
||||||
let new_val = value_at_path(&after, path).unwrap_or(Value::Null);
|
let new_val = value_at_path(&after, path).unwrap_or(Value::Null);
|
||||||
if old_val != new_val {
|
if old_val != new_val {
|
||||||
|
|
@ -395,55 +395,180 @@ fn value_at_path(value: &Value, path: &str) -> Option<Value> {
|
||||||
Some(current.clone())
|
Some(current.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_event_to_state(state: &mut RuntimeState, event: &BreadEvent) {
|
/// Logical Hyprland state key: both the legacy flat name and its
|
||||||
match event.event.as_str() {
|
/// `bread.hyprland.*` sibling collapse to the same suffix
|
||||||
"bread.monitor.connected" => {
|
/// (`monitor.connected`, `workspace.changed`, …).
|
||||||
if let Some(name) = event.data.get("name").and_then(Value::as_str) {
|
fn hyprland_state_key(event: &str) -> Option<&str> {
|
||||||
|
if let Some(rest) = event.strip_prefix("bread.hyprland.") {
|
||||||
|
return Some(rest);
|
||||||
|
}
|
||||||
|
match event {
|
||||||
|
"bread.monitor.connected" => Some("monitor.connected"),
|
||||||
|
"bread.monitor.disconnected" => Some("monitor.disconnected"),
|
||||||
|
"bread.workspace.changed" => Some("workspace.changed"),
|
||||||
|
"bread.workspace.created" => Some("workspace.created"),
|
||||||
|
"bread.workspace.destroyed" => Some("workspace.destroyed"),
|
||||||
|
"bread.window.focus.changed" => Some("window.focus.changed"),
|
||||||
|
"bread.window.focused" => Some("window.focused"),
|
||||||
|
"bread.window.opened" => Some("window.opened"),
|
||||||
|
"bread.window.closed" => Some("window.closed"),
|
||||||
|
"bread.window.moved" => Some("window.moved"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_stringish(value: Option<&Value>) -> Option<String> {
|
||||||
|
let value = value?;
|
||||||
|
if let Some(s) = value.as_str() {
|
||||||
|
return Some(s.to_string());
|
||||||
|
}
|
||||||
|
value.as_i64().map(|n| n.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_id_from_data(data: &Value) -> Option<String> {
|
||||||
|
json_stringish(data.get("workspace"))
|
||||||
|
.or_else(|| json_stringish(data.get("id")))
|
||||||
|
.or_else(|| json_stringish(data.get("name")))
|
||||||
|
.or_else(|| {
|
||||||
|
data.get("data")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(|s| s.split(',').next().unwrap_or(s).trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_window_from_data(data: &Value) -> Option<String> {
|
||||||
|
json_stringish(data.get("window"))
|
||||||
|
.or_else(|| json_stringish(data.get("class")))
|
||||||
|
.or_else(|| json_stringish(data.get("address")))
|
||||||
|
.or_else(|| {
|
||||||
|
data.get("data")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(|s| s.split(',').next().unwrap_or(s).trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upsert_monitor(state: &mut RuntimeState, data: &Value) {
|
||||||
|
let Some(name) = data.get("name").and_then(Value::as_str) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
if let Some(m) = state.monitors.iter_mut().find(|m| m.name == name) {
|
if let Some(m) = state.monitors.iter_mut().find(|m| m.name == name) {
|
||||||
m.connected = true;
|
m.connected = true;
|
||||||
|
if let Some(res) = data.get("resolution").and_then(Value::as_str) {
|
||||||
|
m.resolution = Some(res.to_string());
|
||||||
|
}
|
||||||
|
if let Some(pos) = data.get("position").and_then(Value::as_str) {
|
||||||
|
m.position = Some(pos.to_string());
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
state.monitors.push(crate::core::types::Monitor {
|
state.monitors.push(crate::core::types::Monitor {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
connected: true,
|
connected: true,
|
||||||
resolution: event
|
resolution: data
|
||||||
.data
|
|
||||||
.get("resolution")
|
.get("resolution")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.map(ToString::to_string),
|
.map(ToString::to_string),
|
||||||
position: event
|
position: data
|
||||||
.data
|
|
||||||
.get("position")
|
.get("position")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.map(ToString::to_string),
|
.map(ToString::to_string),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_hyprland_snapshot(state: &mut RuntimeState, data: &Value) {
|
||||||
|
if let Some(arr) = data.get("monitors").and_then(Value::as_array) {
|
||||||
|
state.monitors = arr
|
||||||
|
.iter()
|
||||||
|
.filter_map(|m| {
|
||||||
|
let name = m.get("name").and_then(Value::as_str)?;
|
||||||
|
let width = m.get("width").and_then(Value::as_u64);
|
||||||
|
let height = m.get("height").and_then(Value::as_u64);
|
||||||
|
let x = m.get("x").and_then(Value::as_i64);
|
||||||
|
let y = m.get("y").and_then(Value::as_i64);
|
||||||
|
let disabled = m.get("disabled").and_then(Value::as_bool).unwrap_or(false);
|
||||||
|
Some(crate::core::types::Monitor {
|
||||||
|
name: name.to_string(),
|
||||||
|
connected: !disabled,
|
||||||
|
resolution: match (width, height) {
|
||||||
|
(Some(w), Some(h)) => Some(format!("{w}x{h}")),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
position: match (x, y) {
|
||||||
|
(Some(x), Some(y)) => Some(format!("{x}x{y}")),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
}
|
}
|
||||||
"bread.monitor.disconnected" => {
|
if let Some(arr) = data.get("workspaces").and_then(Value::as_array) {
|
||||||
|
state.workspaces = arr
|
||||||
|
.iter()
|
||||||
|
.filter_map(|ws| {
|
||||||
|
let id = json_stringish(ws.get("id")).or_else(|| json_stringish(ws.get("name")))?;
|
||||||
|
Some(crate::core::types::Workspace {
|
||||||
|
id,
|
||||||
|
monitor: json_stringish(ws.get("monitor")),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
if let Some(aw) = data.get("active_workspace") {
|
||||||
|
state.active_workspace =
|
||||||
|
json_stringish(aw.get("name")).or_else(|| json_stringish(aw.get("id")));
|
||||||
|
}
|
||||||
|
if let Some(win) = data.get("active_window") {
|
||||||
|
state.active_window = json_stringish(win.get("address"))
|
||||||
|
.or_else(|| json_stringish(win.get("class")))
|
||||||
|
.or_else(|| json_stringish(win.get("title")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_event_to_state(state: &mut RuntimeState, event: &BreadEvent) {
|
||||||
|
if event.event == "bread.hyprland.snapshot" {
|
||||||
|
apply_hyprland_snapshot(state, &event.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(key) = hyprland_state_key(event.event.as_str()) {
|
||||||
|
match key {
|
||||||
|
"monitor.connected" => upsert_monitor(state, &event.data),
|
||||||
|
"monitor.disconnected" => {
|
||||||
if let Some(name) = event.data.get("name").and_then(Value::as_str) {
|
if let Some(name) = event.data.get("name").and_then(Value::as_str) {
|
||||||
if let Some(m) = state.monitors.iter_mut().find(|m| m.name == name) {
|
if let Some(m) = state.monitors.iter_mut().find(|m| m.name == name) {
|
||||||
m.connected = false;
|
m.connected = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"bread.workspace.changed" => {
|
"workspace.changed" => {
|
||||||
let ws = event
|
state.active_workspace = workspace_id_from_data(&event.data);
|
||||||
.data
|
|
||||||
.get("workspace")
|
|
||||||
.or_else(|| event.data.get("id"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToString::to_string);
|
|
||||||
state.active_workspace = ws;
|
|
||||||
}
|
}
|
||||||
"bread.window.focus.changed" | "bread.window.focused" => {
|
"workspace.created" => {
|
||||||
state.active_window = event
|
if let Some(id) = workspace_id_from_data(&event.data) {
|
||||||
.data
|
if !state.workspaces.iter().any(|w| w.id == id) {
|
||||||
.get("window")
|
state.workspaces.push(crate::core::types::Workspace {
|
||||||
.or_else(|| event.data.get("class"))
|
id,
|
||||||
.or_else(|| event.data.get("address"))
|
monitor: json_stringish(event.data.get("monitor")),
|
||||||
.and_then(Value::as_str)
|
});
|
||||||
.map(ToString::to_string);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"workspace.destroyed" => {
|
||||||
|
if let Some(id) = workspace_id_from_data(&event.data) {
|
||||||
|
state.workspaces.retain(|w| w.id != id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"window.focus.changed" | "window.focused" => {
|
||||||
|
state.active_window = active_window_from_data(&event.data);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match event.event.as_str() {
|
||||||
"bread.device.connected" => {
|
"bread.device.connected" => {
|
||||||
apply_device_change(state, &event.data, true);
|
apply_device_change(state, &event.data, true);
|
||||||
}
|
}
|
||||||
|
|
@ -787,6 +912,67 @@ mod tests {
|
||||||
assert_eq!(state.active_window.as_deref(), Some("0xdeadbeef"));
|
assert_eq!(state.active_window.as_deref(), Some("0xdeadbeef"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn namespaced_hyprland_events_update_the_same_state() {
|
||||||
|
let mut state = RuntimeState::default();
|
||||||
|
apply_event_to_state(
|
||||||
|
&mut state,
|
||||||
|
&ev(
|
||||||
|
"bread.hyprland.monitor.connected",
|
||||||
|
json!({"name": "HDMI-A-1"}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
apply_event_to_state(
|
||||||
|
&mut state,
|
||||||
|
&ev("bread.hyprland.workspace.changed", json!({"id": 4})),
|
||||||
|
);
|
||||||
|
apply_event_to_state(
|
||||||
|
&mut state,
|
||||||
|
&ev(
|
||||||
|
"bread.hyprland.window.focus.changed",
|
||||||
|
json!({"kind": "activewindow", "data": "kitty,foo"}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
apply_event_to_state(
|
||||||
|
&mut state,
|
||||||
|
&ev("bread.hyprland.workspace.created", json!({"workspace": "9"})),
|
||||||
|
);
|
||||||
|
assert_eq!(state.monitors.len(), 1);
|
||||||
|
assert_eq!(state.monitors[0].name, "HDMI-A-1");
|
||||||
|
assert_eq!(state.active_workspace.as_deref(), Some("4"));
|
||||||
|
assert_eq!(state.active_window.as_deref(), Some("kitty"));
|
||||||
|
assert_eq!(state.workspaces.len(), 1);
|
||||||
|
assert_eq!(state.workspaces[0].id, "9");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hyprland_snapshot_replaces_topology() {
|
||||||
|
let mut state = RuntimeState::default();
|
||||||
|
apply_event_to_state(
|
||||||
|
&mut state,
|
||||||
|
&ev("bread.monitor.connected", json!({"name": "stale"})),
|
||||||
|
);
|
||||||
|
apply_event_to_state(
|
||||||
|
&mut state,
|
||||||
|
&ev(
|
||||||
|
"bread.hyprland.snapshot",
|
||||||
|
json!({
|
||||||
|
"monitors": [{"name": "eDP-1", "width": 1920, "height": 1200, "x": 0, "y": 0}],
|
||||||
|
"workspaces": [{"id": 1, "name": "1", "monitor": "eDP-1"}],
|
||||||
|
"active_workspace": {"id": 1, "name": "1"},
|
||||||
|
"active_window": {"address": "0xabc", "class": "kitty"}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert_eq!(state.monitors.len(), 1);
|
||||||
|
assert_eq!(state.monitors[0].name, "eDP-1");
|
||||||
|
assert_eq!(state.monitors[0].resolution.as_deref(), Some("1920x1200"));
|
||||||
|
assert_eq!(state.workspaces.len(), 1);
|
||||||
|
assert_eq!(state.workspaces[0].id, "1");
|
||||||
|
assert_eq!(state.active_workspace.as_deref(), Some("1"));
|
||||||
|
assert_eq!(state.active_window.as_deref(), Some("0xabc"));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── apply_device_change ──────────────────────────────────────────────
|
// ─── apply_device_change ──────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@ use std::sync::Arc;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use bread_shared::apps::{event_domain, is_known_app, is_reserved_domain, validate_app_namespace};
|
use bread_shared::apps::{
|
||||||
|
event_domain, is_known_app, is_reserved_domain, validate_app_namespace, validate_command_event,
|
||||||
|
};
|
||||||
use bread_shared::{now_unix_ms, AdapterSource, BreadEvent, RawEvent};
|
use bread_shared::{now_unix_ms, AdapterSource, BreadEvent, RawEvent};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
@ -34,7 +36,12 @@ mod module_host_bridge;
|
||||||
/// *Since 1.6.0* — Workstream G's `module_host.*` methods (hello handshake
|
/// *Since 1.6.0* — Workstream G's `module_host.*` methods (hello handshake
|
||||||
/// plus the RPC bridge a `bread-module-host` child uses in place of direct
|
/// plus the RPC bridge a `bread-module-host` child uses in place of direct
|
||||||
/// in-process `bread.*` bindings).
|
/// in-process `bread.*` bindings).
|
||||||
const API_VERSION: &str = "1.6.0";
|
/// *Since 1.7.0* — well-formed `bread.command.<known-app>.<verb>` is an
|
||||||
|
/// explicit exception to the reserved-domain reject on unsourced emit, and
|
||||||
|
/// sourced `AdapterSource::App` emit may publish commands to another known
|
||||||
|
/// app. `command` stays in `RESERVED_DOMAINS` so it cannot be claimed as
|
||||||
|
/// an app id.
|
||||||
|
const API_VERSION: &str = "1.7.1";
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Server {
|
pub struct Server {
|
||||||
|
|
@ -325,13 +332,17 @@ impl Server {
|
||||||
};
|
};
|
||||||
// For a sibling-app source, `kind` is the full dotted event
|
// For a sibling-app source, `kind` is the full dotted event
|
||||||
// name (e.g. "bread.clip.copied"), not a bare suffix — it
|
// name (e.g. "bread.clip.copied"), not a bare suffix — it
|
||||||
// must live inside that app's own namespace.
|
// must live inside that app's own namespace. Well-formed
|
||||||
|
// `bread.command.<known-app>.<verb>` is the one exception:
|
||||||
|
// an app may publish a command addressed to another known
|
||||||
|
// app (see `validate_command_event`). Adapter namespaces
|
||||||
|
// (`bread.power.*`, `bread.hyprland.*`, ...) stay rejected.
|
||||||
if let AdapterSource::App(app) = &source {
|
if let AdapterSource::App(app) = &source {
|
||||||
if !validate_app_namespace(app, kind) {
|
if !validate_app_namespace(app, kind) && !validate_command_event(kind) {
|
||||||
return Err((
|
return Err((
|
||||||
id,
|
id,
|
||||||
format!(
|
format!(
|
||||||
"event '{kind}' is not in the '{app}' namespace (must start with 'bread.{app}.')"
|
"event '{kind}' is not in the '{app}' namespace (must start with 'bread.{app}.') and is not a well-formed command event"
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -363,7 +374,11 @@ impl Server {
|
||||||
// `bread.hyprland.*`, ...) — otherwise this path would
|
// `bread.hyprland.*`, ...) — otherwise this path would
|
||||||
// let any same-UID process impersonate a real adapter
|
// let any same-UID process impersonate a real adapter
|
||||||
// event with nothing downstream able to tell the
|
// event with nothing downstream able to tell the
|
||||||
// difference.
|
// difference. Well-formed `bread.command.<known-app>.<verb>`
|
||||||
|
// is the documented exception: the command bus is
|
||||||
|
// supposed to be publishable by any module or
|
||||||
|
// `bread-emit` caller. Other reserved domains, and
|
||||||
|
// `bread.command.<not-an-app>.*`, stay rejected.
|
||||||
let Some(event) = req.params.get("event").and_then(Value::as_str) else {
|
let Some(event) = req.params.get("event").and_then(Value::as_str) else {
|
||||||
return Err((id, "missing event name".to_string()));
|
return Err((id, "missing event name".to_string()));
|
||||||
};
|
};
|
||||||
|
|
@ -429,9 +444,9 @@ impl Server {
|
||||||
/// impersonate a real adapter-owned event namespace.
|
/// impersonate a real adapter-owned event namespace.
|
||||||
fn manual_emit(&self, event: &str, data: Value) -> std::result::Result<Value, String> {
|
fn manual_emit(&self, event: &str, data: Value) -> std::result::Result<Value, String> {
|
||||||
if let Some(domain) = event_domain(event) {
|
if let Some(domain) = event_domain(event) {
|
||||||
if is_reserved_domain(domain) {
|
if is_reserved_domain(domain) && !validate_command_event(event) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"event '{event}' claims the reserved '{domain}' domain — manual emit cannot impersonate an adapter-owned event; use a custom event name, or a sourced emit if this should go through the normalizer"
|
"event '{event}' claims the reserved '{domain}' domain — manual emit cannot impersonate an adapter-owned event; use a custom event name, a well-formed bread.command.<app>.<verb>, or a sourced emit if this should go through the normalizer"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
//! directly from Lua bypasses every check in this file — that's the
|
//! directly from Lua bypasses every check in this file — that's the
|
||||||
//! scenario the sandbox exists for, not this file.
|
//! scenario the sandbox exists for, not this file.
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Component, Path, PathBuf};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use bread_shared::{glob, ModuleHostHello, ModuleHostPush, ModulePermission, PermissionKind};
|
use bread_shared::{glob, ModuleHostHello, ModuleHostPush, ModulePermission, PermissionKind};
|
||||||
|
|
@ -349,10 +350,7 @@ impl Server {
|
||||||
Ok(json!({ "ok": true }))
|
Ok(json!({ "ok": true }))
|
||||||
}
|
}
|
||||||
"module_host.fs_read" => {
|
"module_host.fs_read" => {
|
||||||
if !permissions
|
if !permissions.iter().any(|p| p.kind == PermissionKind::FsRead) {
|
||||||
.iter()
|
|
||||||
.any(|p| p.kind == PermissionKind::FsRead)
|
|
||||||
{
|
|
||||||
return Err("fs.read not granted to this module".to_string());
|
return Err("fs.read not granted to this module".to_string());
|
||||||
}
|
}
|
||||||
let path = req
|
let path = req
|
||||||
|
|
@ -411,16 +409,17 @@ impl Server {
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.ok_or("missing cmd")?
|
.ok_or("missing cmd")?
|
||||||
.to_string();
|
.to_string();
|
||||||
if !bin_allowed(permissions, &cmd) {
|
let argv = exec_argv(permissions, &cmd)?;
|
||||||
return Err("command is outside this module's granted exec bin scope".to_string());
|
|
||||||
}
|
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
match std::process::Command::new("sh").arg("-c").arg(&cmd).status() {
|
match std::process::Command::new(&argv[0])
|
||||||
|
.args(&argv[1..])
|
||||||
|
.status()
|
||||||
|
{
|
||||||
Ok(status) if !status.success() => {
|
Ok(status) if !status.success() => {
|
||||||
warn!(cmd = %cmd, code = ?status.code(), "module_host.exec exited non-zero");
|
warn!(cmd = %argv[0], code = ?status.code(), "module_host.exec exited non-zero");
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!(cmd = %cmd, error = %e, "module_host.exec failed to spawn");
|
error!(cmd = %argv[0], error = %e, "module_host.exec failed to spawn");
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
@ -437,17 +436,16 @@ impl Server {
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.ok_or("missing cmd")?
|
.ok_or("missing cmd")?
|
||||||
.to_string();
|
.to_string();
|
||||||
if !bin_allowed(permissions, &cmd) {
|
let argv = exec_argv(permissions, &cmd)?;
|
||||||
return Err("command is outside this module's granted exec bin scope".to_string());
|
|
||||||
}
|
|
||||||
let timeout_ms = req
|
let timeout_ms = req
|
||||||
.params
|
.params
|
||||||
.get("timeout_ms")
|
.get("timeout_ms")
|
||||||
.and_then(Value::as_u64)
|
.and_then(Value::as_u64)
|
||||||
.unwrap_or(2000);
|
.unwrap_or(2000);
|
||||||
let handle =
|
let handle = tokio::task::spawn_blocking(move || {
|
||||||
tokio::task::spawn_blocking(move || {
|
std::process::Command::new(&argv[0])
|
||||||
std::process::Command::new("sh").arg("-c").arg(&cmd).output()
|
.args(&argv[1..])
|
||||||
|
.output()
|
||||||
});
|
});
|
||||||
match tokio::time::timeout(Duration::from_millis(timeout_ms + 500), handle).await {
|
match tokio::time::timeout(Duration::from_millis(timeout_ms + 500), handle).await {
|
||||||
Ok(Ok(Ok(out))) => Ok(json!({
|
Ok(Ok(Ok(out))) => Ok(json!({
|
||||||
|
|
@ -474,7 +472,9 @@ impl Server {
|
||||||
(
|
(
|
||||||
ModuleLoadState::LoadError,
|
ModuleLoadState::LoadError,
|
||||||
ModuleHostOutcome::LoadError(
|
ModuleHostOutcome::LoadError(
|
||||||
error.clone().unwrap_or_else(|| "module load failed".to_string()),
|
error
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "module load failed".to_string()),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
@ -501,13 +501,14 @@ impl Server {
|
||||||
|
|
||||||
/// Belt-and-suspenders path scoping for `fs_read`/`fs_write`: if the
|
/// Belt-and-suspenders path scoping for `fs_read`/`fs_write`: if the
|
||||||
/// manifest declared a `path` hint for this permission kind, the requested
|
/// manifest declared a `path` hint for this permission kind, the requested
|
||||||
/// path (after `~`-expansion) must fall under at least one granted prefix.
|
/// path (after `~`-expansion and canonicalize) must be a real descendant
|
||||||
/// No hint at all means this RPC-level check stays permissive (matching
|
/// of at least one granted prefix. No hint at all means this RPC-level
|
||||||
/// Workstream D's existing "un-hinted grant = ungated within that
|
/// check stays permissive (matching Workstream D's existing "un-hinted
|
||||||
/// namespace" behavior) — Landlock's own ruleset (built independently in
|
/// grant = ungated within that namespace" behavior) — Landlock's own
|
||||||
/// `module_host::apply_sandbox`) does NOT grant a filesystem rule for an
|
/// ruleset (built independently in `module_host::apply_sandbox`) does NOT
|
||||||
/// un-hinted permission, so the direct `os`/`io` escape hatch remains
|
/// grant a filesystem rule for an un-hinted permission, so the direct
|
||||||
/// kernel-denied for that case regardless of what this function returns.
|
/// `os`/`io` escape hatch remains kernel-denied for that case regardless
|
||||||
|
/// of what this function returns.
|
||||||
fn path_allowed(permissions: &[ModulePermission], kind: PermissionKind, path: &str) -> bool {
|
fn path_allowed(permissions: &[ModulePermission], kind: PermissionKind, path: &str) -> bool {
|
||||||
let hints: Vec<&String> = permissions
|
let hints: Vec<&String> = permissions
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -517,18 +518,93 @@ fn path_allowed(permissions: &[ModulePermission], kind: PermissionKind, path: &s
|
||||||
if hints.is_empty() {
|
if hints.is_empty() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
let expanded = bread_shared::expand_path(path);
|
let Some(requested) = resolve_scoped_path(path) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
hints.iter().any(|hint| {
|
hints.iter().any(|hint| {
|
||||||
let hint_expanded = bread_shared::expand_path(hint);
|
let Some(granted) = resolve_scoped_path(hint) else {
|
||||||
expanded.starts_with(&hint_expanded)
|
return false;
|
||||||
|
};
|
||||||
|
// Path::starts_with is component-wise; str::starts_with would let
|
||||||
|
// `/Wallpapers-evil` match a `/Wallpapers` grant.
|
||||||
|
requested.starts_with(&granted)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Same idea as [`path_allowed`] for `exec`'s `bin` hint: compares by file
|
/// Canonicalize for containment: existing paths resolve symlinks and `..`;
|
||||||
/// name (so `bin = "hyprpaper"` matches a command invoking
|
/// new files canonicalize the parent then re-join the filename. Anything
|
||||||
/// `/usr/bin/hyprpaper` as well as a bare `hyprpaper`) or an exact leading
|
/// that still contains `..` after lexical normalization is rejected.
|
||||||
/// token match.
|
fn resolve_scoped_path(path: &str) -> Option<PathBuf> {
|
||||||
fn bin_allowed(permissions: &[ModulePermission], cmd: &str) -> bool {
|
let expanded = bread_shared::expand_path(path);
|
||||||
|
if let Ok(canon) = expanded.canonicalize() {
|
||||||
|
return Some(canon);
|
||||||
|
}
|
||||||
|
let parent = expanded.parent().filter(|p| !p.as_os_str().is_empty());
|
||||||
|
if let (Some(parent), Some(name)) = (parent, expanded.file_name()) {
|
||||||
|
if let Ok(parent_canon) = parent.canonicalize() {
|
||||||
|
return Some(parent_canon.join(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lexical_abs(&expanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lexical_abs(path: &Path) -> Option<PathBuf> {
|
||||||
|
let abs = if path.is_absolute() {
|
||||||
|
path.to_path_buf()
|
||||||
|
} else {
|
||||||
|
std::path::absolute(path).ok()?
|
||||||
|
};
|
||||||
|
let mut out = PathBuf::new();
|
||||||
|
for c in abs.components() {
|
||||||
|
match c {
|
||||||
|
Component::CurDir => {}
|
||||||
|
Component::ParentDir => {
|
||||||
|
if !out.pop() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rest => out.push(rest),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.components().any(|c| matches!(c, Component::ParentDir)) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split `cmd` into argv without a shell. Metacharacters are rejected so a
|
||||||
|
/// hinted binary cannot smuggle extra commands (`hyprpaper; curl evil`).
|
||||||
|
fn parse_exec_argv(cmd: &str) -> Option<Vec<String>> {
|
||||||
|
if cmd.chars().any(|c| {
|
||||||
|
matches!(
|
||||||
|
c,
|
||||||
|
'|' | ';' | '&' | '$' | '`' | '\n' | '\r' | '<' | '>' | '(' | ')'
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let argv: Vec<String> = cmd.split_whitespace().map(str::to_string).collect();
|
||||||
|
if argv.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(argv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `cmd` and enforce the exec `bin` hint against argv[0].
|
||||||
|
fn exec_argv(permissions: &[ModulePermission], cmd: &str) -> Result<Vec<String>, String> {
|
||||||
|
let argv = parse_exec_argv(cmd)
|
||||||
|
.ok_or_else(|| "command contains shell metacharacters or is empty".to_string())?;
|
||||||
|
if !bin_allowed(permissions, &argv[0]) {
|
||||||
|
return Err("command is outside this module's granted exec bin scope".to_string());
|
||||||
|
}
|
||||||
|
Ok(argv)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same idea as [`path_allowed`] for `exec`'s `bin` hint: compares argv[0]
|
||||||
|
/// by file name (so `bin = "hyprpaper"` matches `/usr/bin/hyprpaper` as
|
||||||
|
/// well as a bare `hyprpaper`) or an exact path match.
|
||||||
|
fn bin_allowed(permissions: &[ModulePermission], program: &str) -> bool {
|
||||||
let hints: Vec<&String> = permissions
|
let hints: Vec<&String> = permissions
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|p| p.kind == PermissionKind::Exec)
|
.filter(|p| p.kind == PermissionKind::Exec)
|
||||||
|
|
@ -537,16 +613,109 @@ fn bin_allowed(permissions: &[ModulePermission], cmd: &str) -> bool {
|
||||||
if hints.is_empty() {
|
if hints.is_empty() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
let first_word = cmd.split_whitespace().next().unwrap_or("");
|
let cmd_leaf = Path::new(program)
|
||||||
let cmd_leaf = std::path::Path::new(first_word)
|
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|f| f.to_str())
|
.and_then(|f| f.to_str())
|
||||||
.unwrap_or(first_word);
|
.unwrap_or(program);
|
||||||
hints.iter().any(|hint| {
|
hints.iter().any(|hint| {
|
||||||
let hint_leaf = std::path::Path::new(hint.as_str())
|
let hint_leaf = Path::new(hint.as_str())
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|f| f.to_str())
|
.and_then(|f| f.to_str())
|
||||||
.unwrap_or(hint.as_str());
|
.unwrap_or(hint.as_str());
|
||||||
cmd_leaf == hint_leaf || first_word == hint.as_str()
|
cmd_leaf == hint_leaf || program == hint.as_str()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn fs_grant(kind: PermissionKind, path: &str) -> Vec<ModulePermission> {
|
||||||
|
vec![ModulePermission {
|
||||||
|
kind,
|
||||||
|
path: Some(path.to_string()),
|
||||||
|
bin: None,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exec_grant(bin: &str) -> Vec<ModulePermission> {
|
||||||
|
vec![ModulePermission {
|
||||||
|
kind: PermissionKind::Exec,
|
||||||
|
path: None,
|
||||||
|
bin: Some(bin.to_string()),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn path_allowed_denies_dotdot_escape_from_granted_prefix() {
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let wallpapers = tmp.path().join("Wallpapers");
|
||||||
|
std::fs::create_dir(&wallpapers).unwrap();
|
||||||
|
let granted = wallpapers.to_str().unwrap();
|
||||||
|
let perms = fs_grant(PermissionKind::FsRead, granted);
|
||||||
|
|
||||||
|
let escape = wallpapers.join("../../.ssh/id_rsa");
|
||||||
|
assert!(
|
||||||
|
!path_allowed(&perms, PermissionKind::FsRead, escape.to_str().unwrap()),
|
||||||
|
"Wallpapers/../../.ssh/id_rsa must not match a Wallpapers grant"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn path_allowed_denies_string_prefix_sibling() {
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let wallpapers = tmp.path().join("Wallpapers");
|
||||||
|
let evil = tmp.path().join("Wallpapers-evil");
|
||||||
|
std::fs::create_dir(&wallpapers).unwrap();
|
||||||
|
std::fs::create_dir(&evil).unwrap();
|
||||||
|
let secret = evil.join("secret");
|
||||||
|
std::fs::write(&secret, "x").unwrap();
|
||||||
|
let perms = fs_grant(PermissionKind::FsRead, wallpapers.to_str().unwrap());
|
||||||
|
assert!(!path_allowed(
|
||||||
|
&perms,
|
||||||
|
PermissionKind::FsRead,
|
||||||
|
secret.to_str().unwrap()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn path_allowed_accepts_real_descendant_and_new_file() {
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let wallpapers = tmp.path().join("Wallpapers");
|
||||||
|
std::fs::create_dir(&wallpapers).unwrap();
|
||||||
|
let existing = wallpapers.join("bg.png");
|
||||||
|
std::fs::write(&existing, "x").unwrap();
|
||||||
|
let perms = fs_grant(PermissionKind::FsWrite, wallpapers.to_str().unwrap());
|
||||||
|
assert!(path_allowed(
|
||||||
|
&perms,
|
||||||
|
PermissionKind::FsWrite,
|
||||||
|
existing.to_str().unwrap()
|
||||||
|
));
|
||||||
|
let new_file = wallpapers.join("new.png");
|
||||||
|
assert!(path_allowed(
|
||||||
|
&perms,
|
||||||
|
PermissionKind::FsWrite,
|
||||||
|
new_file.to_str().unwrap()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exec_argv_denies_shell_chaining_when_bin_hinted() {
|
||||||
|
let perms = exec_grant("hyprpaper");
|
||||||
|
assert!(exec_argv(&perms, "hyprpaper; curl evil").is_err());
|
||||||
|
assert!(exec_argv(&perms, "hyprpaper").is_ok());
|
||||||
|
assert!(exec_argv(&perms, "/usr/bin/hyprpaper --config x").is_ok());
|
||||||
|
assert!(exec_argv(&perms, "curl evil").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_exec_argv_rejects_metacharacters() {
|
||||||
|
assert!(parse_exec_argv("hyprpaper; curl evil").is_none());
|
||||||
|
assert!(parse_exec_argv("hyprpaper | curl evil").is_none());
|
||||||
|
assert!(parse_exec_argv("hyprpaper && curl evil").is_none());
|
||||||
|
assert_eq!(
|
||||||
|
parse_exec_argv("hyprpaper --config x"),
|
||||||
|
Some(vec!["hyprpaper".into(), "--config".into(), "x".into()])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -262,6 +262,7 @@ pub fn spawn_module_host(
|
||||||
let sandbox_bin_path = bin_path.clone();
|
let sandbox_bin_path = bin_path.clone();
|
||||||
let sandbox_module_name = module_name.to_string();
|
let sandbox_module_name = module_name.to_string();
|
||||||
let sandbox_entry_path = entry_path.to_path_buf();
|
let sandbox_entry_path = entry_path.to_path_buf();
|
||||||
|
let sandbox_socket_path = socket_path.to_path_buf();
|
||||||
// SAFETY: the closure runs in the forked child between fork() and
|
// SAFETY: the closure runs in the forked child between fork() and
|
||||||
// execve() (that's what pre_exec is for). It only touches its own
|
// execve() (that's what pre_exec is for). It only touches its own
|
||||||
// captured, already-allocated data plus filesystem/landlock syscalls —
|
// captured, already-allocated data plus filesystem/landlock syscalls —
|
||||||
|
|
@ -270,7 +271,13 @@ pub fn spawn_module_host(
|
||||||
// restricting a spawned child.
|
// restricting a spawned child.
|
||||||
unsafe {
|
unsafe {
|
||||||
cmd.pre_exec(move || {
|
cmd.pre_exec(move || {
|
||||||
apply_sandbox(&sandbox_bin_path, &sandbox_entry_path, &sandbox_permissions).map_err(|e| {
|
apply_sandbox(
|
||||||
|
&sandbox_bin_path,
|
||||||
|
&sandbox_entry_path,
|
||||||
|
&sandbox_socket_path,
|
||||||
|
&sandbox_permissions,
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
std::io::Error::other(format!(
|
std::io::Error::other(format!(
|
||||||
"landlock sandbox setup failed for module '{sandbox_module_name}': {e}"
|
"landlock sandbox setup failed for module '{sandbox_module_name}': {e}"
|
||||||
))
|
))
|
||||||
|
|
@ -451,6 +458,7 @@ pub fn resolve_module_host_binary() -> PathBuf {
|
||||||
fn apply_sandbox(
|
fn apply_sandbox(
|
||||||
module_host_bin: &Path,
|
module_host_bin: &Path,
|
||||||
entry_path: &Path,
|
entry_path: &Path,
|
||||||
|
socket_path: &Path,
|
||||||
permissions: &[ModulePermission],
|
permissions: &[ModulePermission],
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let abi = ABI::V1;
|
let abi = ABI::V1;
|
||||||
|
|
@ -509,6 +517,36 @@ fn apply_sandbox(
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The module-host must connect back to breadd over this Unix socket
|
||||||
|
// (BREAD_MODULE_SOCKET). Path-based AF_UNIX connect is mediated by
|
||||||
|
// Landlock as filesystem access — without a rule for the socket path
|
||||||
|
// (and walk access on its parent), UnixStream::connect fails with
|
||||||
|
// EACCES, the hello handshake never completes, and every
|
||||||
|
// out-of-process module times out with "did not report ready".
|
||||||
|
if let Some(socket_dir) = socket_path.parent() {
|
||||||
|
if let Ok(fd) = PathFd::new(socket_dir) {
|
||||||
|
ruleset = ruleset
|
||||||
|
.add_rule(PathBeneath::new(fd, read_only))
|
||||||
|
.map_err(|e| {
|
||||||
|
anyhow!(
|
||||||
|
"landlock rule for socket directory {}: {e}",
|
||||||
|
socket_dir.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if socket_path.exists() {
|
||||||
|
if let Ok(fd) = PathFd::new(socket_path) {
|
||||||
|
ruleset = ruleset
|
||||||
|
.add_rule(PathBeneath::new(fd, read_file_only))
|
||||||
|
.map_err(|e| {
|
||||||
|
anyhow!(
|
||||||
|
"landlock rule for socket {}: {e}",
|
||||||
|
socket_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for perm in permissions {
|
for perm in permissions {
|
||||||
match perm.kind {
|
match perm.kind {
|
||||||
|
|
@ -652,7 +690,12 @@ mod tests {
|
||||||
let sandbox_bin = sh_bin.clone();
|
let sandbox_bin = sh_bin.clone();
|
||||||
unsafe {
|
unsafe {
|
||||||
cmd.pre_exec(move || {
|
cmd.pre_exec(move || {
|
||||||
apply_sandbox(&sandbox_bin, Path::new("/nonexistent/dummy/entry.lua"), &permissions)
|
apply_sandbox(
|
||||||
|
&sandbox_bin,
|
||||||
|
Path::new("/nonexistent/dummy/entry.lua"),
|
||||||
|
Path::new("/nonexistent/dummy/breadd.sock"),
|
||||||
|
&permissions,
|
||||||
|
)
|
||||||
.map_err(|e| std::io::Error::other(e.to_string()))
|
.map_err(|e| std::io::Error::other(e.to_string()))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -707,7 +750,12 @@ mod tests {
|
||||||
let sandbox_bin = sh_bin.clone();
|
let sandbox_bin = sh_bin.clone();
|
||||||
unsafe {
|
unsafe {
|
||||||
cmd.pre_exec(move || {
|
cmd.pre_exec(move || {
|
||||||
apply_sandbox(&sandbox_bin, Path::new("/nonexistent/dummy/entry.lua"), &permissions)
|
apply_sandbox(
|
||||||
|
&sandbox_bin,
|
||||||
|
Path::new("/nonexistent/dummy/entry.lua"),
|
||||||
|
Path::new("/nonexistent/dummy/breadd.sock"),
|
||||||
|
&permissions,
|
||||||
|
)
|
||||||
.map_err(|e| std::io::Error::other(e.to_string()))
|
.map_err(|e| std::io::Error::other(e.to_string()))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -381,6 +381,126 @@ async fn emit_with_app_source_rejects_wrong_namespace() -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn emit_without_source_allows_well_formed_command_event() -> Result<()> {
|
||||||
|
let harness = TestHarness::spawn()?;
|
||||||
|
harness.wait_until_ready().await?;
|
||||||
|
|
||||||
|
// Unsourced `bread-emit bread.command.clip.clear` is the documented
|
||||||
|
// command-bus path — `command` is reserved so it cannot be an app id,
|
||||||
|
// but a well-formed command to a known app must still go through.
|
||||||
|
let result = harness
|
||||||
|
.send_request(
|
||||||
|
"emit",
|
||||||
|
json!({ "event": "bread.command.clip.clear", "data": {} }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"unsourced well-formed command event must be accepted: {result:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result.unwrap().get("emitted").and_then(Value::as_bool),
|
||||||
|
Some(true)
|
||||||
|
);
|
||||||
|
|
||||||
|
harness.shutdown();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn emit_without_source_rejects_command_to_non_app() -> Result<()> {
|
||||||
|
let harness = TestHarness::spawn()?;
|
||||||
|
harness.wait_until_ready().await?;
|
||||||
|
|
||||||
|
// `power` is reserved and is not a known app — this must not sneak
|
||||||
|
// through the command-bus exception.
|
||||||
|
let result = harness
|
||||||
|
.send_request(
|
||||||
|
"emit",
|
||||||
|
json!({ "event": "bread.command.power.off", "data": {} }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"command to a reserved/non-app target must be rejected"
|
||||||
|
);
|
||||||
|
|
||||||
|
harness.shutdown();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn emit_without_source_still_rejects_hyprland_namespace() -> Result<()> {
|
||||||
|
let harness = TestHarness::spawn()?;
|
||||||
|
harness.wait_until_ready().await?;
|
||||||
|
|
||||||
|
let result = harness
|
||||||
|
.send_request(
|
||||||
|
"emit",
|
||||||
|
json!({ "event": "bread.hyprland.workspace.changed", "data": {} }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"unsourced emit must still reject adapter-owned hyprland events"
|
||||||
|
);
|
||||||
|
|
||||||
|
harness.shutdown();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn emit_with_app_source_allows_command_to_another_app() -> Result<()> {
|
||||||
|
let harness = TestHarness::spawn()?;
|
||||||
|
harness.wait_until_ready().await?;
|
||||||
|
|
||||||
|
// An app may publish a command addressed to a different known app.
|
||||||
|
let result = harness
|
||||||
|
.send_request(
|
||||||
|
"emit",
|
||||||
|
json!({
|
||||||
|
"source": "cast",
|
||||||
|
"kind": "bread.command.clip.clear",
|
||||||
|
"data": {}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"sourced command to another known app must be accepted: {result:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
harness.shutdown();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn emit_with_app_source_still_rejects_foreign_app_namespace() -> Result<()> {
|
||||||
|
let harness = TestHarness::spawn()?;
|
||||||
|
harness.wait_until_ready().await?;
|
||||||
|
|
||||||
|
// `cast` must not be able to publish `bread.clip.*` events — only
|
||||||
|
// commands to clip, not clip's own inbound namespace.
|
||||||
|
let result = harness
|
||||||
|
.send_request(
|
||||||
|
"emit",
|
||||||
|
json!({
|
||||||
|
"source": "cast",
|
||||||
|
"kind": "bread.clip.copied",
|
||||||
|
"data": {}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"sourced emit must still reject a foreign app namespace"
|
||||||
|
);
|
||||||
|
|
||||||
|
harness.shutdown();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn state_get_returns_specific_subtree() -> Result<()> {
|
async fn state_get_returns_specific_subtree() -> Result<()> {
|
||||||
let harness = TestHarness::spawn()?;
|
let harness = TestHarness::spawn()?;
|
||||||
|
|
@ -496,7 +616,10 @@ return M
|
||||||
.await?;
|
.await?;
|
||||||
let entry = modules
|
let entry = modules
|
||||||
.as_array()
|
.as_array()
|
||||||
.and_then(|arr| arr.iter().find(|m| m.get("name").and_then(Value::as_str) == Some("scoped-test")))
|
.and_then(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.find(|m| m.get("name").and_then(Value::as_str) == Some("scoped-test"))
|
||||||
|
})
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or_else(|| anyhow!("scoped-test module not found in modules state; dump: {modules}"))?;
|
.ok_or_else(|| anyhow!("scoped-test module not found in modules state; dump: {modules}"))?;
|
||||||
|
|
||||||
|
|
@ -511,7 +634,9 @@ return M
|
||||||
"a module with a manifest that declares permissions must not be flagged ungated"
|
"a module with a manifest that declares permissions must not be flagged ungated"
|
||||||
);
|
);
|
||||||
|
|
||||||
let store = harness.trigger_and_await_result("test.scoped_result").await?;
|
let store = harness
|
||||||
|
.trigger_and_await_result("test.scoped_result")
|
||||||
|
.await?;
|
||||||
assert_eq!(store.get("state_get_ok"), Some(&json!(true)));
|
assert_eq!(store.get("state_get_ok"), Some(&json!(true)));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store.get("fs_present"),
|
store.get("fs_present"),
|
||||||
|
|
@ -525,8 +650,16 @@ return M
|
||||||
);
|
);
|
||||||
assert_eq!(store.get("exec_capture_present"), Some(&json!(false)));
|
assert_eq!(store.get("exec_capture_present"), Some(&json!(false)));
|
||||||
assert_eq!(store.get("bluetooth_present"), Some(&json!(false)));
|
assert_eq!(store.get("bluetooth_present"), Some(&json!(false)));
|
||||||
assert_eq!(store.get("json_present"), Some(&json!(true)), "baseline bread.json must still be present");
|
assert_eq!(
|
||||||
assert_eq!(store.get("log_present"), Some(&json!(true)), "baseline bread.log must still be present");
|
store.get("json_present"),
|
||||||
|
Some(&json!(true)),
|
||||||
|
"baseline bread.json must still be present"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store.get("log_present"),
|
||||||
|
Some(&json!(true)),
|
||||||
|
"baseline bread.log must still be present"
|
||||||
|
);
|
||||||
|
|
||||||
harness.shutdown();
|
harness.shutdown();
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -568,7 +701,10 @@ return M
|
||||||
.await?;
|
.await?;
|
||||||
let entry = modules
|
let entry = modules
|
||||||
.as_array()
|
.as_array()
|
||||||
.and_then(|arr| arr.iter().find(|m| m.get("name").and_then(Value::as_str) == Some("legacy-test")))
|
.and_then(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.find(|m| m.get("name").and_then(Value::as_str) == Some("legacy-test"))
|
||||||
|
})
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or_else(|| anyhow!("legacy-test module not found in modules state; dump: {modules}"))?;
|
.ok_or_else(|| anyhow!("legacy-test module not found in modules state; dump: {modules}"))?;
|
||||||
|
|
||||||
|
|
@ -647,7 +783,9 @@ return M
|
||||||
.find(|m| m.get("name").and_then(Value::as_str) == Some("empty-perms-test"))
|
.find(|m| m.get("name").and_then(Value::as_str) == Some("empty-perms-test"))
|
||||||
})
|
})
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or_else(|| anyhow!("empty-perms-test module not found in modules state; dump: {modules}"))?;
|
.ok_or_else(|| {
|
||||||
|
anyhow!("empty-perms-test module not found in modules state; dump: {modules}")
|
||||||
|
})?;
|
||||||
|
|
||||||
assert_eq!(entry.get("status").and_then(Value::as_str), Some("loaded"));
|
assert_eq!(entry.get("status").and_then(Value::as_str), Some("loaded"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -656,7 +794,9 @@ return M
|
||||||
"an explicit empty permissions list is a deliberate declaration, not 'undeclared'"
|
"an explicit empty permissions list is a deliberate declaration, not 'undeclared'"
|
||||||
);
|
);
|
||||||
|
|
||||||
let store = harness.trigger_and_await_result("test.empty_perms_result").await?;
|
let store = harness
|
||||||
|
.trigger_and_await_result("test.empty_perms_result")
|
||||||
|
.await?;
|
||||||
assert_eq!(store.get("fs_present"), Some(&json!(false)));
|
assert_eq!(store.get("fs_present"), Some(&json!(false)));
|
||||||
assert_eq!(store.get("state_present"), Some(&json!(false)));
|
assert_eq!(store.get("state_present"), Some(&json!(false)));
|
||||||
|
|
||||||
|
|
@ -969,7 +1109,10 @@ async fn event_causality_chain_threads_caused_by_across_handlers() -> Result<()>
|
||||||
// Lua handler — its `caused_by` must be None. Everything downstream
|
// Lua handler — its `caused_by` must be None. Everything downstream
|
||||||
// (X, Y, Z) is emitted by `bread.emit()` from inside a running handler.
|
// (X, Y, Z) is emitted by `bread.emit()` from inside a running handler.
|
||||||
harness
|
harness
|
||||||
.send_request("emit", json!({ "event": "bread.chain.trigger", "data": {} }))
|
.send_request(
|
||||||
|
"emit",
|
||||||
|
json!({ "event": "bread.chain.trigger", "data": {} }),
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let mut events: HashMap<String, Value> = HashMap::new();
|
let mut events: HashMap<String, Value> = HashMap::new();
|
||||||
|
|
@ -1038,8 +1181,12 @@ async fn event_causality_chain_threads_caused_by_across_handlers() -> Result<()>
|
||||||
"Z should be caused_by Y's id"
|
"Z should be caused_by Y's id"
|
||||||
);
|
);
|
||||||
|
|
||||||
let ids: std::collections::HashSet<&str> =
|
let ids: std::collections::HashSet<&str> = [
|
||||||
[trigger_id.as_str(), x_id.as_str(), y_id.as_str(), z_id.as_str()]
|
trigger_id.as_str(),
|
||||||
|
x_id.as_str(),
|
||||||
|
y_id.as_str(),
|
||||||
|
z_id.as_str(),
|
||||||
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.collect();
|
.collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -1169,7 +1316,10 @@ async fn rules_toml_absent_is_a_no_op() -> Result<()> {
|
||||||
rules_mod.get("status").and_then(Value::as_str),
|
rules_mod.get("status").and_then(Value::as_str),
|
||||||
Some("loaded")
|
Some("loaded")
|
||||||
);
|
);
|
||||||
assert!(rules_mod.get("last_error").and_then(Value::as_str).is_none());
|
assert!(rules_mod
|
||||||
|
.get("last_error")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_none());
|
||||||
|
|
||||||
harness.shutdown();
|
harness.shutdown();
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -1610,7 +1760,9 @@ enabled = false
|
||||||
// breadd itself would.
|
// breadd itself would.
|
||||||
let deadline = Instant::now() + Duration::from_secs(55);
|
let deadline = Instant::now() + Duration::from_secs(55);
|
||||||
while Instant::now() < deadline {
|
while Instant::now() < deadline {
|
||||||
let modules = self.send_request("state.get", json!({"key": "modules"})).await?;
|
let modules = self
|
||||||
|
.send_request("state.get", json!({"key": "modules"}))
|
||||||
|
.await?;
|
||||||
if let Some(arr) = modules.as_array() {
|
if let Some(arr) = modules.as_array() {
|
||||||
for m in arr {
|
for m in arr {
|
||||||
if m.get("name").and_then(Value::as_str) == Some(name) {
|
if m.get("name").and_then(Value::as_str) == Some(name) {
|
||||||
|
|
@ -1626,7 +1778,9 @@ enabled = false
|
||||||
}
|
}
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
}
|
}
|
||||||
Err(anyhow!("module '{name}' did not reach Loaded within timeout"))
|
Err(anyhow!(
|
||||||
|
"module '{name}' did not reach Loaded within timeout"
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribe to `result_event`, send a `test.trigger` manual emit to
|
/// Subscribe to `result_event`, send a `test.trigger` manual emit to
|
||||||
|
|
|
||||||
|
|
@ -137,11 +137,9 @@ enabled = false
|
||||||
async fn wait_until_ready(&self) -> Result<()> {
|
async fn wait_until_ready(&self) -> Result<()> {
|
||||||
let deadline = Instant::now() + Duration::from_secs(8);
|
let deadline = Instant::now() + Duration::from_secs(8);
|
||||||
while Instant::now() < deadline {
|
while Instant::now() < deadline {
|
||||||
if self.socket_path.exists() {
|
if self.socket_path.exists() && self.send_request("ping", json!({})).await.is_ok() {
|
||||||
if self.send_request("ping", json!({})).await.is_ok() {
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
sleep(Duration::from_millis(100)).await;
|
sleep(Duration::from_millis(100)).await;
|
||||||
}
|
}
|
||||||
Err(anyhow!("daemon did not become ready in time"))
|
Err(anyhow!("daemon did not become ready in time"))
|
||||||
|
|
|
||||||
1
ci/bread-ecosystem.rev
Normal file
1
ci/bread-ecosystem.rev
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
147cfbbf96ae4b171027defa1130d2caddb934b1
|
||||||
21
ci/build.sh
Executable file
21
ci/build.sh
Executable file
|
|
@ -0,0 +1,21 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Delegates to bread-ecosystem's shared CI build image/script, pinned to
|
||||||
|
# the commit in ci/bread-ecosystem.rev — not `main`. bread-ecosystem's CI
|
||||||
|
# files now affect every product's release pipeline, so bumping the pin
|
||||||
|
# is a deliberate act instead of silent drift (see the bread-theme test
|
||||||
|
# that broke here for exactly that reason, before it was pinned by rev).
|
||||||
|
#
|
||||||
|
# Usage: ci/build.sh cargo build --release --locked
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
REV="$(cat "${ROOT}/ci/bread-ecosystem.rev")"
|
||||||
|
|
||||||
|
CACHE_DIR="/tmp/bread-ecosystem-ci-${REV}"
|
||||||
|
if [ ! -d "$CACHE_DIR" ]; then
|
||||||
|
rm -rf /tmp/bread-ecosystem-ci-*
|
||||||
|
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "$CACHE_DIR"
|
||||||
|
git -C "$CACHE_DIR" checkout --quiet "$REV"
|
||||||
|
fi
|
||||||
|
|
||||||
|
bash "${CACHE_DIR}/ci/build.sh" bread "$ROOT" "$@"
|
||||||
|
|
@ -37,6 +37,7 @@ suggested `[[permissions]]` block for its own `bread.module.toml`.
|
||||||
| `low-battery-warning.lua` | Critical notification once when the battery runs low; resets on AC. | none |
|
| `low-battery-warning.lua` | Critical notification once when the battery runs low; resets on AC. | none |
|
||||||
| `pause-media-on-headphone-unplug.lua` | Runs `playerctl pause` when a headphone/earbud device disconnects. | none (needs `playerctl`) |
|
| `pause-media-on-headphone-unplug.lua` | Runs `playerctl pause` when a headphone/earbud device disconnects. | none (needs `playerctl`) |
|
||||||
| `dock-monitors.lua` | Applies a multi-monitor layout when an external display connects, reverts when removed. | edit output names/resolutions |
|
| `dock-monitors.lua` | Applies a multi-monitor layout when an external display connects, reverts when removed. | edit output names/resolutions |
|
||||||
|
| `external-monitors.lua` | Zero-config laptop displays: any HDMI/DP/USB-C head at its preferred mode, mirrored by default (or extended), lid-safe, restores the panel on unplug. | optional `ARRANGE` / `SCALE` at the top |
|
||||||
| `active-window-widget.lua` | Shows the focused window next to the workspace pills in breadbar, via `bread.widget` + `bread.state.watch`. | none |
|
| `active-window-widget.lua` | Shows the focused window next to the workspace pills in breadbar, via `bread.widget` + `bread.state.watch`. | none |
|
||||||
| `cpu-temp-widget/` | Live CPU temperature readout in breadbar's stats area, via `bread.widget` + `bread.fs.read` on a timer. Directory module with a `bread.module.toml` declaring `fs.read` + `widget` — the permission-manifest worked example. | edit `TEMP_PATH` for your hwmon layout |
|
| `cpu-temp-widget/` | Live CPU temperature readout in breadbar's stats area, via `bread.widget` + `bread.fs.read` on a timer. Directory module with a `bread.module.toml` declaring `fs.read` + `widget` — the permission-manifest worked example. | edit `TEMP_PATH` for your hwmon layout |
|
||||||
| `bluetooth-toggle-widget.lua` | One-click Bluetooth power toggle in breadbar's tray, via `bread.widget` + a click handler. | none |
|
| `bluetooth-toggle-widget.lua` | One-click Bluetooth power toggle in breadbar's tray, via `bread.widget` + a click handler. | none |
|
||||||
|
|
|
||||||
228
examples/modules/external-monitors.lua
Normal file
228
examples/modules/external-monitors.lua
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
-- external-monitors — behave like a normal laptop desktop
|
||||||
|
--
|
||||||
|
-- Plug in any display (HDMI, DisplayPort, USB-C dock, a random TV) and
|
||||||
|
-- the session just works. No output names to edit.
|
||||||
|
--
|
||||||
|
-- • the laptop panel stays at its preferred (native) mode
|
||||||
|
-- • each external uses its preferred mode and refresh
|
||||||
|
-- • new screens clone the laptop (set ARRANGE = "extend" to sit to the right)
|
||||||
|
-- • closing the lid does not sleep while an external is on
|
||||||
|
-- • unplug everything and the laptop is the only display again
|
||||||
|
--
|
||||||
|
-- Drop-in: copy to ~/.config/bread/modules/ and `bread reload`.
|
||||||
|
|
||||||
|
local M = bread.module({
|
||||||
|
name = "external-monitors",
|
||||||
|
version = "1.0.0",
|
||||||
|
after = { "bread.monitors" },
|
||||||
|
})
|
||||||
|
|
||||||
|
-- "mirror" = every external clones the laptop (presentations, TVs)
|
||||||
|
-- "extend" = extra desktop to the right
|
||||||
|
local ARRANGE = "mirror"
|
||||||
|
local SCALE = "auto"
|
||||||
|
|
||||||
|
local INTERNAL_RE = "^eDP"
|
||||||
|
local INHIBITOR = "/tmp/bread-lid-inhibitor.pid"
|
||||||
|
|
||||||
|
local function inhibit_lid()
|
||||||
|
if bread.fs.exists(INHIBITOR) then return end
|
||||||
|
bread.exec(
|
||||||
|
"bash -c 'systemd-inhibit --what=handle-lid-switch --who=bread "
|
||||||
|
.. "--why=external-display sleep infinity & echo $! > "
|
||||||
|
.. INHIBITOR
|
||||||
|
.. "'"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function release_lid()
|
||||||
|
bread.exec(
|
||||||
|
"bash -c 'kill $(cat " .. INHIBITOR .. " 2>/dev/null) 2>/dev/null; rm -f " .. INHIBITOR .. "'"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_internal(name)
|
||||||
|
return type(name) == "string" and name:match(INTERNAL_RE) ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function drm_status(name)
|
||||||
|
for card = 0, 5 do
|
||||||
|
local raw = bread.fs.read(string.format("/sys/class/drm/card%d-%s/status", card, name))
|
||||||
|
if raw then
|
||||||
|
return raw:match("^%s*(%S+)")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function drm_first_mode(name)
|
||||||
|
for card = 0, 5 do
|
||||||
|
local raw = bread.fs.read(string.format("/sys/class/drm/card%d-%s/modes", card, name))
|
||||||
|
if raw then
|
||||||
|
local w, h = raw:match("(%d+)x(%d+)")
|
||||||
|
if w then
|
||||||
|
return tonumber(w), tonumber(h)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return 1920, 1080
|
||||||
|
end
|
||||||
|
|
||||||
|
local function list_connectors()
|
||||||
|
local names = {}
|
||||||
|
local ok, out = bread.exec_capture("ls /sys/class/drm", { timeout_ms = 500 })
|
||||||
|
if not ok or not out then
|
||||||
|
return names
|
||||||
|
end
|
||||||
|
for ent in out:gmatch("[^%s]+") do
|
||||||
|
local name = ent:match("^card%d+%-(.+)$")
|
||||||
|
if name and not name:match("^Writeback") then
|
||||||
|
names[#names + 1] = name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(names)
|
||||||
|
return names
|
||||||
|
end
|
||||||
|
|
||||||
|
local function connected()
|
||||||
|
local internal, externals = nil, {}
|
||||||
|
for _, name in ipairs(list_connectors()) do
|
||||||
|
if drm_status(name) == "connected" then
|
||||||
|
if is_internal(name) then
|
||||||
|
internal = internal or name
|
||||||
|
else
|
||||||
|
externals[#externals + 1] = name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return internal or "eDP-1", externals
|
||||||
|
end
|
||||||
|
|
||||||
|
-- BOS Hyprland talks Lua (`hl.monitor`). Stock Hyprland uses the
|
||||||
|
-- `monitor=` keyword. Try eval first, then keyword.
|
||||||
|
local function apply_monitor(opts)
|
||||||
|
local extra = ""
|
||||||
|
if opts.mirror and opts.mirror ~= "" then
|
||||||
|
extra = string.format(", mirror = %q", opts.mirror)
|
||||||
|
end
|
||||||
|
local expr = string.format(
|
||||||
|
"hl.monitor({ output = %q, mode = %q, position = %q, scale = %q%s })",
|
||||||
|
opts.output,
|
||||||
|
opts.mode or "preferred",
|
||||||
|
opts.position or "0x0",
|
||||||
|
opts.scale or SCALE,
|
||||||
|
extra
|
||||||
|
)
|
||||||
|
local resp = bread.hyprland.eval(expr)
|
||||||
|
if type(resp) == "string" and resp:match("error") then
|
||||||
|
local spec = string.format(
|
||||||
|
"%s, %s, %s, %s",
|
||||||
|
opts.output,
|
||||||
|
opts.mode or "preferred",
|
||||||
|
opts.position or "0x0",
|
||||||
|
opts.scale or SCALE
|
||||||
|
)
|
||||||
|
if opts.mirror and opts.mirror ~= "" then
|
||||||
|
spec = spec .. ", mirror, " .. opts.mirror
|
||||||
|
end
|
||||||
|
bread.hyprland.keyword("monitor", spec)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function apply(internal, externals)
|
||||||
|
apply_monitor({
|
||||||
|
output = internal,
|
||||||
|
mode = "preferred",
|
||||||
|
position = "0x0",
|
||||||
|
scale = SCALE,
|
||||||
|
})
|
||||||
|
|
||||||
|
if ARRANGE == "mirror" then
|
||||||
|
for _, name in ipairs(externals) do
|
||||||
|
apply_monitor({
|
||||||
|
output = name,
|
||||||
|
mode = "preferred",
|
||||||
|
position = "0x0",
|
||||||
|
scale = SCALE,
|
||||||
|
mirror = internal,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local x = select(1, drm_first_mode(internal)) or 1920
|
||||||
|
for _, name in ipairs(externals) do
|
||||||
|
apply_monitor({
|
||||||
|
output = name,
|
||||||
|
mode = "preferred",
|
||||||
|
position = x .. "x0",
|
||||||
|
scale = SCALE,
|
||||||
|
})
|
||||||
|
local w = select(1, drm_first_mode(name)) or 1920
|
||||||
|
x = x + w
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.on_load()
|
||||||
|
local last = nil
|
||||||
|
local applied = false
|
||||||
|
|
||||||
|
local function evaluate()
|
||||||
|
local internal, externals = connected()
|
||||||
|
local sig = internal .. "|" .. table.concat(externals, ",")
|
||||||
|
if sig == last then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
last = sig
|
||||||
|
|
||||||
|
if #externals == 0 then
|
||||||
|
if applied then
|
||||||
|
apply_monitor({
|
||||||
|
output = internal,
|
||||||
|
mode = "preferred",
|
||||||
|
position = "0x0",
|
||||||
|
scale = SCALE,
|
||||||
|
})
|
||||||
|
release_lid()
|
||||||
|
applied = false
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
apply(internal, externals)
|
||||||
|
inhibit_lid()
|
||||||
|
applied = true
|
||||||
|
bread.log("[external-monitors] " .. internal .. " + " .. table.concat(externals, ", "))
|
||||||
|
end
|
||||||
|
|
||||||
|
local settle = bread.debounce(1500, evaluate)
|
||||||
|
|
||||||
|
bread.on("bread.hyprland.monitor.connected", function(event)
|
||||||
|
local name = event.data and event.data.name
|
||||||
|
if name and not is_internal(name) then
|
||||||
|
bread.notify("Display connected: " .. name, { urgency = "low" })
|
||||||
|
end
|
||||||
|
settle()
|
||||||
|
end)
|
||||||
|
|
||||||
|
bread.on("bread.hyprland.monitor.disconnected", function()
|
||||||
|
settle()
|
||||||
|
end)
|
||||||
|
|
||||||
|
bread.on("bread.device.**", function(event)
|
||||||
|
local sub = event.data and event.data.subsystem
|
||||||
|
if sub == "drm" then
|
||||||
|
settle()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
bread.hyprland.on_raw("configreloaded", function()
|
||||||
|
last = nil
|
||||||
|
evaluate()
|
||||||
|
end)
|
||||||
|
|
||||||
|
bread.every(3000, evaluate)
|
||||||
|
settle()
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1 +0,0 @@
|
||||||
{"files": {"code": ["/home/breadway/Projects/bread/bread-cli/src/hooks_git.rs", "/home/breadway/Projects/bread/bread-cli/src/hooks_shell.rs", "/home/breadway/Projects/bread/bread-cli/src/lib.rs", "/home/breadway/Projects/bread/bread-cli/src/main.rs", "/home/breadway/Projects/bread/bread-cli/src/modules_mgmt.rs", "/home/breadway/Projects/bread/bread-cli/tests/modules.rs", "/home/breadway/Projects/bread/bread-emit/src/main.rs", "/home/breadway/Projects/bread/bread-shared/src/apps.rs", "/home/breadway/Projects/bread/bread-shared/src/glob.rs", "/home/breadway/Projects/bread/bread-shared/src/lib.rs", "/home/breadway/Projects/bread/bread-shared/src/widget.rs", "/home/breadway/Projects/bread/breadd/src/adapters/bluetooth.rs", "/home/breadway/Projects/bread/breadd/src/adapters/filesystem.rs", "/home/breadway/Projects/bread/breadd/src/adapters/git.rs", "/home/breadway/Projects/bread/breadd/src/adapters/hyprland.rs", "/home/breadway/Projects/bread/breadd/src/adapters/mod.rs", "/home/breadway/Projects/bread/breadd/src/adapters/network.rs", "/home/breadway/Projects/bread/breadd/src/adapters/network_rtnetlink.rs", "/home/breadway/Projects/bread/breadd/src/adapters/podman.rs", "/home/breadway/Projects/bread/breadd/src/adapters/power.rs", "/home/breadway/Projects/bread/breadd/src/adapters/power_upower.rs", "/home/breadway/Projects/bread/breadd/src/adapters/systemd.rs", "/home/breadway/Projects/bread/breadd/src/adapters/udev.rs", "/home/breadway/Projects/bread/breadd/src/core/config.rs", "/home/breadway/Projects/bread/breadd/src/core/mod.rs", "/home/breadway/Projects/bread/breadd/src/core/normalizer.rs", "/home/breadway/Projects/bread/breadd/src/core/state_engine.rs", "/home/breadway/Projects/bread/breadd/src/core/subscriptions.rs", "/home/breadway/Projects/bread/breadd/src/core/supervisor.rs", "/home/breadway/Projects/bread/breadd/src/core/types.rs", "/home/breadway/Projects/bread/breadd/src/ipc/mod.rs", "/home/breadway/Projects/bread/breadd/src/lua/mod.rs", "/home/breadway/Projects/bread/breadd/src/main.rs", "/home/breadway/Projects/bread/breadd/tests/ipc_integration.rs", "/home/breadway/Projects/bread/examples/modules/active-window-widget.lua", "/home/breadway/Projects/bread/examples/modules/bluetooth-toggle-widget.lua", "/home/breadway/Projects/bread/examples/modules/cpu-temp-widget.lua", "/home/breadway/Projects/bread/examples/modules/dock-monitors.lua", "/home/breadway/Projects/bread/examples/modules/dock-workflow.lua", "/home/breadway/Projects/bread/examples/modules/focus-mode-widget.lua", "/home/breadway/Projects/bread/examples/modules/git-branch-widget.lua", "/home/breadway/Projects/bread/examples/modules/low-battery-warning.lua", "/home/breadway/Projects/bread/examples/modules/pause-media-on-headphone-unplug.lua", "/home/breadway/Projects/bread/examples/modules/workflow-status-widget.lua", "/home/breadway/Projects/bread/scripts/install.sh"], "document": ["/home/breadway/Projects/bread/.forgejo/workflows/dev-release.yml", "/home/breadway/Projects/bread/.forgejo/workflows/rc-release.yml", "/home/breadway/Projects/bread/.forgejo/workflows/release.yml", "/home/breadway/Projects/bread/CONTRIBUTING.md", "/home/breadway/Projects/bread/Documentation.md", "/home/breadway/Projects/bread/Examples.md", "/home/breadway/Projects/bread/README.md", "/home/breadway/Projects/bread/examples/modules/README.md", "/home/breadway/Projects/bread/packaging/README.md"], "paper": [], "image": [], "video": []}, "total_files": 54, "total_words": 53979, "needs_graph": true, "warning": null, "skipped_sensitive": [], "unclassified": ["/home/breadway/Projects/bread/.gitignore", "/home/breadway/Projects/bread/Cargo.toml", "/home/breadway/Projects/bread/LICENSE", "/home/breadway/Projects/bread/bakery.toml", "/home/breadway/Projects/bread/bread-cli/Cargo.toml", "/home/breadway/Projects/bread/bread-emit/Cargo.toml", "/home/breadway/Projects/bread/bread-shared/Cargo.toml", "/home/breadway/Projects/bread/breadd/Cargo.toml", "/home/breadway/Projects/bread/packaging/systemd/breadd.service"], "walk_errors": [], "ignored": ["/home/breadway/Projects/bread/.claude/", "/home/breadway/Projects/bread/CLAUDE.md", "/home/breadway/Projects/bread/Overview.md"], "pruned_noise_dirs": ["/home/breadway/Projects/bread/.git/", "/home/breadway/Projects/bread/.idea/", "/home/breadway/Projects/bread/graphify-out/", "/home/breadway/Projects/bread/target/"], "graphifyignore_patterns": 26, "scan_root": "/home/breadway/Projects/bread"}
|
|
||||||
95
graphify-out/.graphify_labels.json
Normal file
95
graphify-out/.graphify_labels.json
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
{
|
||||||
|
"0": "LuaEngine",
|
||||||
|
"1": "RawEvent",
|
||||||
|
"2": "config.rs",
|
||||||
|
"3": "Server",
|
||||||
|
"4": "widget.rs",
|
||||||
|
"5": "Bread Daemon (breadd)",
|
||||||
|
"6": "filesystem.rs",
|
||||||
|
"7": "git.rs",
|
||||||
|
"8": "Result",
|
||||||
|
"9": "state_engine.rs",
|
||||||
|
"10": "modules_mgmt.rs",
|
||||||
|
"11": "systemd.rs",
|
||||||
|
"12": "hooks_git.rs",
|
||||||
|
"13": "bread-cli/src/main.rs",
|
||||||
|
"14": "types.rs",
|
||||||
|
"15": "podman.rs",
|
||||||
|
"16": "Value",
|
||||||
|
"17": "Result",
|
||||||
|
"18": "hooks_shell.rs",
|
||||||
|
"19": "bluetooth.rs",
|
||||||
|
"20": "SubscriptionId",
|
||||||
|
"21": "glob.rs",
|
||||||
|
"22": "StateHandle",
|
||||||
|
"23": "Adapter",
|
||||||
|
"24": "network.rs",
|
||||||
|
"25": "power.rs",
|
||||||
|
"26": "hyprland.rs",
|
||||||
|
"27": "parse_upower_message",
|
||||||
|
"28": "main Branch",
|
||||||
|
"29": "git-branch-widget.lua",
|
||||||
|
"30": "TestHarness",
|
||||||
|
"31": "Sync",
|
||||||
|
"32": "active-window-widget.lua",
|
||||||
|
"33": ".new",
|
||||||
|
"34": "focus-mode-widget.lua",
|
||||||
|
"35": "workflow-status-widget.lua",
|
||||||
|
"36": "bread.widget API",
|
||||||
|
"37": "bluetooth-toggle-widget.lua",
|
||||||
|
"38": "pause-media-on-headphone-unplug.lua",
|
||||||
|
"39": "Autostart Example Module",
|
||||||
|
"40": "dock-monitors.lua",
|
||||||
|
"41": "dock-workflow.lua",
|
||||||
|
"42": "low-battery-warning.lua",
|
||||||
|
"43": "install.sh",
|
||||||
|
"44": "Filesystem Adapter",
|
||||||
|
"45": "Git Adapter",
|
||||||
|
"46": "Podman Adapter",
|
||||||
|
"47": "Systemd Adapter",
|
||||||
|
"48": "bread.after(delay_ms, fn)",
|
||||||
|
"49": "bread.bluetooth namespace",
|
||||||
|
"50": "bread.every(interval_ms, fn)",
|
||||||
|
"51": "bread.exec(cmd)",
|
||||||
|
"52": "bread.hyprland namespace",
|
||||||
|
"53": "bread.notify(message, opts)",
|
||||||
|
"54": "bread.state.watch(path, fn)",
|
||||||
|
"55": "bread-cli/src/lib.rs",
|
||||||
|
"56": "core/mod.rs",
|
||||||
|
"57": "bread.system.startup",
|
||||||
|
"58": "Monitors Configuration Example",
|
||||||
|
"59": "Binds Module (Built-in)",
|
||||||
|
"60": "lua/mod.rs",
|
||||||
|
"61": "external-monitors.lua",
|
||||||
|
"62": "AGENTS.md — Repo hygiene",
|
||||||
|
"63": "CLAUDE.md — Repo hygiene",
|
||||||
|
"64": "ModuleHostLua",
|
||||||
|
"65": "ModuleHostRegistry",
|
||||||
|
"66": "xtask/src/main.rs",
|
||||||
|
"67": "Bread",
|
||||||
|
"68": "rules.rs",
|
||||||
|
"69": "Normalized events",
|
||||||
|
"70": "Bread Documentation",
|
||||||
|
"71": "udev.rs",
|
||||||
|
"72": "Dictionary: Lua API",
|
||||||
|
"73": "Out-of-process module sandboxing *(Since: v1.6)*",
|
||||||
|
"74": "Dictionary: Built-in modules",
|
||||||
|
"75": "Events",
|
||||||
|
"76": "Machine and filesystem",
|
||||||
|
"77": "Contributing",
|
||||||
|
"78": "Bluetooth",
|
||||||
|
"79": "Widgets *(Since: v1.3)*",
|
||||||
|
"80": "Getting started",
|
||||||
|
"81": "Capability-scoped modules *(Since: v1.5)*",
|
||||||
|
"82": "Workflows *(Since: v1.2)*",
|
||||||
|
"83": "Timers",
|
||||||
|
"84": "State",
|
||||||
|
"85": "init.lua",
|
||||||
|
"86": "Execution",
|
||||||
|
"87": "packaging/README.md",
|
||||||
|
"88": "PathBuf",
|
||||||
|
"89": "build.sh",
|
||||||
|
"90": "beta Release Track",
|
||||||
|
"91": "dev Release Track",
|
||||||
|
"92": "stable Release Track"
|
||||||
|
}
|
||||||
1
graphify-out/.graphify_labels.json.sig
Normal file
1
graphify-out/.graphify_labels.json.sig
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"0": "dff8ca2baa85bb77", "1": "2213379d8e986e1b", "2": "055a5a9a2c0abb47", "3": "4e76d4b9b12f60a0", "4": "15d25492162f61da", "5": "c9e7c9b0b9f9532c", "6": "5dc03258b399572c", "7": "7e3d24ea3cd2b4cb", "8": "d2b23fb40be07856", "9": "c63bb5497aa74cc4", "10": "37be3ba834804202", "11": "e820233ee9e24e6c", "12": "90afbdba0e9131b5", "13": "a893df0933556d63", "14": "2b140ca807922de5", "15": "77fe3c708c10a92e", "16": "f46d2bf3ec15783d", "17": "9d5d3a5ebfca7e96", "18": "4b221b9f82894754", "19": "1af003225b1510f7", "20": "cec4f1891a3503a7", "21": "af54da4748cbfe19", "22": "755f92ef4fb448a8", "23": "8d61c62e8cd29e24", "24": "a6096a26ebcbe7ca", "25": "4bb3423b0748a5b8", "26": "f14069a977bb7109", "27": "c24364ae8589386f", "28": "b7dfd1e572840cf9", "29": "38bd4a3ed7d82df4", "30": "c7a227c868287ece", "31": "8298f357ade782b6", "32": "8c3f9c59418c0d78", "33": "d441528441bffad6", "34": "0ca065898bae7a4c", "35": "91d64164a425221e", "36": "8f4f1c63c1339596", "37": "f29f07965a593c46", "38": "69c04956206264ea", "39": "d5e6784916307537", "40": "9dd3e8ecc75d42d1", "41": "975bc2eaec9ade41", "42": "c119297bd4e771d7", "43": "5df098735d8e8ccf", "44": "3ea089711b226db5", "45": "937d314565bd1f7c", "46": "644deb50d1523213", "47": "a8ba58be12f4eaaf", "48": "07122a351039caad", "49": "85df939415e8bf76", "50": "ccecca45fa071d89", "51": "d12f6c2877bf28fc", "52": "8fde0947b8e44878", "53": "1894c9960c737861", "54": "8eb6456a35bc1424", "55": "266c21cbaa157140", "56": "3dd39e27b03e805b", "57": "9914e60060e52831", "58": "082ed57548a0aa26", "59": "1d8fa6f90af6c7a9", "60": "5b3a15eab473b681", "61": "53961581c79663fe", "62": "1c3daa39aab41ca2", "63": "cd7e1aedbdbb382a", "64": "793a68e5b584e15a", "65": "e5048b5a19d78c81", "66": "abaa846ce9fdc66e", "67": "a46e2827ad3261b0", "68": "56d1c4600f18d094", "69": "b29197139c1a5fc7", "70": "f6f5196c6a2f3084", "71": "f8b572524780f968", "72": "95bd136ecb3896b6", "73": "a2e1d482f5282a2b", "74": "96279e53156461bf", "75": "3c234d3ea2845544", "76": "37f5bfa38e9dda4b", "77": "c95e6689883cdc3d", "78": "784aa039bef2aabf", "79": "1e1cd6b488d8850c", "80": "42213e202400e524", "81": "bb8f3086e24c3f87", "82": "8f000e4f1c6edcd1", "83": "19573b318feb4438", "84": "47016ab7f59f2ab4", "85": "da1e8594a8fb071a", "86": "d730f8d81a4b91ff", "87": "af426938b90fa391", "88": "dce55fb2c83882bb", "89": "9d30c290d7898f81", "90": "356e8182b8067c22", "91": "825919e98a656895", "92": "c5d2ff6dacf845a7"}
|
||||||
|
|
@ -1 +1 @@
|
||||||
/home/breadway/Projects/bread
|
.
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
/home/breadway/Projects/bread/.forgejo/workflows/dev-release.yml
|
|
||||||
/home/breadway/Projects/bread/.forgejo/workflows/rc-release.yml
|
|
||||||
/home/breadway/Projects/bread/.forgejo/workflows/release.yml
|
|
||||||
/home/breadway/Projects/bread/CONTRIBUTING.md
|
|
||||||
/home/breadway/Projects/bread/Documentation.md
|
|
||||||
/home/breadway/Projects/bread/Examples.md
|
|
||||||
/home/breadway/Projects/bread/README.md
|
|
||||||
/home/breadway/Projects/bread/examples/modules/README.md
|
|
||||||
/home/breadway/Projects/bread/packaging/README.md
|
|
||||||
90
graphify-out/2026-08-16/.graphify_labels.json
Normal file
90
graphify-out/2026-08-16/.graphify_labels.json
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
{
|
||||||
|
"0": "lua/mod.rs",
|
||||||
|
"1": "RawEvent",
|
||||||
|
"2": "config.rs",
|
||||||
|
"3": "Server",
|
||||||
|
"4": "widget.rs",
|
||||||
|
"5": "Bread Daemon (breadd)",
|
||||||
|
"6": "filesystem.rs",
|
||||||
|
"7": "git.rs",
|
||||||
|
"8": "Result",
|
||||||
|
"9": "state_engine.rs",
|
||||||
|
"10": "modules_mgmt.rs",
|
||||||
|
"11": "systemd.rs",
|
||||||
|
"12": "hooks_git.rs",
|
||||||
|
"13": "bread-cli/src/main.rs",
|
||||||
|
"14": "types.rs",
|
||||||
|
"15": "podman.rs",
|
||||||
|
"16": "StateHandle",
|
||||||
|
"17": "run_udev_monitor",
|
||||||
|
"18": "hooks_shell.rs",
|
||||||
|
"19": "bluetooth.rs",
|
||||||
|
"20": "SubscriptionId",
|
||||||
|
"21": "glob.rs",
|
||||||
|
"22": "run_state_engine",
|
||||||
|
"23": "RtnetlinkAdapter",
|
||||||
|
"24": "network.rs",
|
||||||
|
"25": "power.rs",
|
||||||
|
"26": "hyprland.rs",
|
||||||
|
"27": "Adapter",
|
||||||
|
"28": "dl.breadway.dev Distribution",
|
||||||
|
"29": "git-branch-widget.lua",
|
||||||
|
"30": "TestHarness",
|
||||||
|
"31": "Sync",
|
||||||
|
"32": "active-window-widget.lua",
|
||||||
|
"33": "cpu-temp-widget.lua",
|
||||||
|
"34": "focus-mode-widget.lua",
|
||||||
|
"35": "workflow-status-widget.lua",
|
||||||
|
"36": "bread.widget API",
|
||||||
|
"37": "bluetooth-toggle-widget.lua",
|
||||||
|
"38": "pause-media-on-headphone-unplug.lua",
|
||||||
|
"39": "Autostart Example Module",
|
||||||
|
"40": "dock-monitors.lua",
|
||||||
|
"41": "dock-workflow.lua",
|
||||||
|
"42": "low-battery-warning.lua",
|
||||||
|
"43": "install.sh",
|
||||||
|
"44": "Filesystem Adapter",
|
||||||
|
"45": "Git Adapter",
|
||||||
|
"46": "Podman Adapter",
|
||||||
|
"47": "Systemd Adapter",
|
||||||
|
"48": "bread.after(delay_ms, fn)",
|
||||||
|
"49": "bread.bluetooth namespace",
|
||||||
|
"50": "bread.every(interval_ms, fn)",
|
||||||
|
"51": "bread.exec(cmd)",
|
||||||
|
"52": "bread.hyprland namespace",
|
||||||
|
"53": "bread.notify(message, opts)",
|
||||||
|
"54": "bread.state.watch(path, fn)",
|
||||||
|
"55": "bread-cli/src/lib.rs",
|
||||||
|
"56": "core/mod.rs",
|
||||||
|
"57": "bread.system.startup",
|
||||||
|
"58": "Monitors Configuration Example",
|
||||||
|
"59": "Binds Module (Built-in)",
|
||||||
|
"60": "Bakery Package Manager",
|
||||||
|
"61": "Phase 3: GUI Control Center",
|
||||||
|
"62": "Phase 5: Cross-Device Mesh",
|
||||||
|
"63": "Dev Version Computation",
|
||||||
|
"64": "ModuleHostLua",
|
||||||
|
"65": "ModuleHostRegistry",
|
||||||
|
"66": "xtask/src/main.rs",
|
||||||
|
"67": "Bread",
|
||||||
|
"68": "rules.rs",
|
||||||
|
"69": "Normalized events",
|
||||||
|
"70": "Bread Documentation",
|
||||||
|
"71": "udev.rs",
|
||||||
|
"72": "Dictionary: Lua API",
|
||||||
|
"73": "Out-of-process module sandboxing *(Since: v1.6)*",
|
||||||
|
"74": "Dictionary: Built-in modules",
|
||||||
|
"75": "Events",
|
||||||
|
"76": "Machine and filesystem",
|
||||||
|
"77": "Contributing",
|
||||||
|
"78": "Bluetooth",
|
||||||
|
"79": "Widgets *(Since: v1.3)*",
|
||||||
|
"80": "Getting started",
|
||||||
|
"81": "Capability-scoped modules *(Since: v1.5)*",
|
||||||
|
"82": "Workflows *(Since: v1.2)*",
|
||||||
|
"83": "Timers",
|
||||||
|
"84": "State",
|
||||||
|
"85": "init.lua",
|
||||||
|
"86": "Execution",
|
||||||
|
"87": "packaging/README.md"
|
||||||
|
}
|
||||||
397
graphify-out/2026-08-16/GRAPH_REPORT.md
Normal file
397
graphify-out/2026-08-16/GRAPH_REPORT.md
Normal file
|
|
@ -0,0 +1,397 @@
|
||||||
|
# Graph Report - bread (2026-08-15)
|
||||||
|
|
||||||
|
## Corpus Check
|
||||||
|
- 62 files · ~88,250 words
|
||||||
|
- Verdict: corpus is large enough that graph structure adds value.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
- 1467 nodes · 3347 edges · 88 communities (64 shown, 24 thin omitted)
|
||||||
|
- Extraction: 99% EXTRACTED · 1% INFERRED · 0% AMBIGUOUS · INFERRED: 48 edges (avg confidence: 0.78)
|
||||||
|
- Token cost: 0 input · 0 output
|
||||||
|
|
||||||
|
## Graph Freshness
|
||||||
|
- Built from commit: `a6973360`
|
||||||
|
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
|
||||||
|
- Run `graphify update .` after code changes (no API cost).
|
||||||
|
|
||||||
|
## Community Hubs (Navigation)
|
||||||
|
- lua/mod.rs
|
||||||
|
- RawEvent
|
||||||
|
- config.rs
|
||||||
|
- Server
|
||||||
|
- widget.rs
|
||||||
|
- Bread Daemon (breadd)
|
||||||
|
- filesystem.rs
|
||||||
|
- git.rs
|
||||||
|
- Result
|
||||||
|
- state_engine.rs
|
||||||
|
- modules_mgmt.rs
|
||||||
|
- systemd.rs
|
||||||
|
- hooks_git.rs
|
||||||
|
- bread-cli/src/main.rs
|
||||||
|
- types.rs
|
||||||
|
- podman.rs
|
||||||
|
- StateHandle
|
||||||
|
- run_udev_monitor
|
||||||
|
- hooks_shell.rs
|
||||||
|
- bluetooth.rs
|
||||||
|
- SubscriptionId
|
||||||
|
- glob.rs
|
||||||
|
- run_state_engine
|
||||||
|
- RtnetlinkAdapter
|
||||||
|
- network.rs
|
||||||
|
- power.rs
|
||||||
|
- hyprland.rs
|
||||||
|
- Adapter
|
||||||
|
- dl.breadway.dev Distribution
|
||||||
|
- git-branch-widget.lua
|
||||||
|
- TestHarness
|
||||||
|
- Sync
|
||||||
|
- active-window-widget.lua
|
||||||
|
- cpu-temp-widget.lua
|
||||||
|
- focus-mode-widget.lua
|
||||||
|
- workflow-status-widget.lua
|
||||||
|
- bread.widget API
|
||||||
|
- bluetooth-toggle-widget.lua
|
||||||
|
- pause-media-on-headphone-unplug.lua
|
||||||
|
- Autostart Example Module
|
||||||
|
- install.sh
|
||||||
|
- Filesystem Adapter
|
||||||
|
- Git Adapter
|
||||||
|
- Podman Adapter
|
||||||
|
- Systemd Adapter
|
||||||
|
- bread.after(delay_ms, fn)
|
||||||
|
- bread.bluetooth namespace
|
||||||
|
- bread.every(interval_ms, fn)
|
||||||
|
- bread.exec(cmd)
|
||||||
|
- bread.hyprland namespace
|
||||||
|
- bread.notify(message, opts)
|
||||||
|
- bread.state.watch(path, fn)
|
||||||
|
- bread.system.startup
|
||||||
|
- Monitors Configuration Example
|
||||||
|
- Binds Module (Built-in)
|
||||||
|
- Bakery Package Manager
|
||||||
|
- Phase 3: GUI Control Center
|
||||||
|
- Phase 5: Cross-Device Mesh
|
||||||
|
- Dev Version Computation
|
||||||
|
- ModuleHostLua
|
||||||
|
- ModuleHostRegistry
|
||||||
|
- xtask/src/main.rs
|
||||||
|
- Bread
|
||||||
|
- rules.rs
|
||||||
|
- Normalized events
|
||||||
|
- Bread Documentation
|
||||||
|
- udev.rs
|
||||||
|
- Dictionary: Lua API
|
||||||
|
- Out-of-process module sandboxing *(Since: v1.6)*
|
||||||
|
- Dictionary: Built-in modules
|
||||||
|
- Events
|
||||||
|
- Machine and filesystem
|
||||||
|
- Contributing
|
||||||
|
- Bluetooth
|
||||||
|
- Widgets *(Since: v1.3)*
|
||||||
|
- Getting started
|
||||||
|
- Capability-scoped modules *(Since: v1.5)*
|
||||||
|
- Workflows *(Since: v1.2)*
|
||||||
|
- Timers
|
||||||
|
- State
|
||||||
|
- init.lua
|
||||||
|
- Execution
|
||||||
|
- packaging/README.md
|
||||||
|
|
||||||
|
## God Nodes (most connected - your core abstractions)
|
||||||
|
1. `LuaEngine` - 59 edges
|
||||||
|
2. `RawEvent` - 49 edges
|
||||||
|
3. `BreadEvent` - 38 edges
|
||||||
|
4. `raw()` - 37 edges
|
||||||
|
5. `RuntimeState` - 34 edges
|
||||||
|
6. `StateHandle` - 28 edges
|
||||||
|
7. `now_unix_ms()` - 26 edges
|
||||||
|
8. `Adapter` - 25 edges
|
||||||
|
9. `SubscriptionId` - 25 edges
|
||||||
|
10. `ModuleHostLua` - 24 edges
|
||||||
|
|
||||||
|
## Surprising Connections (you probably didn't know these)
|
||||||
|
- `parse_bluetooth_message()` --calls--> `now_unix_ms()` [INFERRED]
|
||||||
|
breadd/src/adapters/bluetooth.rs → bread-shared/src/lib.rs
|
||||||
|
- `try_enumerate()` --calls--> `now_unix_ms()` [INFERRED]
|
||||||
|
breadd/src/adapters/bluetooth.rs → bread-shared/src/lib.rs
|
||||||
|
- `classify()` --calls--> `now_unix_ms()` [INFERRED]
|
||||||
|
breadd/src/adapters/filesystem.rs → bread-shared/src/lib.rs
|
||||||
|
- `network_raw_event()` --calls--> `now_unix_ms()` [INFERRED]
|
||||||
|
breadd/src/adapters/network.rs → bread-shared/src/lib.rs
|
||||||
|
- `power_raw_event()` --calls--> `now_unix_ms()` [INFERRED]
|
||||||
|
breadd/src/adapters/power.rs → bread-shared/src/lib.rs
|
||||||
|
|
||||||
|
## Import Cycles
|
||||||
|
- 2-file cycle: `breadd/src/core/state_engine.rs -> breadd/src/lua/mod.rs -> breadd/src/core/state_engine.rs`
|
||||||
|
- 2-file cycle: `bread-shared/src/lib.rs -> bread-shared/src/module_host_ipc.rs -> bread-shared/src/lib.rs`
|
||||||
|
|
||||||
|
## Hyperedges (group relationships)
|
||||||
|
- **** — ci_dev_release, workflow_version_compute, release_track_dev, distribution_dl_breadway_dev, package_bakery [INFERRED]
|
||||||
|
- **** — adapter_udev, adapter_hyprland, adapter_power, sys_bread_daemon, api_bread_on, sys_lua_runtime [INFERRED]
|
||||||
|
- **** — config_init_lua, config_modules_dir, pattern_module_skeleton, api_bread_on, sys_lua_runtime [INFERRED]
|
||||||
|
- **** — api_bread_workflow, api_bread_spawn, api_bread_wait, example_dock_workflow, api_bread_notify [INFERRED]
|
||||||
|
- **** — api_bread_widget, api_bread_every, example_widget_cpu_temp, api_bread_state_watch [INFERRED]
|
||||||
|
- **** — branch_main, ci_dev_release, ci_rc_release, ci_stable_release, release_track_dev, release_track_beta, release_track_stable [INFERRED]
|
||||||
|
|
||||||
|
## Communities (88 total, 24 thin omitted)
|
||||||
|
|
||||||
|
### Community 0 - "lua/mod.rs"
|
||||||
|
Cohesion: 0.06
|
||||||
|
Nodes (86): now_unix_ms(), ModulePermission, Option, String, WidgetSpec, RuntimeState, bluetooth_connect(), bluetooth_disconnect() (+78 more)
|
||||||
|
|
||||||
|
### Community 1 - "RawEvent"
|
||||||
|
Cohesion: 0.05
|
||||||
|
Nodes (67): adapter_source_is_hashable_and_eq(), AdapterSource, bread_event_new_accepts_owned_and_borrowed_names(), bread_event_new_assigns_unique_id_and_no_cause(), bread_event_new_sets_current_timestamp(), bread_event_with_timestamp_preserves_timestamp_and_assigns_id(), BreadEvent, DaemonSection (+59 more)
|
||||||
|
|
||||||
|
### Community 2 - "config.rs"
|
||||||
|
Cohesion: 0.07
|
||||||
|
Nodes (50): AdaptersConfig, AdapterToggle, compat_section_defaults_legacy_hyprland_names_to_true(), CompatConfig, Config, config_path(), config_path_falls_back_to_home_when_no_xdg(), config_path_respects_xdg_config_home() (+42 more)
|
||||||
|
|
||||||
|
### Community 3 - "Server"
|
||||||
|
Cohesion: 0.05
|
||||||
|
Nodes (41): A, command_target(), event_domain(), is_known_app(), is_reserved_domain(), Option, validate_app_namespace(), validate_command_event() (+33 more)
|
||||||
|
|
||||||
|
### Community 4 - "widget.rs"
|
||||||
|
Cohesion: 0.07
|
||||||
|
Nodes (32): accepts_node_count_at_max(), accepts_tree_at_max_depth(), Align, Background, box_of(), default_orientation(), FontWeight, is_valid_class() (+24 more)
|
||||||
|
|
||||||
|
### Community 5 - "Bread Daemon (breadd)"
|
||||||
|
Cohesion: 0.06
|
||||||
|
Nodes (39): Bluetooth Adapter, Hyprland Adapter, Network Adapter, Power Adapter, udev Adapter, bread.on(pattern, fn), bread.spawn(fn), bread.wait(pattern, opts) (+31 more)
|
||||||
|
|
||||||
|
### Community 6 - "filesystem.rs"
|
||||||
|
Cohesion: 0.12
|
||||||
|
Nodes (28): classify(), classify_build_artifact_created_in_target(), classify_debounces_rapid_repeat_events_for_same_path(), classify_file_changed_for_ordinary_source_file(), classify_silent_for_modify_in_target_not_create(), classify_silent_under_git(), classify_silent_under_node_modules(), detect_markers() (+20 more)
|
||||||
|
|
||||||
|
### Community 7 - "git.rs"
|
||||||
|
Cohesion: 0.12
|
||||||
|
Nodes (22): check_ahead_behind(), check_dirty(), discover_repos(), expand_roots(), expand_roots_globs_single_trailing_star(), expand_roots_skips_unreadable_glob_parent_without_panicking(), expand_roots_uses_literal_path_without_trailing_star(), GitAdapter (+14 more)
|
||||||
|
|
||||||
|
### Community 8 - "Result"
|
||||||
|
Cohesion: 0.15
|
||||||
|
Nodes (50): daemon_survives_repeated_reloads_and_pipeline_resumes(), emit_with_app_source_allows_command_to_another_app(), emit_with_app_source_rejects_wrong_namespace(), emit_with_app_source_still_rejects_foreign_app_namespace(), emit_with_internal_source_is_rejected(), emit_with_known_app_source_routes_through_normalizer(), emit_with_unregistered_app_source_is_rejected(), emit_without_event_errors() (+42 more)
|
||||||
|
|
||||||
|
### Community 9 - "state_engine.rs"
|
||||||
|
Cohesion: 0.12
|
||||||
|
Nodes (19): apply_device_change(), apply_event_to_state(), device_connect_adds_device_with_all_fields(), device_connect_is_idempotent_for_same_id(), device_disconnect_of_unknown_id_is_noop(), device_disconnect_removes_matching_id(), ev(), monitor_connect_adds_new_monitor() (+11 more)
|
||||||
|
|
||||||
|
### Community 10 - "modules_mgmt.rs"
|
||||||
|
Cohesion: 0.11
|
||||||
|
Nodes (36): audit_detects_fs_read_and_widget_from_cpu_temp_widget_style_module(), audit_extracts_exec_bin_hint_and_ignores_baseline_calls(), audit_module(), audit_scans_required_sibling_files_in_module_directory(), classify_call_site(), collect_lua_files(), copy_dir(), extract_first_string_arg() (+28 more)
|
||||||
|
|
||||||
|
### Community 11 - "systemd.rs"
|
||||||
|
Cohesion: 0.12
|
||||||
|
Nodes (17): active_state_to_kind(), failure_result(), get_unit_path(), handle_message(), is_failed_transition(), query_active_state(), Connection, HashMap (+9 more)
|
||||||
|
|
||||||
|
### Community 12 - "hooks_git.rs"
|
||||||
|
Cohesion: 0.15
|
||||||
|
Nodes (24): all_hook_scripts_exit_0_unconditionally(), all_hook_scripts_start_with_shebang_and_marker(), branch_changed_emit_line(), commit_created_emit_line(), emit_line_for(), git_dir(), hook_script(), hook_script_rejects_unknown_name() (+16 more)
|
||||||
|
|
||||||
|
### Community 13 - "bread-cli/src/main.rs"
|
||||||
|
Cohesion: 0.19
|
||||||
|
Nodes (29): CausalityTracker, Cli, Commands, config_directory(), daemon_socket_path(), format_timestamp(), handle_modules_cmd(), HooksCommand (+21 more)
|
||||||
|
|
||||||
|
### Community 14 - "types.rs"
|
||||||
|
Cohesion: 0.17
|
||||||
|
Nodes (21): Device, DeviceRule, DeviceTopology, InterfaceState, MatchCondition, ModuleStatus, Monitor, NetworkState (+13 more)
|
||||||
|
|
||||||
|
### Community 15 - "podman.rs"
|
||||||
|
Cohesion: 0.15
|
||||||
|
Nodes (17): container_event(), ignores_remove_event(), ignores_stop_event_to_avoid_double_emit_with_died(), ignores_unknown_action(), map_podman_event(), maps_died_event(), maps_health_status_event(), maps_start_event() (+9 more)
|
||||||
|
|
||||||
|
### Community 16 - "StateHandle"
|
||||||
|
Cohesion: 0.14
|
||||||
|
Nodes (10): condition_matches(), resolve_device(), Option, Result, String, Value, Vec, StateHandle (+2 more)
|
||||||
|
|
||||||
|
### Community 17 - "run_udev_monitor"
|
||||||
|
Cohesion: 0.35
|
||||||
|
Nodes (9): enumerate_with_udev(), Result, Self, Sender, String, Vec, run_udev_monitor(), ScannedDevice (+1 more)
|
||||||
|
|
||||||
|
### Community 18 - "hooks_shell.rs"
|
||||||
|
Cohesion: 0.18
|
||||||
|
Nodes (11): hook_scripts_background_every_emit_call(), hooks_dir(), install_shell(), join_line_continuations(), Option, PathBuf, Result, String (+3 more)
|
||||||
|
|
||||||
|
### Community 19 - "bluetooth.rs"
|
||||||
|
Cohesion: 0.15
|
||||||
|
Nodes (10): address_from_path(), BluetoothAdapter, parse_bluetooth_message(), Message, Option, Result, Self, Sender (+2 more)
|
||||||
|
|
||||||
|
### Community 20 - "SubscriptionId"
|
||||||
|
Cohesion: 0.29
|
||||||
|
Nodes (13): HashMap, String, Vec, Subscription, SubscriptionId, SubscriptionTable, table_add_assigns_provided_id_and_finds_match(), table_clear_removes_all() (+5 more)
|
||||||
|
|
||||||
|
### Community 22 - "run_state_engine"
|
||||||
|
Cohesion: 0.27
|
||||||
|
Nodes (12): dispatch_event(), handle_command(), Arc, AtomicU64, Receiver, RwLock, Self, Sender (+4 more)
|
||||||
|
|
||||||
|
### Community 23 - "RtnetlinkAdapter"
|
||||||
|
Cohesion: 0.22
|
||||||
|
Nodes (7): ip_from_bytes(), Option, Result, Self, Sender, String, RtnetlinkAdapter
|
||||||
|
|
||||||
|
### Community 24 - "network.rs"
|
||||||
|
Cohesion: 0.27
|
||||||
|
Nodes (9): has_default_route(), network_raw_event(), NetworkAdapter, NetworkSnapshot, read_network_state(), BTreeMap, Result, Sender (+1 more)
|
||||||
|
|
||||||
|
### Community 25 - "power.rs"
|
||||||
|
Cohesion: 0.26
|
||||||
|
Nodes (8): power_raw_event(), PowerAdapter, PowerSnapshot, read_power_state(), Option, Result, Self, Sender
|
||||||
|
|
||||||
|
### Community 26 - "hyprland.rs"
|
||||||
|
Cohesion: 0.29
|
||||||
|
Nodes (7): hyprland_event_socket(), HyprlandAdapter, parse_hyprland_line(), PathBuf, Result, Sender, String
|
||||||
|
|
||||||
|
### Community 27 - "Adapter"
|
||||||
|
Cohesion: 0.23
|
||||||
|
Nodes (8): Adapter, parse_upower_message(), Message, Result, Self, Sender, UPowerAdapter, Send
|
||||||
|
|
||||||
|
### Community 28 - "dl.breadway.dev Distribution"
|
||||||
|
Cohesion: 0.25
|
||||||
|
Nodes (8): main Branch, dev-release.yml Workflow, rc-release.yml Workflow, release.yml Workflow, dl.breadway.dev Distribution, beta Release Track, dev Release Track, stable Release Track
|
||||||
|
|
||||||
|
### Community 29 - "git-branch-widget.lua"
|
||||||
|
Cohesion: 0.62
|
||||||
|
Nodes (6): focused_tab_cwd(), git_info(), M.on_load(), shell_quote(), update(), widget_root()
|
||||||
|
|
||||||
|
### Community 30 - "TestHarness"
|
||||||
|
Cohesion: 0.16
|
||||||
|
Nodes (16): main(), parse_args(), Option, String, killing_a_module_host_child_does_not_take_down_breadd_or_other_modules(), os_execute_and_io_open_are_denied_at_the_kernel_level_outside_granted_scope(), Child, Drop (+8 more)
|
||||||
|
|
||||||
|
### Community 31 - "Sync"
|
||||||
|
Cohesion: 0.50
|
||||||
|
Nodes (3): main(), Result, Sync
|
||||||
|
|
||||||
|
### Community 32 - "active-window-widget.lua"
|
||||||
|
Cohesion: 0.83
|
||||||
|
Nodes (3): label_for(), M.on_load(), widget_root()
|
||||||
|
|
||||||
|
### Community 33 - "cpu-temp-widget.lua"
|
||||||
|
Cohesion: 0.83
|
||||||
|
Nodes (3): M.on_load(), read_temp_c(), widget_root()
|
||||||
|
|
||||||
|
### Community 34 - "focus-mode-widget.lua"
|
||||||
|
Cohesion: 1.00
|
||||||
|
Nodes (3): is_focused(), M.on_load(), widget_root()
|
||||||
|
|
||||||
|
### Community 35 - "workflow-status-widget.lua"
|
||||||
|
Cohesion: 0.83
|
||||||
|
Nodes (3): M.on_load(), most_relevant(), widget_update()
|
||||||
|
|
||||||
|
### Community 36 - "bread.widget API"
|
||||||
|
Cohesion: 0.67
|
||||||
|
Nodes (3): bread.widget API, CPU Temperature Widget Example, Live Widget Update Pattern
|
||||||
|
|
||||||
|
### Community 64 - "ModuleHostLua"
|
||||||
|
Cohesion: 0.10
|
||||||
|
Nodes (38): call(), HostMessage, IoCommand, RpcResponse, Duration, Option, PathBuf, Receiver (+30 more)
|
||||||
|
|
||||||
|
### Community 65 - "ModuleHostRegistry"
|
||||||
|
Cohesion: 0.07
|
||||||
|
Nodes (43): bin_allowed(), path_allowed(), HashMap, Option, OwnedWriteHalf, Result, Sender, String (+35 more)
|
||||||
|
|
||||||
|
### Community 66 - "xtask/src/main.rs"
|
||||||
|
Cohesion: 0.11
|
||||||
|
Nodes (36): BTreeSet, ExitCode, check(), CheckReport, clean_state_passes(), extract_cli_commands(), extract_enum_variants(), extract_ipc_methods() (+28 more)
|
||||||
|
|
||||||
|
### Community 67 - "Bread"
|
||||||
|
Cohesion: 0.06
|
||||||
|
Nodes (28): Deprecations, Hyprland legacy flat event names (since v1.5), Bread Examples, Example 1: Porting keyboard_and_display_watcher.sh (system script), Example 2: Porting autostart.lua, Example 3: Porting display/monitors.lua, Example 4: Multi-step automation workflows, Example 5: A live widget in breadbar (+20 more)
|
||||||
|
|
||||||
|
### Community 68 - "rules.rs"
|
||||||
|
Cohesion: 0.14
|
||||||
|
Nodes (27): empty_file_loads_with_no_rules(), empty_on_is_treated_as_missing(), invalid_toml_is_fatal(), load_rules(), missing_on_is_reported_with_index_and_no_on(), multiple_action_keys_is_reported(), one_bad_rule_does_not_block_other_valid_rules(), ParsedRule (+19 more)
|
||||||
|
|
||||||
|
### Community 69 - "Normalized events"
|
||||||
|
Cohesion: 0.12
|
||||||
|
Nodes (16): Bluetooth (BlueZ), Compatibility: `[compat]` config, Devices (udev / Bluetooth), Filesystem / project detection, Git (hooks + dirty-state poller), Hyprland, Network, Normalized events (+8 more)
|
||||||
|
|
||||||
|
### Community 70 - "Bread Documentation"
|
||||||
|
Cohesion: 0.14
|
||||||
|
Nodes (14): API Stability & Versioning, Bread Documentation, Contents, Debugging tips, Dictionary: Event reference, Dictionary: IPC protocol, Dictionary: Runtime state schema, Integrating a bread\* app (+6 more)
|
||||||
|
|
||||||
|
### Community 71 - "udev.rs"
|
||||||
|
Cohesion: 0.32
|
||||||
|
Nodes (10): build_device_event(), build_event(), enumerate_payload_includes_classification_fields(), prop_bool(), prop_str(), Option, Value, udev_event_payload() (+2 more)
|
||||||
|
|
||||||
|
### Community 72 - "Dictionary: Lua API"
|
||||||
|
Cohesion: 0.17
|
||||||
|
Nodes (12): `bread.debounce(delay_ms, fn) -> wrapped_fn`, `bread.log(msg)` / `bread.warn(msg)` / `bread.error(msg)`, `bread.notify(message, opts)`, `bread.profile.activate(name)`, Dictionary: Lua API, Hyprland, Module declaration, Module lifecycle hooks (+4 more)
|
||||||
|
|
||||||
|
### Community 73 - "Out-of-process module sandboxing *(Since: v1.6)*"
|
||||||
|
Cohesion: 0.20
|
||||||
|
Nodes (10): Architecture, Crash isolation, New IPC methods, Out-of-process module sandboxing *(Since: v1.6)*, RPC bridge coverage, The gap this closes, The Landlock sandbox, The token/identity handshake (+2 more)
|
||||||
|
|
||||||
|
### Community 74 - "Dictionary: Built-in modules"
|
||||||
|
Cohesion: 0.20
|
||||||
|
Nodes (10): `bread.binds`, `bread.devices`, `bread.monitors`, `bread.rules` *(Since: v1.5)*, `bread.workspaces`, Device rule options, Dictionary: Built-in modules, Example: Dock-specific setup (+2 more)
|
||||||
|
|
||||||
|
### Community 75 - "Events"
|
||||||
|
Cohesion: 0.20
|
||||||
|
Nodes (10): `bread.emit(event, data)`, `bread.filter(pattern, fn, opts) -> id`, `bread.off(id)`, `bread.on(pattern, fn) -> id`, `bread.once(pattern, fn) -> id`, `bread.spawn(fn)`, `bread.wait_all(patterns, opts) -> table` *(Since: v1.2)*, `bread.wait_any(patterns, opts) -> event | nil` *(Since: v1.2)* (+2 more)
|
||||||
|
|
||||||
|
### Community 76 - "Machine and filesystem"
|
||||||
|
Cohesion: 0.20
|
||||||
|
Nodes (10): `bread.fs.exists(path) -> bool`, `bread.fs.expand(path) -> string`, `bread.fs.read(path) -> string | nil`, `bread.fs.readlink(path) -> string | nil`, `bread.fs.write(path, content)`, `bread.json.decode(str) -> table | nil`, `bread.machine.has_tag(tag) -> bool`, `bread.machine.name() -> string` (+2 more)
|
||||||
|
|
||||||
|
### Community 77 - "Contributing"
|
||||||
|
Cohesion: 0.22
|
||||||
|
Nodes (8): Branches, CI, Contributing, Keeping the API docs honest, Local development, Questions, The release cycle, Tracks, from a user's perspective
|
||||||
|
|
||||||
|
### Community 78 - "Bluetooth"
|
||||||
|
Cohesion: 0.22
|
||||||
|
Nodes (9): Bluetooth, `bread.bluetooth.connect(address)`, `bread.bluetooth.devices() -> table | nil`, `bread.bluetooth.disconnect(address)`, `bread.bluetooth.power(enabled)`, `bread.bluetooth.powered() -> bool | nil`, `bread.bluetooth.scan(enabled)`, Example: auto-connect headphones on AC power (+1 more)
|
||||||
|
|
||||||
|
### Community 79 - "Widgets *(Since: v1.3)*"
|
||||||
|
Cohesion: 0.22
|
||||||
|
Nodes (9): `bread.widget.list() -> table`, `bread.widget.register(spec) -> ok, err`, `bread.widget.remove(id) -> bool`, `bread.widget.update(id, patch) -> ok, err`, Click events, Node types, `style` *(Since: v1.4)*, Style vs. `class` (+1 more)
|
||||||
|
|
||||||
|
### Community 80 - "Getting started"
|
||||||
|
Cohesion: 0.33
|
||||||
|
Nodes (6): 1) Create a minimal config, 2) The fast path: `rules.toml` *(Since: v1.5)*, 3) Minimal `init.lua`, 4) Start the daemon, 5) Check that it's running, Getting started
|
||||||
|
|
||||||
|
### Community 81 - "Capability-scoped modules *(Since: v1.5)*"
|
||||||
|
Cohesion: 0.33
|
||||||
|
Nodes (6): Baseline (always available, no manifest entry needed), `bread modules audit <name>`, Capability-scoped modules *(Since: v1.5)*, Gated — requires a matching `[[permissions]]` entry, `path`/`bin` enforcement depends on where the module runs, `require("bread.devices")` still works from a scoped module
|
||||||
|
|
||||||
|
### Community 82 - "Workflows *(Since: v1.2)*"
|
||||||
|
Cohesion: 0.33
|
||||||
|
Nodes (6): `bread.workflow.define(name, fn)`, `bread.workflow.list() -> table`, `bread.workflow.start(name, opts)`, `bread.workflow.status(name) -> table | nil`, `bread.workflow.step(label)`, Workflows *(Since: v1.2)*
|
||||||
|
|
||||||
|
### Community 83 - "Timers"
|
||||||
|
Cohesion: 0.50
|
||||||
|
Nodes (4): `bread.after(delay_ms, fn) -> id`, `bread.cancel(id)`, `bread.every(interval_ms, fn) -> id`, Timers
|
||||||
|
|
||||||
|
### Community 84 - "State"
|
||||||
|
Cohesion: 0.50
|
||||||
|
Nodes (4): `bread.state.get(path)`, `bread.state.watch(path, fn) -> id`, State, Typed shorthands
|
||||||
|
|
||||||
|
### Community 85 - "init.lua"
|
||||||
|
Cohesion: 0.83
|
||||||
|
Nodes (3): M.on_load(), read_temp_c(), widget_root()
|
||||||
|
|
||||||
|
### Community 86 - "Execution"
|
||||||
|
Cohesion: 0.67
|
||||||
|
Nodes (3): `bread.exec_capture(cmd, opts) -> ok, stdout`, `bread.exec(cmd)`, Execution
|
||||||
|
|
||||||
|
## Knowledge Gaps
|
||||||
|
- **179 isolated node(s):** `install.sh script`, `Branches`, `The release cycle`, `Tracks, from a user's perspective`, `Keeping the API docs honest` (+174 more)
|
||||||
|
These have ≤1 connection - possible missing edges or undocumented components.
|
||||||
|
- **24 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||||
|
|
||||||
|
## Suggested Questions
|
||||||
|
_Questions this graph is uniquely positioned to answer:_
|
||||||
|
|
||||||
|
- **Why does `RawEvent` connect `RawEvent` to `Server`, `filesystem.rs`, `git.rs`, `udev.rs`, `systemd.rs`, `podman.rs`, `run_udev_monitor`, `bluetooth.rs`, `RtnetlinkAdapter`, `network.rs`, `power.rs`, `hyprland.rs`, `Adapter`?**
|
||||||
|
_High betweenness centrality (0.094) - this node is a cross-community bridge._
|
||||||
|
- **Why does `Adapter` connect `Adapter` to `Server`, `filesystem.rs`, `git.rs`, `udev.rs`, `systemd.rs`, `podman.rs`, `run_udev_monitor`, `bluetooth.rs`, `RtnetlinkAdapter`, `network.rs`, `power.rs`, `hyprland.rs`, `Sync`?**
|
||||||
|
_High betweenness centrality (0.077) - this node is a cross-community bridge._
|
||||||
|
- **Why does `BreadEvent` connect `RawEvent` to `ModuleHostLua`, `lua/mod.rs`, `ModuleHostRegistry`, `Server`, `state_engine.rs`, `run_state_engine`?**
|
||||||
|
_High betweenness centrality (0.063) - this node is a cross-community bridge._
|
||||||
|
- **What connects `install.sh script`, `Branches`, `The release cycle` to the rest of the system?**
|
||||||
|
_179 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||||
|
- **Should `lua/mod.rs` be split into smaller, more focused modules?**
|
||||||
|
_Cohesion score 0.056 - nodes in this community are weakly interconnected._
|
||||||
|
- **Should `RawEvent` be split into smaller, more focused modules?**
|
||||||
|
_Cohesion score 0.051842708517016396 - nodes in this community are weakly interconnected._
|
||||||
|
- **Should `config.rs` be split into smaller, more focused modules?**
|
||||||
|
_Cohesion score 0.07191780821917808 - nodes in this community are weakly interconnected._
|
||||||
18
graphify-out/2026-08-16/cost.json
Normal file
18
graphify-out/2026-08-16/cost.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"date": "2026-08-04T09:09:09.071813+00:00",
|
||||||
|
"input_tokens": 0,
|
||||||
|
"output_tokens": 0,
|
||||||
|
"files": 55
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-08-04T09:28:29.057021+00:00",
|
||||||
|
"input_tokens": 72759,
|
||||||
|
"output_tokens": 0,
|
||||||
|
"files": 55
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_input_tokens": 72759,
|
||||||
|
"total_output_tokens": 0
|
||||||
|
}
|
||||||
55512
graphify-out/2026-08-16/graph.json
Normal file
55512
graphify-out/2026-08-16/graph.json
Normal file
File diff suppressed because it is too large
Load diff
327
graphify-out/2026-08-16/manifest.json
Normal file
327
graphify-out/2026-08-16/manifest.json
Normal file
|
|
@ -0,0 +1,327 @@
|
||||||
|
{
|
||||||
|
"bread-cli/src/hooks_git.rs": {
|
||||||
|
"mtime": 1786800467.7200727,
|
||||||
|
"ast_hash": "7a29b5d5d90170f0aef9cececc7d8656",
|
||||||
|
"semantic_hash": "7a29b5d5d90170f0aef9cececc7d8656"
|
||||||
|
},
|
||||||
|
"bread-cli/src/hooks_shell.rs": {
|
||||||
|
"mtime": 1786800467.7201686,
|
||||||
|
"ast_hash": "c5d5b8922fcff79954ddb00496721603",
|
||||||
|
"semantic_hash": "c5d5b8922fcff79954ddb00496721603"
|
||||||
|
},
|
||||||
|
"bread-cli/src/lib.rs": {
|
||||||
|
"mtime": 1786800467.7201686,
|
||||||
|
"ast_hash": "d1bf6e1c239498521c42418f672bc400",
|
||||||
|
"semantic_hash": "d1bf6e1c239498521c42418f672bc400"
|
||||||
|
},
|
||||||
|
"bread-cli/src/main.rs": {
|
||||||
|
"mtime": 1786800467.7201686,
|
||||||
|
"ast_hash": "e1fbe63ea3b10e0887072ab7133d0508",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"bread-cli/src/modules_mgmt.rs": {
|
||||||
|
"mtime": 1786801244.1263742,
|
||||||
|
"ast_hash": "1cb6f3e4fa26108f81215ab57bb02b91",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"bread-cli/tests/modules.rs": {
|
||||||
|
"mtime": 1786801244.1297076,
|
||||||
|
"ast_hash": "84fca8f87dc915b1cb523f8199e78521",
|
||||||
|
"semantic_hash": "84fca8f87dc915b1cb523f8199e78521"
|
||||||
|
},
|
||||||
|
"bread-emit/src/main.rs": {
|
||||||
|
"mtime": 1786800467.7206197,
|
||||||
|
"ast_hash": "350cefbf697cfd93f7df7b7bcc45a101",
|
||||||
|
"semantic_hash": "350cefbf697cfd93f7df7b7bcc45a101"
|
||||||
|
},
|
||||||
|
"bread-shared/src/apps.rs": {
|
||||||
|
"mtime": 1786801218.1467261,
|
||||||
|
"ast_hash": "e8419571e7a322019d71c89559fb02fb",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"bread-shared/src/glob.rs": {
|
||||||
|
"mtime": 1786800467.7210522,
|
||||||
|
"ast_hash": "0594a313cb1909d1cca5fd5b8f241b68",
|
||||||
|
"semantic_hash": "0594a313cb1909d1cca5fd5b8f241b68"
|
||||||
|
},
|
||||||
|
"bread-shared/src/lib.rs": {
|
||||||
|
"mtime": 1786801244.1297076,
|
||||||
|
"ast_hash": "236b73bad29f1544a28fbf3ee1ccc795",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"bread-shared/src/widget.rs": {
|
||||||
|
"mtime": 1786801244.1297076,
|
||||||
|
"ast_hash": "33272eb38c7fffbfa180cef7c5a286fc",
|
||||||
|
"semantic_hash": "33272eb38c7fffbfa180cef7c5a286fc"
|
||||||
|
},
|
||||||
|
"breadd/src/adapters/bluetooth.rs": {
|
||||||
|
"mtime": 1786800467.7214499,
|
||||||
|
"ast_hash": "4d1df67347d5b7c9cdbcb12921a6ae28",
|
||||||
|
"semantic_hash": "4d1df67347d5b7c9cdbcb12921a6ae28"
|
||||||
|
},
|
||||||
|
"breadd/src/adapters/filesystem.rs": {
|
||||||
|
"mtime": 1786800467.7215972,
|
||||||
|
"ast_hash": "8ec9dcd8de13f30922cfbe9b4a3fff56",
|
||||||
|
"semantic_hash": "8ec9dcd8de13f30922cfbe9b4a3fff56"
|
||||||
|
},
|
||||||
|
"breadd/src/adapters/git.rs": {
|
||||||
|
"mtime": 1786800467.7215972,
|
||||||
|
"ast_hash": "6e6ff6ca318e28cd94690c30d41b520b",
|
||||||
|
"semantic_hash": "6e6ff6ca318e28cd94690c30d41b520b"
|
||||||
|
},
|
||||||
|
"breadd/src/adapters/hyprland.rs": {
|
||||||
|
"mtime": 1786800467.7215972,
|
||||||
|
"ast_hash": "71537dbd86b413d94476f8022054f1f4",
|
||||||
|
"semantic_hash": "71537dbd86b413d94476f8022054f1f4"
|
||||||
|
},
|
||||||
|
"breadd/src/adapters/mod.rs": {
|
||||||
|
"mtime": 1786800467.7215972,
|
||||||
|
"ast_hash": "63885887c2fc3ea2314d7fe095e5df61",
|
||||||
|
"semantic_hash": "63885887c2fc3ea2314d7fe095e5df61"
|
||||||
|
},
|
||||||
|
"breadd/src/adapters/network.rs": {
|
||||||
|
"mtime": 1786800467.7215972,
|
||||||
|
"ast_hash": "8706dc546b7e08d5aa084ca58c48559c",
|
||||||
|
"semantic_hash": "8706dc546b7e08d5aa084ca58c48559c"
|
||||||
|
},
|
||||||
|
"breadd/src/adapters/network_rtnetlink.rs": {
|
||||||
|
"mtime": 1786800467.7215972,
|
||||||
|
"ast_hash": "0c9d0bb2693bc46b11807f6b8ebb7c4c",
|
||||||
|
"semantic_hash": "0c9d0bb2693bc46b11807f6b8ebb7c4c"
|
||||||
|
},
|
||||||
|
"breadd/src/adapters/podman.rs": {
|
||||||
|
"mtime": 1786800467.7215972,
|
||||||
|
"ast_hash": "97c08551c0fe53b0a5888d98730d35db",
|
||||||
|
"semantic_hash": "97c08551c0fe53b0a5888d98730d35db"
|
||||||
|
},
|
||||||
|
"breadd/src/adapters/power.rs": {
|
||||||
|
"mtime": 1786800467.7215972,
|
||||||
|
"ast_hash": "987d202ec0d30ec26b1d747a6e320ed7",
|
||||||
|
"semantic_hash": "987d202ec0d30ec26b1d747a6e320ed7"
|
||||||
|
},
|
||||||
|
"breadd/src/adapters/power_upower.rs": {
|
||||||
|
"mtime": 1786800467.7215972,
|
||||||
|
"ast_hash": "fc0a36ca76d340c8be77644f63e462bf",
|
||||||
|
"semantic_hash": "fc0a36ca76d340c8be77644f63e462bf"
|
||||||
|
},
|
||||||
|
"breadd/src/adapters/systemd.rs": {
|
||||||
|
"mtime": 1786800467.7215972,
|
||||||
|
"ast_hash": "b3884dbecd39acc38abdf67589a8b954",
|
||||||
|
"semantic_hash": "b3884dbecd39acc38abdf67589a8b954"
|
||||||
|
},
|
||||||
|
"breadd/src/adapters/udev.rs": {
|
||||||
|
"mtime": 1786800978.7399695,
|
||||||
|
"ast_hash": "568993c8c2dd218879eec57aee50a6b9",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"breadd/src/core/config.rs": {
|
||||||
|
"mtime": 1786800467.7215972,
|
||||||
|
"ast_hash": "7f479bfb51647e6131c14f0c2f153d95",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"breadd/src/core/mod.rs": {
|
||||||
|
"mtime": 1786800467.7223108,
|
||||||
|
"ast_hash": "e38bfdb894eabd208941bd3ffb1ef464",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"breadd/src/core/normalizer.rs": {
|
||||||
|
"mtime": 1786801218.2233918,
|
||||||
|
"ast_hash": "1596f60df5049ff8715f66f64bee9f8a",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"breadd/src/core/state_engine.rs": {
|
||||||
|
"mtime": 1786800467.7223108,
|
||||||
|
"ast_hash": "84a35ee78f3881261a3ce378ea3c03a0",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"breadd/src/core/subscriptions.rs": {
|
||||||
|
"mtime": 1786800467.7223108,
|
||||||
|
"ast_hash": "8735d4717b74523c787dab9ea2b0bbd8",
|
||||||
|
"semantic_hash": "8735d4717b74523c787dab9ea2b0bbd8"
|
||||||
|
},
|
||||||
|
"breadd/src/core/supervisor.rs": {
|
||||||
|
"mtime": 1786800467.7223108,
|
||||||
|
"ast_hash": "403f7ba1807c71f9b89d81bfeff2681a",
|
||||||
|
"semantic_hash": "403f7ba1807c71f9b89d81bfeff2681a"
|
||||||
|
},
|
||||||
|
"breadd/src/core/types.rs": {
|
||||||
|
"mtime": 1786800467.7223108,
|
||||||
|
"ast_hash": "8bd070d4c09725d8717749a79aa42a92",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"breadd/src/ipc/mod.rs": {
|
||||||
|
"mtime": 1786800912.9916558,
|
||||||
|
"ast_hash": "54af229257e555313f7d4b59d57ff8d3",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"breadd/src/lua/mod.rs": {
|
||||||
|
"mtime": 1786801244.1297076,
|
||||||
|
"ast_hash": "ed13e2495399d532625653a22a4fee14",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"breadd/src/main.rs": {
|
||||||
|
"mtime": 1786800467.722855,
|
||||||
|
"ast_hash": "4b31888cc9e76210df0c7cff3a8b1e42",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"breadd/tests/ipc_integration.rs": {
|
||||||
|
"mtime": 1786801218.36339,
|
||||||
|
"ast_hash": "2b5bade2cc2e189a3af9c692232be258",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"examples/modules/active-window-widget.lua": {
|
||||||
|
"mtime": 1786800467.7243168,
|
||||||
|
"ast_hash": "a5f6fb2c54775a49ddaf29674f86571d",
|
||||||
|
"semantic_hash": "a5f6fb2c54775a49ddaf29674f86571d"
|
||||||
|
},
|
||||||
|
"examples/modules/bluetooth-toggle-widget.lua": {
|
||||||
|
"mtime": 1786800467.7243168,
|
||||||
|
"ast_hash": "c8529dc45862adf4f63db48674a28277",
|
||||||
|
"semantic_hash": "c8529dc45862adf4f63db48674a28277"
|
||||||
|
},
|
||||||
|
"examples/modules/dock-monitors.lua": {
|
||||||
|
"mtime": 1786800467.724451,
|
||||||
|
"ast_hash": "4abe51f19614d2bf117ac35e95324801",
|
||||||
|
"semantic_hash": "4abe51f19614d2bf117ac35e95324801"
|
||||||
|
},
|
||||||
|
"examples/modules/dock-workflow.lua": {
|
||||||
|
"mtime": 1786800467.724451,
|
||||||
|
"ast_hash": "84cf81cfb7780e89a46e96a6ccfbad15",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"examples/modules/focus-mode-widget.lua": {
|
||||||
|
"mtime": 1786800467.724451,
|
||||||
|
"ast_hash": "f62c366c2c85cfbe59eef663fc607230",
|
||||||
|
"semantic_hash": "f62c366c2c85cfbe59eef663fc607230"
|
||||||
|
},
|
||||||
|
"examples/modules/git-branch-widget.lua": {
|
||||||
|
"mtime": 1786800467.724451,
|
||||||
|
"ast_hash": "ebeeaebab5b5b29619f3db65c1df57d8",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"examples/modules/low-battery-warning.lua": {
|
||||||
|
"mtime": 1786800467.724451,
|
||||||
|
"ast_hash": "e0ba79860562fc36fa8cb183bc576fb7",
|
||||||
|
"semantic_hash": "e0ba79860562fc36fa8cb183bc576fb7"
|
||||||
|
},
|
||||||
|
"examples/modules/pause-media-on-headphone-unplug.lua": {
|
||||||
|
"mtime": 1786800467.724451,
|
||||||
|
"ast_hash": "6c4cf82b6963eb93df224bed60d06c2d",
|
||||||
|
"semantic_hash": "6c4cf82b6963eb93df224bed60d06c2d"
|
||||||
|
},
|
||||||
|
"examples/modules/workflow-status-widget.lua": {
|
||||||
|
"mtime": 1786800467.724451,
|
||||||
|
"ast_hash": "9f913a66a0f8654dadc1c5d6c2be0938",
|
||||||
|
"semantic_hash": "9f913a66a0f8654dadc1c5d6c2be0938"
|
||||||
|
},
|
||||||
|
"scripts/install.sh": {
|
||||||
|
"mtime": 1786800994.0764284,
|
||||||
|
"ast_hash": "35d1a7f63824e9176bd105ab3d699576",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"CONTRIBUTING.md": {
|
||||||
|
"mtime": 1786800467.716893,
|
||||||
|
"ast_hash": "6d056fcf0dae29949d19c41b41792879",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"Documentation.md": {
|
||||||
|
"mtime": 1786801027.2816515,
|
||||||
|
"ast_hash": "7b8471e7ae45aed70682bb858826860c",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"Examples.md": {
|
||||||
|
"mtime": 1786800467.716893,
|
||||||
|
"ast_hash": "6bcd7a14be53a9f67ef6912b160da8bc",
|
||||||
|
"semantic_hash": "6bcd7a14be53a9f67ef6912b160da8bc"
|
||||||
|
},
|
||||||
|
"README.md": {
|
||||||
|
"mtime": 1786800994.0964282,
|
||||||
|
"ast_hash": "97ae9f7efb9f2cdceb615a47cf3dfa1a",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"bread-module-host/src/io.rs": {
|
||||||
|
"mtime": 1786801244.1297076,
|
||||||
|
"ast_hash": "dbe04e05d1cfb02770db8d7c0bcc0b68",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"bread-module-host/src/lua_env.rs": {
|
||||||
|
"mtime": 1786801244.1297076,
|
||||||
|
"ast_hash": "97d009846e18d3c809663b1185c98bbb",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"bread-module-host/src/main.rs": {
|
||||||
|
"mtime": 1786801244.1297076,
|
||||||
|
"ast_hash": "9a93253ae44c764b8273df40999ddede",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"bread-shared/src/module_host_ipc.rs": {
|
||||||
|
"mtime": 1786801244.1297076,
|
||||||
|
"ast_hash": "fcdb4c03f242a5ea51fd3a096b711363",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"bread-shared/src/permissions.rs": {
|
||||||
|
"mtime": 1786800467.7210522,
|
||||||
|
"ast_hash": "800445ef9c90bdbf8f3ac9d4f1afd98e",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"breadd/src/core/rules.rs": {
|
||||||
|
"mtime": 1786801244.1297076,
|
||||||
|
"ast_hash": "bd430cf213f400b8d4e96ccc35a76ff1",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"breadd/src/ipc/module_host_bridge.rs": {
|
||||||
|
"mtime": 1786801244.1297076,
|
||||||
|
"ast_hash": "caa9cf82695e48c88758fcfa835e1871",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"breadd/src/module_host.rs": {
|
||||||
|
"mtime": 1786801244.1297076,
|
||||||
|
"ast_hash": "228826ff3c8941ea5f9bb28b66d637a9",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"breadd/tests/module_host_sandbox.rs": {
|
||||||
|
"mtime": 1786801244.1297076,
|
||||||
|
"ast_hash": "7e637b3a950c510215a6f6a7b88dd404",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"examples/modules/cpu-temp-widget/init.lua": {
|
||||||
|
"mtime": 1786800467.724451,
|
||||||
|
"ast_hash": "5ac893dd2f6af6d8b22dd290e3021f77",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"xtask/src/main.rs": {
|
||||||
|
"mtime": 1786801244.1297076,
|
||||||
|
"ast_hash": "e914dd77ac3241df09e3a681228961db",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
".forgejo/workflows/dev-release.yml": {
|
||||||
|
"mtime": 1786800467.7161875,
|
||||||
|
"ast_hash": "707129aa2fe79b4539d108c03e6d7abf",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
".forgejo/workflows/rc-release.yml": {
|
||||||
|
"mtime": 1786800467.7167187,
|
||||||
|
"ast_hash": "3007d2c43d785cd72f0548096626a21d",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
".forgejo/workflows/release.yml": {
|
||||||
|
"mtime": 1786800467.7167187,
|
||||||
|
"ast_hash": "3578aa39e7b2466822c8d85247489d36",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"DEPRECATIONS.md": {
|
||||||
|
"mtime": 1786800467.716893,
|
||||||
|
"ast_hash": "1685b60fad2ed4184e4119c30866ec12",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"examples/modules/README.md": {
|
||||||
|
"mtime": 1786800467.7242541,
|
||||||
|
"ast_hash": "dfa8a72f41c08bd5b8036b684092a93b",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"packaging/README.md": {
|
||||||
|
"mtime": 1786800994.0430956,
|
||||||
|
"ast_hash": "caa295eed64de126874a2e48b8aed8a2",
|
||||||
|
"semantic_hash": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
416
graphify-out/GRAPH_REPORT.md
Normal file
416
graphify-out/GRAPH_REPORT.md
Normal file
|
|
@ -0,0 +1,416 @@
|
||||||
|
# Graph Report - bread (2026-08-16)
|
||||||
|
|
||||||
|
## Corpus Check
|
||||||
|
- 66 files · ~90,429 words
|
||||||
|
- Verdict: corpus is large enough that graph structure adds value.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
- 1486 nodes · 3384 edges · 93 communities (68 shown, 25 thin omitted)
|
||||||
|
- Extraction: 99% EXTRACTED · 1% INFERRED · 0% AMBIGUOUS · INFERRED: 47 edges (avg confidence: 0.78)
|
||||||
|
- Token cost: 0 input · 0 output
|
||||||
|
|
||||||
|
## Graph Freshness
|
||||||
|
- Built from commit: `cdd5de8f`
|
||||||
|
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
|
||||||
|
- Run `graphify update .` after code changes (no API cost).
|
||||||
|
|
||||||
|
## Community Hubs (Navigation)
|
||||||
|
- LuaEngine
|
||||||
|
- RawEvent
|
||||||
|
- config.rs
|
||||||
|
- Server
|
||||||
|
- widget.rs
|
||||||
|
- Bread Daemon (breadd)
|
||||||
|
- filesystem.rs
|
||||||
|
- git.rs
|
||||||
|
- Result
|
||||||
|
- state_engine.rs
|
||||||
|
- modules_mgmt.rs
|
||||||
|
- systemd.rs
|
||||||
|
- hooks_git.rs
|
||||||
|
- bread-cli/src/main.rs
|
||||||
|
- types.rs
|
||||||
|
- podman.rs
|
||||||
|
- Value
|
||||||
|
- Result
|
||||||
|
- hooks_shell.rs
|
||||||
|
- bluetooth.rs
|
||||||
|
- SubscriptionId
|
||||||
|
- glob.rs
|
||||||
|
- StateHandle
|
||||||
|
- Adapter
|
||||||
|
- network.rs
|
||||||
|
- power.rs
|
||||||
|
- hyprland.rs
|
||||||
|
- parse_upower_message
|
||||||
|
- main Branch
|
||||||
|
- git-branch-widget.lua
|
||||||
|
- TestHarness
|
||||||
|
- Sync
|
||||||
|
- active-window-widget.lua
|
||||||
|
- .new
|
||||||
|
- focus-mode-widget.lua
|
||||||
|
- workflow-status-widget.lua
|
||||||
|
- bread.widget API
|
||||||
|
- bluetooth-toggle-widget.lua
|
||||||
|
- pause-media-on-headphone-unplug.lua
|
||||||
|
- Autostart Example Module
|
||||||
|
- install.sh
|
||||||
|
- Filesystem Adapter
|
||||||
|
- Git Adapter
|
||||||
|
- Podman Adapter
|
||||||
|
- Systemd Adapter
|
||||||
|
- bread.after(delay_ms, fn)
|
||||||
|
- bread.bluetooth namespace
|
||||||
|
- bread.every(interval_ms, fn)
|
||||||
|
- bread.exec(cmd)
|
||||||
|
- bread.hyprland namespace
|
||||||
|
- bread.notify(message, opts)
|
||||||
|
- bread.state.watch(path, fn)
|
||||||
|
- bread.system.startup
|
||||||
|
- Monitors Configuration Example
|
||||||
|
- Binds Module (Built-in)
|
||||||
|
- lua/mod.rs
|
||||||
|
- external-monitors.lua
|
||||||
|
- AGENTS.md — Repo hygiene
|
||||||
|
- CLAUDE.md — Repo hygiene
|
||||||
|
- ModuleHostLua
|
||||||
|
- ModuleHostRegistry
|
||||||
|
- xtask/src/main.rs
|
||||||
|
- Bread
|
||||||
|
- rules.rs
|
||||||
|
- Normalized events
|
||||||
|
- Bread Documentation
|
||||||
|
- udev.rs
|
||||||
|
- Dictionary: Lua API
|
||||||
|
- Out-of-process module sandboxing *(Since: v1.6)*
|
||||||
|
- Dictionary: Built-in modules
|
||||||
|
- Events
|
||||||
|
- Machine and filesystem
|
||||||
|
- Contributing
|
||||||
|
- Bluetooth
|
||||||
|
- Widgets *(Since: v1.3)*
|
||||||
|
- Getting started
|
||||||
|
- Capability-scoped modules *(Since: v1.5)*
|
||||||
|
- Workflows *(Since: v1.2)*
|
||||||
|
- Timers
|
||||||
|
- State
|
||||||
|
- init.lua
|
||||||
|
- Execution
|
||||||
|
- packaging/README.md
|
||||||
|
- PathBuf
|
||||||
|
- build.sh
|
||||||
|
- beta Release Track
|
||||||
|
- dev Release Track
|
||||||
|
- stable Release Track
|
||||||
|
|
||||||
|
## God Nodes (most connected - your core abstractions)
|
||||||
|
1. `LuaEngine` - 59 edges
|
||||||
|
2. `RawEvent` - 50 edges
|
||||||
|
3. `BreadEvent` - 38 edges
|
||||||
|
4. `raw()` - 37 edges
|
||||||
|
5. `RuntimeState` - 36 edges
|
||||||
|
6. `StateHandle` - 28 edges
|
||||||
|
7. `now_unix_ms()` - 25 edges
|
||||||
|
8. `Adapter` - 25 edges
|
||||||
|
9. `SubscriptionId` - 25 edges
|
||||||
|
10. `ModuleHostLua` - 24 edges
|
||||||
|
|
||||||
|
## Surprising Connections (you probably didn't know these)
|
||||||
|
- `parse_bluetooth_message()` --calls--> `now_unix_ms()` [INFERRED]
|
||||||
|
breadd/src/adapters/bluetooth.rs → bread-shared/src/lib.rs
|
||||||
|
- `try_enumerate()` --calls--> `now_unix_ms()` [INFERRED]
|
||||||
|
breadd/src/adapters/bluetooth.rs → bread-shared/src/lib.rs
|
||||||
|
- `classify()` --calls--> `now_unix_ms()` [INFERRED]
|
||||||
|
breadd/src/adapters/filesystem.rs → bread-shared/src/lib.rs
|
||||||
|
- `emit_topology_snapshot()` --calls--> `now_unix_ms()` [INFERRED]
|
||||||
|
breadd/src/adapters/hyprland.rs → bread-shared/src/lib.rs
|
||||||
|
- `network_raw_event()` --calls--> `now_unix_ms()` [INFERRED]
|
||||||
|
breadd/src/adapters/network.rs → bread-shared/src/lib.rs
|
||||||
|
|
||||||
|
## Import Cycles
|
||||||
|
- 2-file cycle: `breadd/src/core/state_engine.rs -> breadd/src/lua/mod.rs -> breadd/src/core/state_engine.rs`
|
||||||
|
- 2-file cycle: `bread-shared/src/lib.rs -> bread-shared/src/module_host_ipc.rs -> bread-shared/src/lib.rs`
|
||||||
|
|
||||||
|
## Hyperedges (group relationships)
|
||||||
|
- **** — adapter_udev, adapter_hyprland, adapter_power, sys_bread_daemon, api_bread_on, sys_lua_runtime [INFERRED]
|
||||||
|
- **** — config_init_lua, config_modules_dir, pattern_module_skeleton, api_bread_on, sys_lua_runtime [INFERRED]
|
||||||
|
- **** — api_bread_workflow, api_bread_spawn, api_bread_wait, example_dock_workflow, api_bread_notify [INFERRED]
|
||||||
|
- **** — api_bread_widget, api_bread_every, example_widget_cpu_temp, api_bread_state_watch [INFERRED]
|
||||||
|
|
||||||
|
## Communities (93 total, 25 thin omitted)
|
||||||
|
|
||||||
|
### Community 0 - "LuaEngine"
|
||||||
|
Cohesion: 0.11
|
||||||
|
Nodes (19): ErrorEntry, HandlerEntry, HandlerKind, LuaEngine, LuaMessage, ModuleInfo, AtomicU64, HashMap (+11 more)
|
||||||
|
|
||||||
|
### Community 1 - "RawEvent"
|
||||||
|
Cohesion: 0.06
|
||||||
|
Nodes (67): adapter_source_is_hashable_and_eq(), AdapterSource, bread_event_new_accepts_owned_and_borrowed_names(), bread_event_new_assigns_unique_id_and_no_cause(), bread_event_new_sets_current_timestamp(), bread_event_with_timestamp_preserves_timestamp_and_assigns_id(), BreadEvent, DaemonSection (+59 more)
|
||||||
|
|
||||||
|
### Community 2 - "config.rs"
|
||||||
|
Cohesion: 0.07
|
||||||
|
Nodes (50): AdaptersConfig, AdapterToggle, compat_section_defaults_legacy_hyprland_names_to_true(), CompatConfig, Config, config_path(), config_path_falls_back_to_home_when_no_xdg(), config_path_respects_xdg_config_home() (+42 more)
|
||||||
|
|
||||||
|
### Community 3 - "Server"
|
||||||
|
Cohesion: 0.05
|
||||||
|
Nodes (41): A, command_target(), event_domain(), is_known_app(), is_reserved_domain(), Option, validate_app_namespace(), validate_command_event() (+33 more)
|
||||||
|
|
||||||
|
### Community 4 - "widget.rs"
|
||||||
|
Cohesion: 0.07
|
||||||
|
Nodes (33): accepts_node_count_at_max(), accepts_tree_at_max_depth(), Align, Background, box_of(), default_orientation(), FontWeight, is_valid_class() (+25 more)
|
||||||
|
|
||||||
|
### Community 5 - "Bread Daemon (breadd)"
|
||||||
|
Cohesion: 0.06
|
||||||
|
Nodes (36): Bluetooth Adapter, Hyprland Adapter, Network Adapter, Power Adapter, udev Adapter, bread.on(pattern, fn), bread.spawn(fn), bread.wait(pattern, opts) (+28 more)
|
||||||
|
|
||||||
|
### Community 6 - "filesystem.rs"
|
||||||
|
Cohesion: 0.12
|
||||||
|
Nodes (28): classify(), classify_build_artifact_created_in_target(), classify_debounces_rapid_repeat_events_for_same_path(), classify_file_changed_for_ordinary_source_file(), classify_silent_for_modify_in_target_not_create(), classify_silent_under_git(), classify_silent_under_node_modules(), detect_markers() (+20 more)
|
||||||
|
|
||||||
|
### Community 7 - "git.rs"
|
||||||
|
Cohesion: 0.12
|
||||||
|
Nodes (22): check_ahead_behind(), check_dirty(), discover_repos(), expand_roots(), expand_roots_globs_single_trailing_star(), expand_roots_skips_unreadable_glob_parent_without_panicking(), expand_roots_uses_literal_path_without_trailing_star(), GitAdapter (+14 more)
|
||||||
|
|
||||||
|
### Community 8 - "Result"
|
||||||
|
Cohesion: 0.15
|
||||||
|
Nodes (50): daemon_survives_repeated_reloads_and_pipeline_resumes(), emit_with_app_source_allows_command_to_another_app(), emit_with_app_source_rejects_wrong_namespace(), emit_with_app_source_still_rejects_foreign_app_namespace(), emit_with_internal_source_is_rejected(), emit_with_known_app_source_routes_through_normalizer(), emit_with_unregistered_app_source_is_rejected(), emit_without_event_errors() (+42 more)
|
||||||
|
|
||||||
|
### Community 9 - "state_engine.rs"
|
||||||
|
Cohesion: 0.12
|
||||||
|
Nodes (20): apply_device_change(), apply_event_to_state(), device_connect_adds_device_with_all_fields(), device_connect_is_idempotent_for_same_id(), device_disconnect_of_unknown_id_is_noop(), device_disconnect_removes_matching_id(), ev(), hyprland_snapshot_replaces_topology() (+12 more)
|
||||||
|
|
||||||
|
### Community 10 - "modules_mgmt.rs"
|
||||||
|
Cohesion: 0.11
|
||||||
|
Nodes (36): audit_detects_fs_read_and_widget_from_cpu_temp_widget_style_module(), audit_extracts_exec_bin_hint_and_ignores_baseline_calls(), audit_module(), audit_scans_required_sibling_files_in_module_directory(), classify_call_site(), collect_lua_files(), copy_dir(), extract_first_string_arg() (+28 more)
|
||||||
|
|
||||||
|
### Community 11 - "systemd.rs"
|
||||||
|
Cohesion: 0.12
|
||||||
|
Nodes (17): active_state_to_kind(), failure_result(), get_unit_path(), handle_message(), is_failed_transition(), query_active_state(), Connection, HashMap (+9 more)
|
||||||
|
|
||||||
|
### Community 12 - "hooks_git.rs"
|
||||||
|
Cohesion: 0.15
|
||||||
|
Nodes (24): all_hook_scripts_exit_0_unconditionally(), all_hook_scripts_start_with_shebang_and_marker(), branch_changed_emit_line(), commit_created_emit_line(), emit_line_for(), git_dir(), hook_script(), hook_script_rejects_unknown_name() (+16 more)
|
||||||
|
|
||||||
|
### Community 13 - "bread-cli/src/main.rs"
|
||||||
|
Cohesion: 0.19
|
||||||
|
Nodes (29): CausalityTracker, Cli, Commands, config_directory(), daemon_socket_path(), format_timestamp(), handle_modules_cmd(), HooksCommand (+21 more)
|
||||||
|
|
||||||
|
### Community 14 - "types.rs"
|
||||||
|
Cohesion: 0.15
|
||||||
|
Nodes (21): DeviceTopology, InterfaceState, MatchCondition, ModuleStatus, Monitor, NetworkState, PowerState, ProfileState (+13 more)
|
||||||
|
|
||||||
|
### Community 15 - "podman.rs"
|
||||||
|
Cohesion: 0.15
|
||||||
|
Nodes (17): container_event(), ignores_remove_event(), ignores_stop_event_to_avoid_double_emit_with_died(), ignores_unknown_action(), map_podman_event(), maps_died_event(), maps_health_status_event(), maps_start_event() (+9 more)
|
||||||
|
|
||||||
|
### Community 16 - "Value"
|
||||||
|
Cohesion: 0.17
|
||||||
|
Nodes (14): active_window_from_data(), apply_hyprland_snapshot(), condition_matches(), hyprland_state_key(), json_stringish(), resolve_device(), Option, Result (+6 more)
|
||||||
|
|
||||||
|
### Community 17 - "Result"
|
||||||
|
Cohesion: 0.13
|
||||||
|
Nodes (26): bluetooth_connect(), bluetooth_disconnect(), bluetooth_find_adapter(), bluetooth_get_powered(), bluetooth_query(), bluetooth_set_powered(), bluetooth_set_scanning(), bluetooth_spawn() (+18 more)
|
||||||
|
|
||||||
|
### Community 18 - "hooks_shell.rs"
|
||||||
|
Cohesion: 0.18
|
||||||
|
Nodes (11): hook_scripts_background_every_emit_call(), hooks_dir(), install_shell(), join_line_continuations(), Option, PathBuf, Result, String (+3 more)
|
||||||
|
|
||||||
|
### Community 19 - "bluetooth.rs"
|
||||||
|
Cohesion: 0.15
|
||||||
|
Nodes (10): address_from_path(), BluetoothAdapter, parse_bluetooth_message(), Message, Option, Result, Self, Sender (+2 more)
|
||||||
|
|
||||||
|
### Community 20 - "SubscriptionId"
|
||||||
|
Cohesion: 0.29
|
||||||
|
Nodes (13): HashMap, String, Vec, Subscription, SubscriptionId, SubscriptionTable, table_add_assigns_provided_id_and_finds_match(), table_clear_removes_all() (+5 more)
|
||||||
|
|
||||||
|
### Community 22 - "StateHandle"
|
||||||
|
Cohesion: 0.15
|
||||||
|
Nodes (16): dispatch_event(), handle_command(), Arc, AtomicU64, HashMap, Receiver, RwLock, Self (+8 more)
|
||||||
|
|
||||||
|
### Community 23 - "Adapter"
|
||||||
|
Cohesion: 0.19
|
||||||
|
Nodes (9): Adapter, ip_from_bytes(), Option, Result, Self, Sender, String, RtnetlinkAdapter (+1 more)
|
||||||
|
|
||||||
|
### Community 24 - "network.rs"
|
||||||
|
Cohesion: 0.27
|
||||||
|
Nodes (9): has_default_route(), network_raw_event(), NetworkAdapter, NetworkSnapshot, read_network_state(), BTreeMap, Result, Sender (+1 more)
|
||||||
|
|
||||||
|
### Community 25 - "power.rs"
|
||||||
|
Cohesion: 0.26
|
||||||
|
Nodes (8): power_raw_event(), PowerAdapter, PowerSnapshot, read_power_state(), Option, Result, Self, Sender
|
||||||
|
|
||||||
|
### Community 26 - "hyprland.rs"
|
||||||
|
Cohesion: 0.29
|
||||||
|
Nodes (11): emit_topology_snapshot(), hyprland_event_socket(), hyprland_request_json(), hyprland_request_socket(), HyprlandAdapter, parse_hyprland_line(), PathBuf, Result (+3 more)
|
||||||
|
|
||||||
|
### Community 27 - "parse_upower_message"
|
||||||
|
Cohesion: 0.27
|
||||||
|
Nodes (6): parse_upower_message(), Message, Result, Self, Sender, UPowerAdapter
|
||||||
|
|
||||||
|
### Community 29 - "git-branch-widget.lua"
|
||||||
|
Cohesion: 0.62
|
||||||
|
Nodes (6): focused_tab_cwd(), git_info(), M.on_load(), shell_quote(), update(), widget_root()
|
||||||
|
|
||||||
|
### Community 30 - "TestHarness"
|
||||||
|
Cohesion: 0.16
|
||||||
|
Nodes (16): main(), parse_args(), Option, String, killing_a_module_host_child_does_not_take_down_breadd_or_other_modules(), os_execute_and_io_open_are_denied_at_the_kernel_level_outside_granted_scope(), Child, Drop (+8 more)
|
||||||
|
|
||||||
|
### Community 31 - "Sync"
|
||||||
|
Cohesion: 0.50
|
||||||
|
Nodes (3): main(), Result, Sync
|
||||||
|
|
||||||
|
### Community 32 - "active-window-widget.lua"
|
||||||
|
Cohesion: 0.83
|
||||||
|
Nodes (3): label_for(), M.on_load(), widget_root()
|
||||||
|
|
||||||
|
### Community 33 - ".new"
|
||||||
|
Cohesion: 0.16
|
||||||
|
Nodes (15): ModulePermission, Option, String, bluetooth_list_devices(), builtin_module_decls(), is_lib_path(), list_lua_files(), module_name_from_path() (+7 more)
|
||||||
|
|
||||||
|
### Community 34 - "focus-mode-widget.lua"
|
||||||
|
Cohesion: 1.00
|
||||||
|
Nodes (3): is_focused(), M.on_load(), widget_root()
|
||||||
|
|
||||||
|
### Community 35 - "workflow-status-widget.lua"
|
||||||
|
Cohesion: 0.83
|
||||||
|
Nodes (3): M.on_load(), most_relevant(), widget_update()
|
||||||
|
|
||||||
|
### Community 36 - "bread.widget API"
|
||||||
|
Cohesion: 0.67
|
||||||
|
Nodes (3): bread.widget API, CPU Temperature Widget Example, Live Widget Update Pattern
|
||||||
|
|
||||||
|
### Community 60 - "lua/mod.rs"
|
||||||
|
Cohesion: 0.30
|
||||||
|
Nodes (20): now_unix_ms(), RuntimeState, module_store_get(), module_store_set(), Arc, JsonValue, RwLock, widget_list_json() (+12 more)
|
||||||
|
|
||||||
|
### Community 61 - "external-monitors.lua"
|
||||||
|
Cohesion: 0.29
|
||||||
|
Nodes (8): apply(), apply_monitor(), connected(), drm_first_mode(), drm_status(), is_internal(), list_connectors(), M.on_load()
|
||||||
|
|
||||||
|
### Community 62 - "AGENTS.md — Repo hygiene"
|
||||||
|
Cohesion: 0.33
|
||||||
|
Nodes (5): AGENTS.md — Repo hygiene, CI, Don't, Local architecture (still true), Remotes
|
||||||
|
|
||||||
|
### Community 63 - "CLAUDE.md — Repo hygiene"
|
||||||
|
Cohesion: 0.40
|
||||||
|
Nodes (4): CI, CLAUDE.md — Repo hygiene, Don't, Remotes
|
||||||
|
|
||||||
|
### Community 64 - "ModuleHostLua"
|
||||||
|
Cohesion: 0.09
|
||||||
|
Nodes (38): call(), HostMessage, IoCommand, RpcResponse, Duration, Option, PathBuf, Receiver (+30 more)
|
||||||
|
|
||||||
|
### Community 65 - "ModuleHostRegistry"
|
||||||
|
Cohesion: 0.07
|
||||||
|
Nodes (43): bin_allowed(), path_allowed(), HashMap, Option, OwnedWriteHalf, Result, Sender, String (+35 more)
|
||||||
|
|
||||||
|
### Community 66 - "xtask/src/main.rs"
|
||||||
|
Cohesion: 0.11
|
||||||
|
Nodes (36): BTreeSet, ExitCode, check(), CheckReport, clean_state_passes(), extract_cli_commands(), extract_enum_variants(), extract_ipc_methods() (+28 more)
|
||||||
|
|
||||||
|
### Community 67 - "Bread"
|
||||||
|
Cohesion: 0.06
|
||||||
|
Nodes (28): Deprecations, Hyprland legacy flat event names (since v1.5), Bread Examples, Example 1: Porting keyboard_and_display_watcher.sh (system script), Example 2: Porting autostart.lua, Example 3: Porting display/monitors.lua, Example 4: Multi-step automation workflows, Example 5: A live widget in breadbar (+20 more)
|
||||||
|
|
||||||
|
### Community 68 - "rules.rs"
|
||||||
|
Cohesion: 0.14
|
||||||
|
Nodes (27): empty_file_loads_with_no_rules(), empty_on_is_treated_as_missing(), invalid_toml_is_fatal(), load_rules(), missing_on_is_reported_with_index_and_no_on(), multiple_action_keys_is_reported(), one_bad_rule_does_not_block_other_valid_rules(), ParsedRule (+19 more)
|
||||||
|
|
||||||
|
### Community 69 - "Normalized events"
|
||||||
|
Cohesion: 0.12
|
||||||
|
Nodes (16): Bluetooth (BlueZ), Compatibility: `[compat]` config, Devices (udev / Bluetooth), Filesystem / project detection, Git (hooks + dirty-state poller), Hyprland, Network, Normalized events (+8 more)
|
||||||
|
|
||||||
|
### Community 70 - "Bread Documentation"
|
||||||
|
Cohesion: 0.14
|
||||||
|
Nodes (14): API Stability & Versioning, Bread Documentation, Contents, Debugging tips, Dictionary: Event reference, Dictionary: IPC protocol, Dictionary: Runtime state schema, Integrating a bread\* app (+6 more)
|
||||||
|
|
||||||
|
### Community 71 - "udev.rs"
|
||||||
|
Cohesion: 0.18
|
||||||
|
Nodes (18): build_device_event(), build_event(), enumerate_payload_includes_classification_fields(), prop_bool(), prop_str(), Option, Result, Self (+10 more)
|
||||||
|
|
||||||
|
### Community 72 - "Dictionary: Lua API"
|
||||||
|
Cohesion: 0.17
|
||||||
|
Nodes (12): `bread.debounce(delay_ms, fn) -> wrapped_fn`, `bread.log(msg)` / `bread.warn(msg)` / `bread.error(msg)`, `bread.notify(message, opts)`, `bread.profile.activate(name)`, Dictionary: Lua API, Hyprland, Module declaration, Module lifecycle hooks (+4 more)
|
||||||
|
|
||||||
|
### Community 73 - "Out-of-process module sandboxing *(Since: v1.6)*"
|
||||||
|
Cohesion: 0.20
|
||||||
|
Nodes (10): Architecture, Crash isolation, New IPC methods, Out-of-process module sandboxing *(Since: v1.6)*, RPC bridge coverage, The gap this closes, The Landlock sandbox, The token/identity handshake (+2 more)
|
||||||
|
|
||||||
|
### Community 74 - "Dictionary: Built-in modules"
|
||||||
|
Cohesion: 0.20
|
||||||
|
Nodes (10): `bread.binds`, `bread.devices`, `bread.monitors`, `bread.rules` *(Since: v1.5)*, `bread.workspaces`, Device rule options, Dictionary: Built-in modules, Example: Dock-specific setup (+2 more)
|
||||||
|
|
||||||
|
### Community 75 - "Events"
|
||||||
|
Cohesion: 0.20
|
||||||
|
Nodes (10): `bread.emit(event, data)`, `bread.filter(pattern, fn, opts) -> id`, `bread.off(id)`, `bread.on(pattern, fn) -> id`, `bread.once(pattern, fn) -> id`, `bread.spawn(fn)`, `bread.wait_all(patterns, opts) -> table` *(Since: v1.2)*, `bread.wait_any(patterns, opts) -> event | nil` *(Since: v1.2)* (+2 more)
|
||||||
|
|
||||||
|
### Community 76 - "Machine and filesystem"
|
||||||
|
Cohesion: 0.20
|
||||||
|
Nodes (10): `bread.fs.exists(path) -> bool`, `bread.fs.expand(path) -> string`, `bread.fs.read(path) -> string | nil`, `bread.fs.readlink(path) -> string | nil`, `bread.fs.write(path, content)`, `bread.json.decode(str) -> table | nil`, `bread.machine.has_tag(tag) -> bool`, `bread.machine.name() -> string` (+2 more)
|
||||||
|
|
||||||
|
### Community 77 - "Contributing"
|
||||||
|
Cohesion: 0.22
|
||||||
|
Nodes (8): Branches, CI, Contributing, Keeping the API docs honest, Local development, Questions, The release cycle, Tracks, from a user's perspective
|
||||||
|
|
||||||
|
### Community 78 - "Bluetooth"
|
||||||
|
Cohesion: 0.22
|
||||||
|
Nodes (9): Bluetooth, `bread.bluetooth.connect(address)`, `bread.bluetooth.devices() -> table | nil`, `bread.bluetooth.disconnect(address)`, `bread.bluetooth.power(enabled)`, `bread.bluetooth.powered() -> bool | nil`, `bread.bluetooth.scan(enabled)`, Example: auto-connect headphones on AC power (+1 more)
|
||||||
|
|
||||||
|
### Community 79 - "Widgets *(Since: v1.3)*"
|
||||||
|
Cohesion: 0.22
|
||||||
|
Nodes (9): `bread.widget.list() -> table`, `bread.widget.register(spec) -> ok, err`, `bread.widget.remove(id) -> bool`, `bread.widget.update(id, patch) -> ok, err`, Click events, Node types, `style` *(Since: v1.4)*, Style vs. `class` (+1 more)
|
||||||
|
|
||||||
|
### Community 80 - "Getting started"
|
||||||
|
Cohesion: 0.33
|
||||||
|
Nodes (6): 1) Create a minimal config, 2) The fast path: `rules.toml` *(Since: v1.5)*, 3) Minimal `init.lua`, 4) Start the daemon, 5) Check that it's running, Getting started
|
||||||
|
|
||||||
|
### Community 81 - "Capability-scoped modules *(Since: v1.5)*"
|
||||||
|
Cohesion: 0.33
|
||||||
|
Nodes (6): Baseline (always available, no manifest entry needed), `bread modules audit <name>`, Capability-scoped modules *(Since: v1.5)*, Gated — requires a matching `[[permissions]]` entry, `path`/`bin` enforcement depends on where the module runs, `require("bread.devices")` still works from a scoped module
|
||||||
|
|
||||||
|
### Community 82 - "Workflows *(Since: v1.2)*"
|
||||||
|
Cohesion: 0.33
|
||||||
|
Nodes (6): `bread.workflow.define(name, fn)`, `bread.workflow.list() -> table`, `bread.workflow.start(name, opts)`, `bread.workflow.status(name) -> table | nil`, `bread.workflow.step(label)`, Workflows *(Since: v1.2)*
|
||||||
|
|
||||||
|
### Community 83 - "Timers"
|
||||||
|
Cohesion: 0.50
|
||||||
|
Nodes (4): `bread.after(delay_ms, fn) -> id`, `bread.cancel(id)`, `bread.every(interval_ms, fn) -> id`, Timers
|
||||||
|
|
||||||
|
### Community 84 - "State"
|
||||||
|
Cohesion: 0.50
|
||||||
|
Nodes (4): `bread.state.get(path)`, `bread.state.watch(path, fn) -> id`, State, Typed shorthands
|
||||||
|
|
||||||
|
### Community 85 - "init.lua"
|
||||||
|
Cohesion: 0.83
|
||||||
|
Nodes (3): M.on_load(), read_temp_c(), widget_root()
|
||||||
|
|
||||||
|
### Community 86 - "Execution"
|
||||||
|
Cohesion: 0.67
|
||||||
|
Nodes (3): `bread.exec_capture(cmd, opts) -> ok, stdout`, `bread.exec(cmd)`, Execution
|
||||||
|
|
||||||
|
### Community 88 - "PathBuf"
|
||||||
|
Cohesion: 1.00
|
||||||
|
Nodes (3): dirs_home(), lua_expand_path(), PathBuf
|
||||||
|
|
||||||
|
## Knowledge Gaps
|
||||||
|
- **182 isolated node(s):** `build.sh script`, `install.sh script`, `Remotes`, `CI`, `Local architecture (still true)` (+177 more)
|
||||||
|
These have ≤1 connection - possible missing edges or undocumented components.
|
||||||
|
- **25 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||||
|
|
||||||
|
## Suggested Questions
|
||||||
|
_Questions this graph is uniquely positioned to answer:_
|
||||||
|
|
||||||
|
- **Why does `Adapter` connect `Adapter` to `Server`, `filesystem.rs`, `git.rs`, `udev.rs`, `systemd.rs`, `podman.rs`, `bluetooth.rs`, `network.rs`, `power.rs`, `hyprland.rs`, `parse_upower_message`, `Sync`?**
|
||||||
|
_High betweenness centrality (0.089) - this node is a cross-community bridge._
|
||||||
|
- **Why does `RawEvent` connect `RawEvent` to `Server`, `filesystem.rs`, `git.rs`, `udev.rs`, `systemd.rs`, `podman.rs`, `bluetooth.rs`, `Adapter`, `network.rs`, `power.rs`, `hyprland.rs`, `parse_upower_message`?**
|
||||||
|
_High betweenness centrality (0.084) - this node is a cross-community bridge._
|
||||||
|
- **Why does `BreadEvent` connect `RawEvent` to `ModuleHostLua`, `LuaEngine`, `.new`, `Server`, `ModuleHostRegistry`, `state_engine.rs`, `StateHandle`?**
|
||||||
|
_High betweenness centrality (0.061) - this node is a cross-community bridge._
|
||||||
|
- **What connects `build.sh script`, `install.sh script`, `Remotes` to the rest of the system?**
|
||||||
|
_182 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||||
|
- **Should `LuaEngine` be split into smaller, more focused modules?**
|
||||||
|
_Cohesion score 0.11153846153846154 - nodes in this community are weakly interconnected._
|
||||||
|
- **Should `RawEvent` be split into smaller, more focused modules?**
|
||||||
|
_Cohesion score 0.05702970297029703 - nodes in this community are weakly interconnected._
|
||||||
|
- **Should `config.rs` be split into smaller, more focused modules?**
|
||||||
|
_Cohesion score 0.0670122176971492 - nodes in this community are weakly interconnected._
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"nodes": [{"id": "$graphify-root$_packaging_readme_md", "label": "README.md", "file_type": "document", "source_file": "packaging/README.md", "source_location": "L1"}, {"id": "$graphify-root$_packaging_readme_systemd_user_service", "label": "systemd user service", "file_type": "document", "source_file": "packaging/README.md", "source_location": "L17"}], "edges": [{"source": "$graphify-root$_packaging_readme_md", "target": "$graphify-root$_packaging_readme_systemd_user_service", "relation": "contains", "confidence": "EXTRACTED", "source_file": "packaging/README.md", "source_location": "L17", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"nodes": [{"id": "$graphify-root$_deprecations_md", "label": "DEPRECATIONS.md", "file_type": "document", "source_file": "DEPRECATIONS.md", "source_location": "L1"}, {"id": "$graphify-root$_deprecations_deprecations", "label": "Deprecations", "file_type": "document", "source_file": "DEPRECATIONS.md", "source_location": "L1"}, {"id": "$graphify-root$_deprecations_hyprland_legacy_flat_event_names_since_v1_5", "label": "Hyprland legacy flat event names (since v1.5)", "file_type": "document", "source_file": "DEPRECATIONS.md", "source_location": "L8"}], "edges": [{"source": "$graphify-root$_deprecations_md", "target": "$graphify-root$_deprecations_deprecations", "relation": "contains", "confidence": "EXTRACTED", "source_file": "DEPRECATIONS.md", "source_location": "L1", "weight": 1.0}, {"source": "$graphify-root$_deprecations_md", "target": "$graphify-root$_documentation_md", "relation": "references", "confidence": "EXTRACTED", "source_file": "DEPRECATIONS.md", "source_location": "L4", "weight": 1.0, "target_file": "$graphify-root$/Documentation.md"}, {"source": "$graphify-root$_deprecations_deprecations", "target": "$graphify-root$_deprecations_hyprland_legacy_flat_event_names_since_v1_5", "relation": "contains", "confidence": "EXTRACTED", "source_file": "DEPRECATIONS.md", "source_location": "L8", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
||||||
|
{"nodes": [{"id": "$graphify-root$_examples_modules_readme_md", "label": "README.md", "file_type": "document", "source_file": "examples/modules/README.md", "source_location": "L1"}, {"id": "$graphify-root$_examples_modules_readme_example_bread_modules", "label": "Example bread modules", "file_type": "document", "source_file": "examples/modules/README.md", "source_location": "L1"}, {"id": "$graphify-root$_examples_modules_readme_installing", "label": "Installing", "file_type": "document", "source_file": "examples/modules/README.md", "source_location": "L7"}, {"id": "$graphify-root$_examples_modules_readme_modules", "label": "Modules", "file_type": "document", "source_file": "examples/modules/README.md", "source_location": "L33"}], "edges": [{"source": "$graphify-root$_examples_modules_readme_md", "target": "$graphify-root$_examples_modules_readme_example_bread_modules", "relation": "contains", "confidence": "EXTRACTED", "source_file": "examples/modules/README.md", "source_location": "L1", "weight": 1.0}, {"source": "$graphify-root$_examples_modules_readme_md", "target": "$graphify-root$_examples_md", "relation": "references", "confidence": "EXTRACTED", "source_file": "examples/modules/README.md", "source_location": "L4", "weight": 1.0, "target_file": "$graphify-root$/Examples.md"}, {"source": "$graphify-root$_examples_modules_readme_example_bread_modules", "target": "$graphify-root$_examples_modules_readme_installing", "relation": "contains", "confidence": "EXTRACTED", "source_file": "examples/modules/README.md", "source_location": "L7", "weight": 1.0}, {"source": "$graphify-root$_examples_modules_readme_md", "target": "$graphify-root$_examples_modules_permissions_md", "relation": "references", "confidence": "EXTRACTED", "source_file": "examples/modules/README.md", "source_location": "L18", "weight": 1.0}, {"source": "$graphify-root$_examples_modules_readme_md", "target": "$graphify-root$_documentation_md", "relation": "references", "confidence": "EXTRACTED", "source_file": "examples/modules/README.md", "source_location": "L19", "weight": 1.0, "target_file": "$graphify-root$/Documentation.md"}, {"source": "$graphify-root$_examples_modules_readme_example_bread_modules", "target": "$graphify-root$_examples_modules_readme_modules", "relation": "contains", "confidence": "EXTRACTED", "source_file": "examples/modules/README.md", "source_location": "L33", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"nodes": [{"id": "$graphify-root$_examples_md", "label": "Examples.md", "file_type": "document", "source_file": "Examples.md", "source_location": "L1"}, {"id": "$graphify-root$_examples_bread_examples", "label": "Bread Examples", "file_type": "document", "source_file": "Examples.md", "source_location": "L1"}, {"id": "$graphify-root$_examples_example_1_porting_keyboard_and_display_watcher_sh_system_script", "label": "Example 1: Porting keyboard_and_display_watcher.sh (system script)", "file_type": "document", "source_file": "Examples.md", "source_location": "L7"}, {"id": "$graphify-root$_examples_example_2_porting_autostart_lua", "label": "Example 2: Porting autostart.lua", "file_type": "document", "source_file": "Examples.md", "source_location": "L93"}, {"id": "$graphify-root$_examples_example_3_porting_display_monitors_lua", "label": "Example 3: Porting display/monitors.lua", "file_type": "document", "source_file": "Examples.md", "source_location": "L130"}, {"id": "$graphify-root$_examples_example_4_multi_step_automation_workflows", "label": "Example 4: Multi-step automation workflows", "file_type": "document", "source_file": "Examples.md", "source_location": "L182"}, {"id": "$graphify-root$_examples_example_5_a_live_widget_in_breadbar", "label": "Example 5: A live widget in breadbar", "file_type": "document", "source_file": "Examples.md", "source_location": "L241"}, {"id": "$graphify-root$_examples_tips_for_porting_your_own_scripts", "label": "Tips for porting your own scripts", "file_type": "document", "source_file": "Examples.md", "source_location": "L313"}], "edges": [{"source": "$graphify-root$_examples_md", "target": "$graphify-root$_examples_bread_examples", "relation": "contains", "confidence": "EXTRACTED", "source_file": "Examples.md", "source_location": "L1", "weight": 1.0}, {"source": "$graphify-root$_examples_bread_examples", "target": "$graphify-root$_examples_example_1_porting_keyboard_and_display_watcher_sh_system_script", "relation": "contains", "confidence": "EXTRACTED", "source_file": "Examples.md", "source_location": "L7", "weight": 1.0}, {"source": "$graphify-root$_examples_bread_examples", "target": "$graphify-root$_examples_example_2_porting_autostart_lua", "relation": "contains", "confidence": "EXTRACTED", "source_file": "Examples.md", "source_location": "L93", "weight": 1.0}, {"source": "$graphify-root$_examples_bread_examples", "target": "$graphify-root$_examples_example_3_porting_display_monitors_lua", "relation": "contains", "confidence": "EXTRACTED", "source_file": "Examples.md", "source_location": "L130", "weight": 1.0}, {"source": "$graphify-root$_examples_bread_examples", "target": "$graphify-root$_examples_example_4_multi_step_automation_workflows", "relation": "contains", "confidence": "EXTRACTED", "source_file": "Examples.md", "source_location": "L182", "weight": 1.0}, {"source": "$graphify-root$_examples_md", "target": "$graphify-root$_documentation_md", "relation": "references", "confidence": "EXTRACTED", "source_file": "Examples.md", "source_location": "L235", "weight": 1.0, "target_file": "$graphify-root$/Documentation.md"}, {"source": "$graphify-root$_examples_bread_examples", "target": "$graphify-root$_examples_example_5_a_live_widget_in_breadbar", "relation": "contains", "confidence": "EXTRACTED", "source_file": "Examples.md", "source_location": "L241", "weight": 1.0}, {"source": "$graphify-root$_examples_bread_examples", "target": "$graphify-root$_examples_tips_for_porting_your_own_scripts", "relation": "contains", "confidence": "EXTRACTED", "source_file": "Examples.md", "source_location": "L313", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
||||||
|
{"nodes": [{"id": "$graphify-root$_claude_md", "label": "CLAUDE.md", "file_type": "document", "source_file": "CLAUDE.md", "source_location": "L1"}, {"id": "$graphify-root$_claude_claude_md_repo_hygiene", "label": "CLAUDE.md \u2014 Repo hygiene", "file_type": "document", "source_file": "CLAUDE.md", "source_location": "L1"}, {"id": "$graphify-root$_claude_remotes", "label": "Remotes", "file_type": "document", "source_file": "CLAUDE.md", "source_location": "L10"}, {"id": "$graphify-root$_claude_ci", "label": "CI", "file_type": "document", "source_file": "CLAUDE.md", "source_location": "L14"}, {"id": "$graphify-root$_claude_don_t", "label": "Don't", "file_type": "document", "source_file": "CLAUDE.md", "source_location": "L19"}], "edges": [{"source": "$graphify-root$_claude_md", "target": "$graphify-root$_claude_claude_md_repo_hygiene", "relation": "contains", "confidence": "EXTRACTED", "source_file": "CLAUDE.md", "source_location": "L1", "weight": 1.0}, {"source": "$graphify-root$_claude_claude_md_repo_hygiene", "target": "$graphify-root$_claude_remotes", "relation": "contains", "confidence": "EXTRACTED", "source_file": "CLAUDE.md", "source_location": "L10", "weight": 1.0}, {"source": "$graphify-root$_claude_claude_md_repo_hygiene", "target": "$graphify-root$_claude_ci", "relation": "contains", "confidence": "EXTRACTED", "source_file": "CLAUDE.md", "source_location": "L14", "weight": 1.0}, {"source": "$graphify-root$_claude_claude_md_repo_hygiene", "target": "$graphify-root$_claude_don_t", "relation": "contains", "confidence": "EXTRACTED", "source_file": "CLAUDE.md", "source_location": "L19", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
||||||
|
{"nodes": [{"id": "$graphify-root$_examples_modules_dock_workflow_lua", "label": "dock-workflow.lua", "file_type": "code", "source_file": "examples/modules/dock-workflow.lua", "source_location": "L1"}, {"id": "$graphify-root$_examples_modules_dock_workflow_m_on_load", "label": "M.on_load()", "file_type": "code", "source_file": "examples/modules/dock-workflow.lua", "source_location": "L44", "_callable": true}], "edges": [{"source": "$graphify-root$_examples_modules_dock_workflow_lua", "target": "$graphify-root$_examples_modules_dock_workflow_m_on_load", "relation": "contains", "confidence": "EXTRACTED", "source_file": "examples/modules/dock-workflow.lua", "source_location": "L44", "weight": 1.0}], "raw_calls": [{"caller_nid": "$graphify-root$_examples_modules_dock_workflow_m_on_load", "callee": "bread.on", "is_member_call": false, "source_file": "examples/modules/dock-workflow.lua", "source_location": "L45", "receiver": null}, {"caller_nid": "$graphify-root$_examples_modules_dock_workflow_m_on_load", "callee": "bread.workflow.start", "is_member_call": false, "source_file": "examples/modules/dock-workflow.lua", "source_location": "L49", "receiver": null}]}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
||||||
|
{"nodes": [{"id": "$graphify-root$_ci_build_sh", "label": "build.sh", "file_type": "code", "source_file": "ci/build.sh", "source_location": "L1", "metadata": {"language": "bash", "kind": "file"}}, {"id": "$graphify-root$_ci_build_sh__entry", "label": "build.sh script", "file_type": "code", "source_file": "ci/build.sh", "source_location": "L1", "metadata": {"language": "bash", "kind": "bash_entrypoint"}}], "edges": [{"source": "$graphify-root$_ci_build_sh", "target": "$graphify-root$_ci_build_sh__entry", "relation": "contains", "confidence": "EXTRACTED", "source_file": "ci/build.sh", "source_location": "L1", "weight": 1.0}], "raw_calls": [{"language": "bash", "callee": "set", "caller_nid": "$graphify-root$_ci_build_sh__entry", "source_file": "ci/build.sh", "source_location": "L9"}, {"language": "bash", "callee": "rm", "caller_nid": "$graphify-root$_ci_build_sh__entry", "source_file": "ci/build.sh", "source_location": "L16"}, {"language": "bash", "callee": "git", "caller_nid": "$graphify-root$_ci_build_sh__entry", "source_file": "ci/build.sh", "source_location": "L17"}], "bash_sources": []}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
||||||
|
{"nodes": [{"id": "$graphify-root$_breadd_src_core_mod_rs", "label": "mod.rs", "file_type": "code", "source_file": "breadd/src/core/mod.rs", "source_location": "L1"}], "edges": [], "raw_calls": []}
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
||||||
|
{"nodes": [{"id": "$graphify-root$_examples_modules_readme_md", "label": "README.md", "file_type": "document", "source_file": "examples/modules/README.md", "source_location": "L1"}, {"id": "$graphify-root$_examples_modules_readme_example_bread_modules", "label": "Example bread modules", "file_type": "document", "source_file": "examples/modules/README.md", "source_location": "L1"}, {"id": "$graphify-root$_examples_modules_readme_installing", "label": "Installing", "file_type": "document", "source_file": "examples/modules/README.md", "source_location": "L7"}, {"id": "$graphify-root$_examples_modules_readme_modules", "label": "Modules", "file_type": "document", "source_file": "examples/modules/README.md", "source_location": "L33"}], "edges": [{"source": "$graphify-root$_examples_modules_readme_md", "target": "$graphify-root$_examples_modules_readme_example_bread_modules", "relation": "contains", "confidence": "EXTRACTED", "source_file": "examples/modules/README.md", "source_location": "L1", "weight": 1.0}, {"source": "$graphify-root$_examples_modules_readme_md", "target": "$graphify-root$_examples_md", "relation": "references", "confidence": "EXTRACTED", "source_file": "examples/modules/README.md", "source_location": "L4", "weight": 1.0, "target_file": "$graphify-root$/Examples.md"}, {"source": "$graphify-root$_examples_modules_readme_example_bread_modules", "target": "$graphify-root$_examples_modules_readme_installing", "relation": "contains", "confidence": "EXTRACTED", "source_file": "examples/modules/README.md", "source_location": "L7", "weight": 1.0}, {"source": "$graphify-root$_examples_modules_readme_md", "target": "$graphify-root$_examples_modules_permissions_md", "relation": "references", "confidence": "EXTRACTED", "source_file": "examples/modules/README.md", "source_location": "L18", "weight": 1.0}, {"source": "$graphify-root$_examples_modules_readme_md", "target": "$graphify-root$_documentation_md", "relation": "references", "confidence": "EXTRACTED", "source_file": "examples/modules/README.md", "source_location": "L19", "weight": 1.0, "target_file": "$graphify-root$/Documentation.md"}, {"source": "$graphify-root$_examples_modules_readme_example_bread_modules", "target": "$graphify-root$_examples_modules_readme_modules", "relation": "contains", "confidence": "EXTRACTED", "source_file": "examples/modules/README.md", "source_location": "L33", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"nodes": [{"id": "$graphify-root$_deprecations_md", "label": "DEPRECATIONS.md", "file_type": "document", "source_file": "DEPRECATIONS.md", "source_location": "L1"}, {"id": "$graphify-root$_deprecations_deprecations", "label": "Deprecations", "file_type": "document", "source_file": "DEPRECATIONS.md", "source_location": "L1"}, {"id": "$graphify-root$_deprecations_hyprland_legacy_flat_event_names_since_v1_5", "label": "Hyprland legacy flat event names (since v1.5)", "file_type": "document", "source_file": "DEPRECATIONS.md", "source_location": "L8"}], "edges": [{"source": "$graphify-root$_deprecations_md", "target": "$graphify-root$_deprecations_deprecations", "relation": "contains", "confidence": "EXTRACTED", "source_file": "DEPRECATIONS.md", "source_location": "L1", "weight": 1.0}, {"source": "$graphify-root$_deprecations_md", "target": "$graphify-root$_documentation_md", "relation": "references", "confidence": "EXTRACTED", "source_file": "DEPRECATIONS.md", "source_location": "L4", "weight": 1.0, "target_file": "$graphify-root$/Documentation.md"}, {"source": "$graphify-root$_deprecations_deprecations", "target": "$graphify-root$_deprecations_hyprland_legacy_flat_event_names_since_v1_5", "relation": "contains", "confidence": "EXTRACTED", "source_file": "DEPRECATIONS.md", "source_location": "L8", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
||||||
|
{"nodes": [{"id": "$graphify-root$_agents_md", "label": "AGENTS.md", "file_type": "document", "source_file": "AGENTS.md", "source_location": "L1"}, {"id": "$graphify-root$_agents_agents_md_repo_hygiene", "label": "AGENTS.md \u2014 Repo hygiene", "file_type": "document", "source_file": "AGENTS.md", "source_location": "L1"}, {"id": "$graphify-root$_agents_remotes", "label": "Remotes", "file_type": "document", "source_file": "AGENTS.md", "source_location": "L16"}, {"id": "$graphify-root$_agents_ci", "label": "CI", "file_type": "document", "source_file": "AGENTS.md", "source_location": "L22"}, {"id": "$graphify-root$_agents_local_architecture_still_true", "label": "Local architecture (still true)", "file_type": "document", "source_file": "AGENTS.md", "source_location": "L31"}, {"id": "$graphify-root$_agents_don_t", "label": "Don't", "file_type": "document", "source_file": "AGENTS.md", "source_location": "L39"}], "edges": [{"source": "$graphify-root$_agents_md", "target": "$graphify-root$_agents_agents_md_repo_hygiene", "relation": "contains", "confidence": "EXTRACTED", "source_file": "AGENTS.md", "source_location": "L1", "weight": 1.0}, {"source": "$graphify-root$_agents_agents_md_repo_hygiene", "target": "$graphify-root$_agents_remotes", "relation": "contains", "confidence": "EXTRACTED", "source_file": "AGENTS.md", "source_location": "L16", "weight": 1.0}, {"source": "$graphify-root$_agents_agents_md_repo_hygiene", "target": "$graphify-root$_agents_ci", "relation": "contains", "confidence": "EXTRACTED", "source_file": "AGENTS.md", "source_location": "L22", "weight": 1.0}, {"source": "$graphify-root$_agents_agents_md_repo_hygiene", "target": "$graphify-root$_agents_local_architecture_still_true", "relation": "contains", "confidence": "EXTRACTED", "source_file": "AGENTS.md", "source_location": "L31", "weight": 1.0}, {"source": "$graphify-root$_agents_agents_md_repo_hygiene", "target": "$graphify-root$_agents_don_t", "relation": "contains", "confidence": "EXTRACTED", "source_file": "AGENTS.md", "source_location": "L39", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
||||||
|
{"nodes": [{"id": "$graphify-root$_scripts_install_sh", "label": "install.sh", "file_type": "code", "source_file": "scripts/install.sh", "source_location": "L1", "metadata": {"language": "bash", "kind": "file"}}, {"id": "$graphify-root$_scripts_install_sh__entry", "label": "install.sh script", "file_type": "code", "source_file": "scripts/install.sh", "source_location": "L1", "metadata": {"language": "bash", "kind": "bash_entrypoint"}}], "edges": [{"source": "$graphify-root$_scripts_install_sh", "target": "$graphify-root$_scripts_install_sh__entry", "relation": "contains", "confidence": "EXTRACTED", "source_file": "scripts/install.sh", "source_location": "L1", "weight": 1.0}], "raw_calls": [{"language": "bash", "callee": "set", "caller_nid": "$graphify-root$_scripts_install_sh__entry", "source_file": "scripts/install.sh", "source_location": "L2"}, {"language": "bash", "callee": "echo", "caller_nid": "$graphify-root$_scripts_install_sh__entry", "source_file": "scripts/install.sh", "source_location": "L11"}, {"language": "bash", "callee": "cargo", "caller_nid": "$graphify-root$_scripts_install_sh__entry", "source_file": "scripts/install.sh", "source_location": "L12"}, {"language": "bash", "callee": "mkdir", "caller_nid": "$graphify-root$_scripts_install_sh__entry", "source_file": "scripts/install.sh", "source_location": "L17"}, {"language": "bash", "callee": "ln", "caller_nid": "$graphify-root$_scripts_install_sh__entry", "source_file": "scripts/install.sh", "source_location": "L18"}, {"language": "bash", "callee": "cat", "caller_nid": "$graphify-root$_scripts_install_sh__entry", "source_file": "scripts/install.sh", "source_location": "L39"}, {"language": "bash", "callee": "sed", "caller_nid": "$graphify-root$_scripts_install_sh__entry", "source_file": "scripts/install.sh", "source_location": "L79"}, {"language": "bash", "callee": "systemctl", "caller_nid": "$graphify-root$_scripts_install_sh__entry", "source_file": "scripts/install.sh", "source_location": "L84"}, {"language": "bash", "callee": "break", "caller_nid": "$graphify-root$_scripts_install_sh__entry", "source_file": "scripts/install.sh", "source_location": "L103"}, {"language": "bash", "callee": "sleep", "caller_nid": "$graphify-root$_scripts_install_sh__entry", "source_file": "scripts/install.sh", "source_location": "L105"}], "bash_sources": []}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"nodes": [{"id": "$graphify-root$_contributing_md", "label": "CONTRIBUTING.md", "file_type": "document", "source_file": "CONTRIBUTING.md", "source_location": "L1"}, {"id": "$graphify-root$_contributing_contributing", "label": "Contributing", "file_type": "document", "source_file": "CONTRIBUTING.md", "source_location": "L1"}, {"id": "$graphify-root$_contributing_branches", "label": "Branches", "file_type": "document", "source_file": "CONTRIBUTING.md", "source_location": "L8"}, {"id": "$graphify-root$_contributing_the_release_cycle", "label": "The release cycle", "file_type": "document", "source_file": "CONTRIBUTING.md", "source_location": "L26"}, {"id": "$graphify-root$_contributing_tracks_from_a_user_s_perspective", "label": "Tracks, from a user's perspective", "file_type": "document", "source_file": "CONTRIBUTING.md", "source_location": "L43"}, {"id": "$graphify-root$_contributing_local_development", "label": "Local development", "file_type": "document", "source_file": "CONTRIBUTING.md", "source_location": "L63"}, {"id": "$graphify-root$_contributing_keeping_the_api_docs_honest", "label": "Keeping the API docs honest", "file_type": "document", "source_file": "CONTRIBUTING.md", "source_location": "L70"}, {"id": "$graphify-root$_contributing_ci", "label": "CI", "file_type": "document", "source_file": "CONTRIBUTING.md", "source_location": "L94"}, {"id": "$graphify-root$_contributing_questions", "label": "Questions", "file_type": "document", "source_file": "CONTRIBUTING.md", "source_location": "L106"}], "edges": [{"source": "$graphify-root$_contributing_md", "target": "$graphify-root$_contributing_contributing", "relation": "contains", "confidence": "EXTRACTED", "source_file": "CONTRIBUTING.md", "source_location": "L1", "weight": 1.0}, {"source": "$graphify-root$_contributing_contributing", "target": "$graphify-root$_contributing_branches", "relation": "contains", "confidence": "EXTRACTED", "source_file": "CONTRIBUTING.md", "source_location": "L8", "weight": 1.0}, {"source": "$graphify-root$_contributing_contributing", "target": "$graphify-root$_contributing_the_release_cycle", "relation": "contains", "confidence": "EXTRACTED", "source_file": "CONTRIBUTING.md", "source_location": "L26", "weight": 1.0}, {"source": "$graphify-root$_contributing_contributing", "target": "$graphify-root$_contributing_tracks_from_a_user_s_perspective", "relation": "contains", "confidence": "EXTRACTED", "source_file": "CONTRIBUTING.md", "source_location": "L43", "weight": 1.0}, {"source": "$graphify-root$_contributing_contributing", "target": "$graphify-root$_contributing_local_development", "relation": "contains", "confidence": "EXTRACTED", "source_file": "CONTRIBUTING.md", "source_location": "L63", "weight": 1.0}, {"source": "$graphify-root$_contributing_local_development", "target": "$graphify-root$_contributing_keeping_the_api_docs_honest", "relation": "contains", "confidence": "EXTRACTED", "source_file": "CONTRIBUTING.md", "source_location": "L70", "weight": 1.0}, {"source": "$graphify-root$_contributing_contributing", "target": "$graphify-root$_contributing_ci", "relation": "contains", "confidence": "EXTRACTED", "source_file": "CONTRIBUTING.md", "source_location": "L94", "weight": 1.0}, {"source": "$graphify-root$_contributing_contributing", "target": "$graphify-root$_contributing_questions", "relation": "contains", "confidence": "EXTRACTED", "source_file": "CONTRIBUTING.md", "source_location": "L106", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
||||||
|
{"nodes": [{"id": "$graphify-root$_examples_modules_cpu_temp_widget_init_lua", "label": "init.lua", "file_type": "code", "source_file": "examples/modules/cpu-temp-widget/init.lua", "source_location": "L1"}, {"id": "$graphify-root$_examples_modules_cpu_temp_widget_init_read_temp_c", "label": "read_temp_c()", "file_type": "code", "source_file": "examples/modules/cpu-temp-widget/init.lua", "source_location": "L32", "_callable": true}, {"id": "$graphify-root$_examples_modules_cpu_temp_widget_init_widget_root", "label": "widget_root()", "file_type": "code", "source_file": "examples/modules/cpu-temp-widget/init.lua", "source_location": "L40", "_callable": true}, {"id": "$graphify-root$_examples_modules_cpu_temp_widget_init_m_on_load", "label": "M.on_load()", "file_type": "code", "source_file": "examples/modules/cpu-temp-widget/init.lua", "source_location": "L60", "_callable": true}], "edges": [{"source": "$graphify-root$_examples_modules_cpu_temp_widget_init_lua", "target": "$graphify-root$_examples_modules_cpu_temp_widget_init_read_temp_c", "relation": "contains", "confidence": "EXTRACTED", "source_file": "examples/modules/cpu-temp-widget/init.lua", "source_location": "L32", "weight": 1.0}, {"source": "$graphify-root$_examples_modules_cpu_temp_widget_init_lua", "target": "$graphify-root$_examples_modules_cpu_temp_widget_init_widget_root", "relation": "contains", "confidence": "EXTRACTED", "source_file": "examples/modules/cpu-temp-widget/init.lua", "source_location": "L40", "weight": 1.0}, {"source": "$graphify-root$_examples_modules_cpu_temp_widget_init_lua", "target": "$graphify-root$_examples_modules_cpu_temp_widget_init_m_on_load", "relation": "contains", "confidence": "EXTRACTED", "source_file": "examples/modules/cpu-temp-widget/init.lua", "source_location": "L60", "weight": 1.0}, {"source": "$graphify-root$_examples_modules_cpu_temp_widget_init_m_on_load", "target": "$graphify-root$_examples_modules_cpu_temp_widget_init_widget_root", "relation": "calls", "context": "call", "confidence": "EXTRACTED", "source_file": "examples/modules/cpu-temp-widget/init.lua", "source_location": "L65", "weight": 1.0}, {"source": "$graphify-root$_examples_modules_cpu_temp_widget_init_m_on_load", "target": "$graphify-root$_examples_modules_cpu_temp_widget_init_read_temp_c", "relation": "calls", "context": "call", "confidence": "EXTRACTED", "source_file": "examples/modules/cpu-temp-widget/init.lua", "source_location": "L65", "weight": 1.0}], "raw_calls": [{"caller_nid": "$graphify-root$_examples_modules_cpu_temp_widget_init_read_temp_c", "callee": "bread.fs.read", "is_member_call": false, "source_file": "examples/modules/cpu-temp-widget/init.lua", "source_location": "L33", "receiver": null}, {"caller_nid": "$graphify-root$_examples_modules_cpu_temp_widget_init_read_temp_c", "callee": "tonumber", "is_member_call": false, "source_file": "examples/modules/cpu-temp-widget/init.lua", "source_location": "L37", "receiver": null}, {"caller_nid": "$graphify-root$_examples_modules_cpu_temp_widget_init_widget_root", "callee": "string.format", "is_member_call": false, "source_file": "examples/modules/cpu-temp-widget/init.lua", "source_location": "L41", "receiver": null}, {"caller_nid": "$graphify-root$_examples_modules_cpu_temp_widget_init_widget_root", "callee": "math.min", "is_member_call": false, "source_file": "examples/modules/cpu-temp-widget/init.lua", "source_location": "L53", "receiver": null}, {"caller_nid": "$graphify-root$_examples_modules_cpu_temp_widget_init_m_on_load", "callee": "bread.widget.register", "is_member_call": false, "source_file": "examples/modules/cpu-temp-widget/init.lua", "source_location": "L61", "receiver": null}, {"caller_nid": "$graphify-root$_examples_modules_cpu_temp_widget_init_m_on_load", "callee": "bread.every", "is_member_call": false, "source_file": "examples/modules/cpu-temp-widget/init.lua", "source_location": "L68", "receiver": null}, {"caller_nid": "$graphify-root$_examples_modules_cpu_temp_widget_init_m_on_load", "callee": "bread.widget.update", "is_member_call": false, "source_file": "examples/modules/cpu-temp-widget/init.lua", "source_location": "L69", "receiver": null}]}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
graphify-out/cache/last_query_stamp
vendored
Normal file
1
graphify-out/cache/last_query_stamp
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
1786850688.401695
|
||||||
2
graphify-out/cache/stat-index.json
vendored
2
graphify-out/cache/stat-index.json
vendored
File diff suppressed because one or more lines are too long
18
graphify-out/cost.json
Normal file
18
graphify-out/cost.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"date": "2026-08-04T09:09:09.071813+00:00",
|
||||||
|
"input_tokens": 0,
|
||||||
|
"output_tokens": 0,
|
||||||
|
"files": 55
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-08-04T09:28:29.057021+00:00",
|
||||||
|
"input_tokens": 72759,
|
||||||
|
"output_tokens": 0,
|
||||||
|
"files": 55
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_input_tokens": 72759,
|
||||||
|
"total_output_tokens": 0
|
||||||
|
}
|
||||||
320
graphify-out/graph.html
Normal file
320
graphify-out/graph.html
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load diff
352
graphify-out/manifest.json
Normal file
352
graphify-out/manifest.json
Normal file
|
|
@ -0,0 +1,352 @@
|
||||||
|
{
|
||||||
|
"bread-cli/src/hooks_git.rs": {
|
||||||
|
"mtime": 1784781158.0572724,
|
||||||
|
"ast_hash": "7a29b5d5d90170f0aef9cececc7d8656",
|
||||||
|
"semantic_hash": "7a29b5d5d90170f0aef9cececc7d8656"
|
||||||
|
},
|
||||||
|
"bread-cli/src/hooks_shell.rs": {
|
||||||
|
"mtime": 1784781158.0582726,
|
||||||
|
"ast_hash": "c5d5b8922fcff79954ddb00496721603",
|
||||||
|
"semantic_hash": "c5d5b8922fcff79954ddb00496721603"
|
||||||
|
},
|
||||||
|
"bread-cli/src/lib.rs": {
|
||||||
|
"mtime": 1778512393.4941568,
|
||||||
|
"ast_hash": "d1bf6e1c239498521c42418f672bc400",
|
||||||
|
"semantic_hash": "d1bf6e1c239498521c42418f672bc400"
|
||||||
|
},
|
||||||
|
"bread-cli/src/main.rs": {
|
||||||
|
"mtime": 1785882914.3322613,
|
||||||
|
"ast_hash": "e1fbe63ea3b10e0887072ab7133d0508",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"bread-cli/src/modules_mgmt.rs": {
|
||||||
|
"mtime": 1785882914.3322613,
|
||||||
|
"ast_hash": "1cb6f3e4fa26108f81215ab57bb02b91",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"bread-cli/tests/modules.rs": {
|
||||||
|
"mtime": 1784781158.0602725,
|
||||||
|
"ast_hash": "84fca8f87dc915b1cb523f8199e78521",
|
||||||
|
"semantic_hash": "84fca8f87dc915b1cb523f8199e78521"
|
||||||
|
},
|
||||||
|
"bread-emit/src/main.rs": {
|
||||||
|
"mtime": 1784781158.0616376,
|
||||||
|
"ast_hash": "350cefbf697cfd93f7df7b7bcc45a101",
|
||||||
|
"semantic_hash": "350cefbf697cfd93f7df7b7bcc45a101"
|
||||||
|
},
|
||||||
|
"bread-shared/src/apps.rs": {
|
||||||
|
"mtime": 1786805047.7009091,
|
||||||
|
"ast_hash": "e8419571e7a322019d71c89559fb02fb",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"bread-shared/src/glob.rs": {
|
||||||
|
"mtime": 1784781158.0616376,
|
||||||
|
"ast_hash": "0594a313cb1909d1cca5fd5b8f241b68",
|
||||||
|
"semantic_hash": "0594a313cb1909d1cca5fd5b8f241b68"
|
||||||
|
},
|
||||||
|
"bread-shared/src/lib.rs": {
|
||||||
|
"mtime": 1785882914.3346088,
|
||||||
|
"ast_hash": "236b73bad29f1544a28fbf3ee1ccc795",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"bread-shared/src/widget.rs": {
|
||||||
|
"mtime": 1784781173.9236407,
|
||||||
|
"ast_hash": "33272eb38c7fffbfa180cef7c5a286fc",
|
||||||
|
"semantic_hash": "33272eb38c7fffbfa180cef7c5a286fc"
|
||||||
|
},
|
||||||
|
"breadd/src/adapters/bluetooth.rs": {
|
||||||
|
"mtime": 1778843727.5135255,
|
||||||
|
"ast_hash": "4d1df67347d5b7c9cdbcb12921a6ae28",
|
||||||
|
"semantic_hash": "4d1df67347d5b7c9cdbcb12921a6ae28"
|
||||||
|
},
|
||||||
|
"breadd/src/adapters/filesystem.rs": {
|
||||||
|
"mtime": 1784781158.0616376,
|
||||||
|
"ast_hash": "8ec9dcd8de13f30922cfbe9b4a3fff56",
|
||||||
|
"semantic_hash": "8ec9dcd8de13f30922cfbe9b4a3fff56"
|
||||||
|
},
|
||||||
|
"breadd/src/adapters/git.rs": {
|
||||||
|
"mtime": 1784781158.0616376,
|
||||||
|
"ast_hash": "6e6ff6ca318e28cd94690c30d41b520b",
|
||||||
|
"semantic_hash": "6e6ff6ca318e28cd94690c30d41b520b"
|
||||||
|
},
|
||||||
|
"breadd/src/adapters/hyprland.rs": {
|
||||||
|
"mtime": 1786805047.7042425,
|
||||||
|
"ast_hash": "86056a351db61eb1788a2147d859fc5c",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"breadd/src/adapters/mod.rs": {
|
||||||
|
"mtime": 1784781158.0616376,
|
||||||
|
"ast_hash": "63885887c2fc3ea2314d7fe095e5df61",
|
||||||
|
"semantic_hash": "63885887c2fc3ea2314d7fe095e5df61"
|
||||||
|
},
|
||||||
|
"breadd/src/adapters/network.rs": {
|
||||||
|
"mtime": 1778470615.8460202,
|
||||||
|
"ast_hash": "8706dc546b7e08d5aa084ca58c48559c",
|
||||||
|
"semantic_hash": "8706dc546b7e08d5aa084ca58c48559c"
|
||||||
|
},
|
||||||
|
"breadd/src/adapters/network_rtnetlink.rs": {
|
||||||
|
"mtime": 1784781158.0616376,
|
||||||
|
"ast_hash": "0c9d0bb2693bc46b11807f6b8ebb7c4c",
|
||||||
|
"semantic_hash": "0c9d0bb2693bc46b11807f6b8ebb7c4c"
|
||||||
|
},
|
||||||
|
"breadd/src/adapters/podman.rs": {
|
||||||
|
"mtime": 1784781158.0622723,
|
||||||
|
"ast_hash": "97c08551c0fe53b0a5888d98730d35db",
|
||||||
|
"semantic_hash": "97c08551c0fe53b0a5888d98730d35db"
|
||||||
|
},
|
||||||
|
"breadd/src/adapters/power.rs": {
|
||||||
|
"mtime": 1778470615.838569,
|
||||||
|
"ast_hash": "987d202ec0d30ec26b1d747a6e320ed7",
|
||||||
|
"semantic_hash": "987d202ec0d30ec26b1d747a6e320ed7"
|
||||||
|
},
|
||||||
|
"breadd/src/adapters/power_upower.rs": {
|
||||||
|
"mtime": 1784781158.0622723,
|
||||||
|
"ast_hash": "fc0a36ca76d340c8be77644f63e462bf",
|
||||||
|
"semantic_hash": "fc0a36ca76d340c8be77644f63e462bf"
|
||||||
|
},
|
||||||
|
"breadd/src/adapters/systemd.rs": {
|
||||||
|
"mtime": 1784781158.0622723,
|
||||||
|
"ast_hash": "b3884dbecd39acc38abdf67589a8b954",
|
||||||
|
"semantic_hash": "b3884dbecd39acc38abdf67589a8b954"
|
||||||
|
},
|
||||||
|
"breadd/src/adapters/udev.rs": {
|
||||||
|
"mtime": 1786805047.7042425,
|
||||||
|
"ast_hash": "568993c8c2dd218879eec57aee50a6b9",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"breadd/src/core/config.rs": {
|
||||||
|
"mtime": 1785882914.3346088,
|
||||||
|
"ast_hash": "7f479bfb51647e6131c14f0c2f153d95",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"breadd/src/core/mod.rs": {
|
||||||
|
"mtime": 1785882914.3346088,
|
||||||
|
"ast_hash": "e38bfdb894eabd208941bd3ffb1ef464",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"breadd/src/core/normalizer.rs": {
|
||||||
|
"mtime": 1786805047.7042425,
|
||||||
|
"ast_hash": "3e0e6e0b46cc3d42dbfd6348fc5f0bb1",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"breadd/src/core/state_engine.rs": {
|
||||||
|
"mtime": 1786805047.7042425,
|
||||||
|
"ast_hash": "0e54b3b7d1de26db074454d469543192",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"breadd/src/core/subscriptions.rs": {
|
||||||
|
"mtime": 1784781158.0632722,
|
||||||
|
"ast_hash": "8735d4717b74523c787dab9ea2b0bbd8",
|
||||||
|
"semantic_hash": "8735d4717b74523c787dab9ea2b0bbd8"
|
||||||
|
},
|
||||||
|
"breadd/src/core/supervisor.rs": {
|
||||||
|
"mtime": 1778680581.3778126,
|
||||||
|
"ast_hash": "403f7ba1807c71f9b89d81bfeff2681a",
|
||||||
|
"semantic_hash": "403f7ba1807c71f9b89d81bfeff2681a"
|
||||||
|
},
|
||||||
|
"breadd/src/core/types.rs": {
|
||||||
|
"mtime": 1785882914.3346088,
|
||||||
|
"ast_hash": "8bd070d4c09725d8717749a79aa42a92",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"breadd/src/ipc/mod.rs": {
|
||||||
|
"mtime": 1786805047.7042425,
|
||||||
|
"ast_hash": "d5bc8f1194e7356de3fe181c68133cba",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"breadd/src/lua/mod.rs": {
|
||||||
|
"mtime": 1785882914.3346088,
|
||||||
|
"ast_hash": "ed13e2495399d532625653a22a4fee14",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"breadd/src/main.rs": {
|
||||||
|
"mtime": 1785882914.3346088,
|
||||||
|
"ast_hash": "4b31888cc9e76210df0c7cff3a8b1e42",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"breadd/tests/ipc_integration.rs": {
|
||||||
|
"mtime": 1786805047.7042425,
|
||||||
|
"ast_hash": "2b5bade2cc2e189a3af9c692232be258",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"examples/modules/active-window-widget.lua": {
|
||||||
|
"mtime": 1784781173.9256406,
|
||||||
|
"ast_hash": "a5f6fb2c54775a49ddaf29674f86571d",
|
||||||
|
"semantic_hash": "a5f6fb2c54775a49ddaf29674f86571d"
|
||||||
|
},
|
||||||
|
"examples/modules/bluetooth-toggle-widget.lua": {
|
||||||
|
"mtime": 1784781173.9256406,
|
||||||
|
"ast_hash": "c8529dc45862adf4f63db48674a28277",
|
||||||
|
"semantic_hash": "c8529dc45862adf4f63db48674a28277"
|
||||||
|
},
|
||||||
|
"examples/modules/dock-monitors.lua": {
|
||||||
|
"mtime": 1784781158.0663679,
|
||||||
|
"ast_hash": "4abe51f19614d2bf117ac35e95324801",
|
||||||
|
"semantic_hash": "4abe51f19614d2bf117ac35e95324801"
|
||||||
|
},
|
||||||
|
"examples/modules/dock-workflow.lua": {
|
||||||
|
"mtime": 1785882914.337525,
|
||||||
|
"ast_hash": "84cf81cfb7780e89a46e96a6ccfbad15",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"examples/modules/focus-mode-widget.lua": {
|
||||||
|
"mtime": 1784781173.9256406,
|
||||||
|
"ast_hash": "f62c366c2c85cfbe59eef663fc607230",
|
||||||
|
"semantic_hash": "f62c366c2c85cfbe59eef663fc607230"
|
||||||
|
},
|
||||||
|
"examples/modules/git-branch-widget.lua": {
|
||||||
|
"mtime": 1785882914.337525,
|
||||||
|
"ast_hash": "ebeeaebab5b5b29619f3db65c1df57d8",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"examples/modules/low-battery-warning.lua": {
|
||||||
|
"mtime": 1784781158.0663679,
|
||||||
|
"ast_hash": "e0ba79860562fc36fa8cb183bc576fb7",
|
||||||
|
"semantic_hash": "e0ba79860562fc36fa8cb183bc576fb7"
|
||||||
|
},
|
||||||
|
"examples/modules/pause-media-on-headphone-unplug.lua": {
|
||||||
|
"mtime": 1784781158.0663679,
|
||||||
|
"ast_hash": "6c4cf82b6963eb93df224bed60d06c2d",
|
||||||
|
"semantic_hash": "6c4cf82b6963eb93df224bed60d06c2d"
|
||||||
|
},
|
||||||
|
"examples/modules/workflow-status-widget.lua": {
|
||||||
|
"mtime": 1784781173.9256406,
|
||||||
|
"ast_hash": "9f913a66a0f8654dadc1c5d6c2be0938",
|
||||||
|
"semantic_hash": "9f913a66a0f8654dadc1c5d6c2be0938"
|
||||||
|
},
|
||||||
|
"scripts/install.sh": {
|
||||||
|
"mtime": 1786805047.710909,
|
||||||
|
"ast_hash": "35d1a7f63824e9176bd105ab3d699576",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"CONTRIBUTING.md": {
|
||||||
|
"mtime": 1785909683.2691193,
|
||||||
|
"ast_hash": "6d056fcf0dae29949d19c41b41792879",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"Documentation.md": {
|
||||||
|
"mtime": 1786805047.7009091,
|
||||||
|
"ast_hash": "e219e52fae2208f771d9b77d87d6790b",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"Examples.md": {
|
||||||
|
"mtime": 1784781173.9236407,
|
||||||
|
"ast_hash": "6bcd7a14be53a9f67ef6912b160da8bc",
|
||||||
|
"semantic_hash": "6bcd7a14be53a9f67ef6912b160da8bc"
|
||||||
|
},
|
||||||
|
"README.md": {
|
||||||
|
"mtime": 1786805047.7009091,
|
||||||
|
"ast_hash": "97ae9f7efb9f2cdceb615a47cf3dfa1a",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"bread-module-host/src/io.rs": {
|
||||||
|
"mtime": 1786805041.5686455,
|
||||||
|
"ast_hash": "dbe04e05d1cfb02770db8d7c0bcc0b68",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"bread-module-host/src/lua_env.rs": {
|
||||||
|
"mtime": 1786805041.5686455,
|
||||||
|
"ast_hash": "97d009846e18d3c809663b1185c98bbb",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"bread-module-host/src/main.rs": {
|
||||||
|
"mtime": 1785882914.3346088,
|
||||||
|
"ast_hash": "9a93253ae44c764b8273df40999ddede",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"bread-shared/src/module_host_ipc.rs": {
|
||||||
|
"mtime": 1785882914.3346088,
|
||||||
|
"ast_hash": "fcdb4c03f242a5ea51fd3a096b711363",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"bread-shared/src/permissions.rs": {
|
||||||
|
"mtime": 1785882914.3346088,
|
||||||
|
"ast_hash": "800445ef9c90bdbf8f3ac9d4f1afd98e",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"breadd/src/core/rules.rs": {
|
||||||
|
"mtime": 1785882914.3346088,
|
||||||
|
"ast_hash": "bd430cf213f400b8d4e96ccc35a76ff1",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"breadd/src/ipc/module_host_bridge.rs": {
|
||||||
|
"mtime": 1785882914.3346088,
|
||||||
|
"ast_hash": "caa9cf82695e48c88758fcfa835e1871",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"breadd/src/module_host.rs": {
|
||||||
|
"mtime": 1785895235.7449324,
|
||||||
|
"ast_hash": "228826ff3c8941ea5f9bb28b66d637a9",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"breadd/tests/module_host_sandbox.rs": {
|
||||||
|
"mtime": 1786805041.5686455,
|
||||||
|
"ast_hash": "7e637b3a950c510215a6f6a7b88dd404",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"examples/modules/cpu-temp-widget/init.lua": {
|
||||||
|
"mtime": 1785882914.337525,
|
||||||
|
"ast_hash": "5ac893dd2f6af6d8b22dd290e3021f77",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"xtask/src/main.rs": {
|
||||||
|
"mtime": 1785909683.2724528,
|
||||||
|
"ast_hash": "e914dd77ac3241df09e3a681228961db",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
".forgejo/workflows/dev-release.yml": {
|
||||||
|
"mtime": 1786805047.7009091,
|
||||||
|
"ast_hash": "ee3f41732b4ca3f34718b9c4fee68a4c",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
".forgejo/workflows/rc-release.yml": {
|
||||||
|
"mtime": 1786805047.7009091,
|
||||||
|
"ast_hash": "ce93aea449f6698edb5e993a9f658392",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
".forgejo/workflows/release.yml": {
|
||||||
|
"mtime": 1786805047.7009091,
|
||||||
|
"ast_hash": "aa3e2461c895d0e8ff0cf0fa5556694d",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"DEPRECATIONS.md": {
|
||||||
|
"mtime": 1786805047.7009091,
|
||||||
|
"ast_hash": "f3eb8f249d81c7c4faf2a131e890f459",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"examples/modules/README.md": {
|
||||||
|
"mtime": 1786858478.390548,
|
||||||
|
"ast_hash": "d47ad6c1a9775a44212b89e281aae907",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"packaging/README.md": {
|
||||||
|
"mtime": 1786805047.710909,
|
||||||
|
"ast_hash": "caa295eed64de126874a2e48b8aed8a2",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"ci/build.sh": {
|
||||||
|
"mtime": 1786805047.7071788,
|
||||||
|
"ast_hash": "363dbb1a10cf8e098a0451f43ff101cf",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"examples/modules/external-monitors.lua": {
|
||||||
|
"mtime": 1786858478.390548,
|
||||||
|
"ast_hash": "ba0f44a4d84889b9828201aff9186466",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
".forgejo/workflows/check.yml": {
|
||||||
|
"mtime": 1786805047.7009091,
|
||||||
|
"ast_hash": "4ceb400224458b1a43c5ddc44cfbd187",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"AGENTS.md": {
|
||||||
|
"mtime": 1786805047.7009091,
|
||||||
|
"ast_hash": "513e4a05cbbcdcb47b208b781f4d78bb",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"CLAUDE.md": {
|
||||||
|
"mtime": 1784778267.6147587,
|
||||||
|
"ast_hash": "589cfefd400c641c059a2f8516ff2037",
|
||||||
|
"semantic_hash": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,7 +19,7 @@ file here.
|
||||||
The service unit starts `breadd` as a user service after the graphical session is available.
|
The service unit starts `breadd` as a user service after the graphical session is available.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install and enable manually (if not using the PKGBUILD)
|
# Install and enable manually (if not using bakery)
|
||||||
mkdir -p ~/.config/systemd/user
|
mkdir -p ~/.config/systemd/user
|
||||||
cp systemd/breadd.service ~/.config/systemd/user/
|
cp systemd/breadd.service ~/.config/systemd/user/
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,12 @@ echo "symlinking binaries into $BIN_DIR..."
|
||||||
mkdir -p "$BIN_DIR"
|
mkdir -p "$BIN_DIR"
|
||||||
ln -sf "$REPO_ROOT/target/release/breadd" "$BIN_DIR/breadd"
|
ln -sf "$REPO_ROOT/target/release/breadd" "$BIN_DIR/breadd"
|
||||||
ln -sf "$REPO_ROOT/target/release/bread" "$BIN_DIR/bread"
|
ln -sf "$REPO_ROOT/target/release/bread" "$BIN_DIR/bread"
|
||||||
|
ln -sf "$REPO_ROOT/target/release/bread-emit" "$BIN_DIR/bread-emit"
|
||||||
|
ln -sf "$REPO_ROOT/target/release/bread-module-host" "$BIN_DIR/bread-module-host"
|
||||||
echo " $BIN_DIR/breadd -> $REPO_ROOT/target/release/breadd"
|
echo " $BIN_DIR/breadd -> $REPO_ROOT/target/release/breadd"
|
||||||
echo " $BIN_DIR/bread -> $REPO_ROOT/target/release/bread"
|
echo " $BIN_DIR/bread -> $REPO_ROOT/target/release/bread"
|
||||||
|
echo " $BIN_DIR/bread-emit -> $REPO_ROOT/target/release/bread-emit"
|
||||||
|
echo " $BIN_DIR/bread-module-host -> $REPO_ROOT/target/release/bread-module-host"
|
||||||
|
|
||||||
if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then
|
if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then
|
||||||
echo ""
|
echo ""
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,20 @@
|
||||||
//! binaries themselves.
|
//! binaries themselves.
|
||||||
//!
|
//!
|
||||||
//! Currently just `check-docs`: a drift detector between the actual
|
//! Currently just `check-docs`: a drift detector between the actual
|
||||||
//! `bread.*` Lua binding surface + IPC method surface (as implemented in
|
//! `bread.*` Lua binding surface + IPC method surface + `bread` CLI command
|
||||||
//! `breadd/src/lua/mod.rs` / `breadd/src/ipc/mod.rs`) and the checked-in
|
//! surface (as implemented in `breadd/src/lua/mod.rs`, `breadd/src/ipc/mod.rs`,
|
||||||
//! registry at `api-schema.toml`, cross-referenced against `Documentation.md`.
|
//! and `bread-cli/src/main.rs`) and the checked-in registry at
|
||||||
//! See `api-schema.toml`'s header comment for why this exists and why it's a
|
//! `api-schema.toml`, cross-referenced against `Documentation.md` (Lua/IPC)
|
||||||
//! *drift detector*, not a doc generator.
|
//! and `README.md` (CLI commands). See `api-schema.toml`'s header comment for
|
||||||
|
//! why this exists and why it's a *drift detector*, not a doc generator.
|
||||||
|
//!
|
||||||
|
//! The CLI-command coverage exists specifically because README's "CLI
|
||||||
|
//! reference" section drifted from `Documentation.md`/the real `bread-cli`
|
||||||
|
//! source (missing `modules audit`, `hooks install-shell`/`install-git`,
|
||||||
|
//! `events --tree`) — a live instance of exactly the drift this whole
|
||||||
|
//! workstream exists to prevent, found and fixed by hand once the schema
|
||||||
|
//! didn't cover it yet. See `api-schema.toml`'s header for how CLI commands
|
||||||
|
//! are named and versioned differently from the Lua/IPC entries.
|
||||||
|
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
@ -50,12 +59,16 @@ fn run_check_docs() -> Result<bool> {
|
||||||
.context("reading breadd/src/lua/mod.rs")?;
|
.context("reading breadd/src/lua/mod.rs")?;
|
||||||
let ipc_src = std::fs::read_to_string(root.join("breadd/src/ipc/mod.rs"))
|
let ipc_src = std::fs::read_to_string(root.join("breadd/src/ipc/mod.rs"))
|
||||||
.context("reading breadd/src/ipc/mod.rs")?;
|
.context("reading breadd/src/ipc/mod.rs")?;
|
||||||
|
let cli_src = std::fs::read_to_string(root.join("bread-cli/src/main.rs"))
|
||||||
|
.context("reading bread-cli/src/main.rs")?;
|
||||||
let schema_toml = std::fs::read_to_string(root.join("api-schema.toml"))
|
let schema_toml = std::fs::read_to_string(root.join("api-schema.toml"))
|
||||||
.context("reading api-schema.toml")?;
|
.context("reading api-schema.toml")?;
|
||||||
let doc_md =
|
let doc_md =
|
||||||
std::fs::read_to_string(root.join("Documentation.md")).context("reading Documentation.md")?;
|
std::fs::read_to_string(root.join("Documentation.md")).context("reading Documentation.md")?;
|
||||||
|
let readme_md =
|
||||||
|
std::fs::read_to_string(root.join("README.md")).context("reading README.md")?;
|
||||||
|
|
||||||
let report = check(&lua_src, &ipc_src, &schema_toml, &doc_md)?;
|
let report = check(&lua_src, &ipc_src, &cli_src, &schema_toml, &doc_md, &readme_md)?;
|
||||||
report.print();
|
report.print();
|
||||||
Ok(report.is_clean())
|
Ok(report.is_clean())
|
||||||
}
|
}
|
||||||
|
|
@ -87,7 +100,7 @@ struct SchemaEntry {
|
||||||
since: String,
|
since: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
const VALID_KINDS: &[&str] = &["lua_function", "lua_table", "ipc_method"];
|
const VALID_KINDS: &[&str] = &["lua_function", "lua_table", "ipc_method", "cli_command"];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Extraction: breadd/src/lua/mod.rs -> the current bread.* Lua API surface.
|
// Extraction: breadd/src/lua/mod.rs -> the current bread.* Lua API surface.
|
||||||
|
|
@ -259,6 +272,92 @@ fn extract_ipc_methods(src: &str) -> BTreeSet<String> {
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Extraction: bread-cli/src/main.rs -> the current `bread` CLI command
|
||||||
|
// surface.
|
||||||
|
//
|
||||||
|
// Same deliberate textual-scanning approach as the Lua/IPC extractors
|
||||||
|
// above, not a real Rust/clap-derive-macro-expanding parser.
|
||||||
|
|
||||||
|
/// Top-level `Commands` variants that delegate to a nested subcommand enum
|
||||||
|
/// via `#[command(subcommand)] subcommand: XCommand` rather than being a
|
||||||
|
/// leaf command themselves — each such variant's own leaf commands are
|
||||||
|
/// named `<variant-kebab>.<subcommand-kebab>` (e.g. `modules.audit`,
|
||||||
|
/// `hooks.install-shell`) instead of the group variant itself being a leaf.
|
||||||
|
/// Update this list if `bread-cli/src/main.rs` gains another subcommand
|
||||||
|
/// group (the same kind of manual-update-needed list as `TABLE_VARS` above).
|
||||||
|
const SUBCOMMAND_GROUPS: &[(&str, &str)] = &[("Modules", "ModulesCommand"), ("Hooks", "HooksCommand")];
|
||||||
|
|
||||||
|
/// clap's default rename for a derived `Subcommand` variant: PascalCase ->
|
||||||
|
/// kebab-case (`ProfileList` -> `profile-list`, `InstallShell` ->
|
||||||
|
/// `install-shell`). This is what actually shows up as `bread <name>` on
|
||||||
|
/// the command line and in README's CLI reference.
|
||||||
|
fn kebab(pascal: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for (i, c) in pascal.chars().enumerate() {
|
||||||
|
if c.is_ascii_uppercase() {
|
||||||
|
if i != 0 {
|
||||||
|
out.push('-');
|
||||||
|
}
|
||||||
|
out.push(c.to_ascii_lowercase());
|
||||||
|
} else {
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan a `#[derive(Subcommand, ...)] enum <enum_name> { ... }` block for
|
||||||
|
/// its variant names, in source order. Depth-tracked the same way
|
||||||
|
/// `extract_ipc_methods` tracks match-arm depth, so a variant's own nested
|
||||||
|
/// struct-style fields (and their attributes/doc comments) aren't mistaken
|
||||||
|
/// for sibling variants.
|
||||||
|
fn extract_enum_variants(src: &str, enum_name: &str) -> Vec<String> {
|
||||||
|
let marker = format!("enum {enum_name} {{");
|
||||||
|
let Some(start) = src.find(&marker) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut depth: i32 = 0;
|
||||||
|
for line in src[start..].lines() {
|
||||||
|
let pre_depth = depth;
|
||||||
|
let trimmed = line.trim_start();
|
||||||
|
|
||||||
|
if pre_depth == 1 {
|
||||||
|
let name_end = trimmed
|
||||||
|
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
|
||||||
|
.unwrap_or(trimmed.len());
|
||||||
|
let name = &trimmed[..name_end];
|
||||||
|
if !name.is_empty() && name.starts_with(|c: char| c.is_ascii_uppercase()) {
|
||||||
|
out.push(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
depth += line.matches('{').count() as i32;
|
||||||
|
depth -= line.matches('}').count() as i32;
|
||||||
|
if pre_depth >= 1 && depth <= 0 {
|
||||||
|
break; // closed the enum block
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_cli_commands(src: &str) -> BTreeSet<String> {
|
||||||
|
let mut out = BTreeSet::new();
|
||||||
|
for variant in extract_enum_variants(src, "Commands") {
|
||||||
|
if let Some((_, sub_enum)) = SUBCOMMAND_GROUPS.iter().find(|(v, _)| *v == variant) {
|
||||||
|
let group = kebab(&variant);
|
||||||
|
for sub in extract_enum_variants(src, sub_enum) {
|
||||||
|
out.insert(format!("{group}.{}", kebab(&sub)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.insert(kebab(&variant));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Documentation.md cross-checks
|
// Documentation.md cross-checks
|
||||||
|
|
||||||
|
|
@ -283,6 +382,13 @@ fn ipc_section(doc: &str) -> &str {
|
||||||
section(doc, "## Dictionary: IPC protocol")
|
section(doc, "## Dictionary: IPC protocol")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// README.md cross-check
|
||||||
|
|
||||||
|
fn readme_cli_section(readme: &str) -> &str {
|
||||||
|
section(readme, "## CLI reference")
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Report
|
// Report
|
||||||
|
|
||||||
|
|
@ -293,8 +399,11 @@ struct CheckReport {
|
||||||
/// In api-schema.toml, no longer in code.
|
/// In api-schema.toml, no longer in code.
|
||||||
stale_in_schema: Vec<(String, String)>,
|
stale_in_schema: Vec<(String, String)>,
|
||||||
/// In api-schema.toml (and in code), but Documentation.md has no
|
/// In api-schema.toml (and in code), but Documentation.md has no
|
||||||
/// heading/row for it.
|
/// heading/row for it (lua_function/lua_table/ipc_method only).
|
||||||
undocumented: Vec<(String, String)>,
|
undocumented: Vec<(String, String)>,
|
||||||
|
/// In api-schema.toml (and in code), but README's "CLI reference"
|
||||||
|
/// section has no `bread <name>` line for it (cli_command only).
|
||||||
|
missing_from_readme: Vec<(String, String)>,
|
||||||
/// Schema entries with an unrecognized `kind`.
|
/// Schema entries with an unrecognized `kind`.
|
||||||
bad_kind: Vec<(String, String)>,
|
bad_kind: Vec<(String, String)>,
|
||||||
/// Schema entries with an empty `since`.
|
/// Schema entries with an empty `since`.
|
||||||
|
|
@ -306,13 +415,14 @@ impl CheckReport {
|
||||||
self.missing_from_schema.is_empty()
|
self.missing_from_schema.is_empty()
|
||||||
&& self.stale_in_schema.is_empty()
|
&& self.stale_in_schema.is_empty()
|
||||||
&& self.undocumented.is_empty()
|
&& self.undocumented.is_empty()
|
||||||
|
&& self.missing_from_readme.is_empty()
|
||||||
&& self.bad_kind.is_empty()
|
&& self.bad_kind.is_empty()
|
||||||
&& self.missing_since.is_empty()
|
&& self.missing_since.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print(&self) {
|
fn print(&self) {
|
||||||
if self.is_clean() {
|
if self.is_clean() {
|
||||||
println!("check-docs: OK — api-schema.toml matches breadd's Lua/IPC surface and Documentation.md.");
|
println!("check-docs: OK — api-schema.toml matches breadd's Lua/IPC/CLI surface, Documentation.md, and README.md.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -348,6 +458,14 @@ impl CheckReport {
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !self.missing_from_readme.is_empty() {
|
||||||
|
println!("In api-schema.toml but missing from README.md's \"CLI reference\" section (no `bread <name>` line):");
|
||||||
|
for (kind, name) in &self.missing_from_readme {
|
||||||
|
println!(" - [{kind}] {name}");
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
if !self.missing_since.is_empty() {
|
if !self.missing_since.is_empty() {
|
||||||
println!("Schema entries with an empty `since`:");
|
println!("Schema entries with an empty `since`:");
|
||||||
for (kind, name) in &self.missing_since {
|
for (kind, name) in &self.missing_since {
|
||||||
|
|
@ -360,7 +478,14 @@ impl CheckReport {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn check(lua_src: &str, ipc_src: &str, schema_toml: &str, doc_md: &str) -> Result<CheckReport> {
|
fn check(
|
||||||
|
lua_src: &str,
|
||||||
|
ipc_src: &str,
|
||||||
|
cli_src: &str,
|
||||||
|
schema_toml: &str,
|
||||||
|
doc_md: &str,
|
||||||
|
readme_md: &str,
|
||||||
|
) -> Result<CheckReport> {
|
||||||
let schema: Schema = toml::from_str(schema_toml).context("parsing api-schema.toml")?;
|
let schema: Schema = toml::from_str(schema_toml).context("parsing api-schema.toml")?;
|
||||||
|
|
||||||
let mut report = CheckReport::default();
|
let mut report = CheckReport::default();
|
||||||
|
|
@ -383,7 +508,7 @@ fn check(lua_src: &str, ipc_src: &str, schema_toml: &str, doc_md: &str) -> Resul
|
||||||
let code_lua = extract_lua_bindings(lua_src);
|
let code_lua = extract_lua_bindings(lua_src);
|
||||||
let schema_lua: BTreeSet<(String, String)> = schema_set
|
let schema_lua: BTreeSet<(String, String)> = schema_set
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(kind, _)| kind != "ipc_method")
|
.filter(|(kind, _)| kind == "lua_function" || kind == "lua_table")
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|
@ -413,12 +538,32 @@ fn check(lua_src: &str, ipc_src: &str, schema_toml: &str, doc_md: &str) -> Resul
|
||||||
.push(("ipc_method".to_string(), name.clone()));
|
.push(("ipc_method".to_string(), name.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- schema vs Documentation.md ---
|
// --- code vs schema: CLI ---
|
||||||
|
let code_cli = extract_cli_commands(cli_src);
|
||||||
|
let schema_cli: BTreeSet<String> = schema_set
|
||||||
|
.iter()
|
||||||
|
.filter(|(kind, _)| kind == "cli_command")
|
||||||
|
.map(|(_, name)| name.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for name in code_cli.difference(&schema_cli) {
|
||||||
|
report
|
||||||
|
.missing_from_schema
|
||||||
|
.push(("cli_command".to_string(), name.clone()));
|
||||||
|
}
|
||||||
|
for name in schema_cli.difference(&code_cli) {
|
||||||
|
report
|
||||||
|
.stale_in_schema
|
||||||
|
.push(("cli_command".to_string(), name.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- schema vs Documentation.md (lua_function/lua_table/ipc_method only —
|
||||||
|
// cli_command is checked against README.md separately, below) ---
|
||||||
let lua_section = lua_api_section(doc_md);
|
let lua_section = lua_api_section(doc_md);
|
||||||
let ipc_tbl_section = ipc_section(doc_md);
|
let ipc_tbl_section = ipc_section(doc_md);
|
||||||
for entry in &schema.entry {
|
for entry in &schema.entry {
|
||||||
if !VALID_KINDS.contains(&entry.kind.as_str()) {
|
if !VALID_KINDS.contains(&entry.kind.as_str()) || entry.kind == "cli_command" {
|
||||||
continue; // already reported above
|
continue; // already reported above, or checked against README instead
|
||||||
}
|
}
|
||||||
let documented = if entry.kind == "ipc_method" {
|
let documented = if entry.kind == "ipc_method" {
|
||||||
ipc_tbl_section.contains(&format!("`{}`", entry.name))
|
ipc_tbl_section.contains(&format!("`{}`", entry.name))
|
||||||
|
|
@ -432,9 +577,24 @@ fn check(lua_src: &str, ipc_src: &str, schema_toml: &str, doc_md: &str) -> Resul
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- schema vs README.md (cli_command only) ---
|
||||||
|
let readme_cli = readme_cli_section(readme_md);
|
||||||
|
for entry in &schema.entry {
|
||||||
|
if entry.kind != "cli_command" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let cmd_text = format!("bread {}", entry.name.replace('.', " "));
|
||||||
|
if !readme_cli.contains(cmd_text.as_str()) {
|
||||||
|
report
|
||||||
|
.missing_from_readme
|
||||||
|
.push((entry.kind.clone(), entry.name.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
report.missing_from_schema.sort();
|
report.missing_from_schema.sort();
|
||||||
report.stale_in_schema.sort();
|
report.stale_in_schema.sort();
|
||||||
report.undocumented.sort();
|
report.undocumented.sort();
|
||||||
|
report.missing_from_readme.sort();
|
||||||
report.bad_kind.sort();
|
report.bad_kind.sort();
|
||||||
report.missing_since.sort();
|
report.missing_since.sort();
|
||||||
|
|
||||||
|
|
@ -492,6 +652,43 @@ mod tests {
|
||||||
| `ping` | - | Connectivity check |
|
| `ping` | - | Connectivity check |
|
||||||
| `emit` | - | Inject an event |
|
| `emit` | - | Inject an event |
|
||||||
| `events.subscribe` | - | Upgrade to streaming mode |
|
| `events.subscribe` | - | Upgrade to streaming mode |
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Mirrors the real `bread-cli/src/main.rs` shape closely enough to
|
||||||
|
/// exercise both a flat leaf command (`Ping`) and a subcommand group
|
||||||
|
/// (`Modules` -> `ModulesCommand`), including a struct-style variant
|
||||||
|
/// with fields (`Info { name: String }`).
|
||||||
|
const CLI_SRC: &str = r#"
|
||||||
|
enum Commands {
|
||||||
|
/// Health check daemon connectivity
|
||||||
|
Ping,
|
||||||
|
/// Manage installed Lua modules
|
||||||
|
Modules {
|
||||||
|
#[command(subcommand)]
|
||||||
|
subcommand: ModulesCommand,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ModulesCommand {
|
||||||
|
/// List all installed modules
|
||||||
|
List,
|
||||||
|
/// Show full manifest details for a module
|
||||||
|
Info { name: String },
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
const README_MD: &str = r#"
|
||||||
|
## CLI reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bread ping
|
||||||
|
bread modules list
|
||||||
|
bread modules info <name>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module system
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
fn schema_toml_for(entries: &[(&str, &str, &str)]) -> String {
|
fn schema_toml_for(entries: &[(&str, &str, &str)]) -> String {
|
||||||
|
|
@ -517,6 +714,9 @@ mod tests {
|
||||||
("ping", "ipc_method", "1.0"),
|
("ping", "ipc_method", "1.0"),
|
||||||
("emit", "ipc_method", "1.0"),
|
("emit", "ipc_method", "1.0"),
|
||||||
("events.subscribe", "ipc_method", "1.0"),
|
("events.subscribe", "ipc_method", "1.0"),
|
||||||
|
("ping", "cli_command", "0.7"),
|
||||||
|
("modules.list", "cli_command", "0.7"),
|
||||||
|
("modules.info", "cli_command", "0.7"),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -554,7 +754,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clean_state_passes() {
|
fn clean_state_passes() {
|
||||||
let report = check(LUA_SRC, IPC_SRC, &full_schema(), DOC_MD).unwrap();
|
let report = check(LUA_SRC, IPC_SRC, CLI_SRC, &full_schema(), DOC_MD, README_MD).unwrap();
|
||||||
assert!(report.is_clean(), "{report:#?}");
|
assert!(report.is_clean(), "{report:#?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -564,7 +764,15 @@ mod tests {
|
||||||
// updating api-schema.toml: schema still says "debounce", code no
|
// updating api-schema.toml: schema still says "debounce", code no
|
||||||
// longer does (only "debounce_v2" now exists).
|
// longer does (only "debounce_v2" now exists).
|
||||||
let renamed_lua_src = LUA_SRC.replace("bread.debounce", "bread.debounce_v2");
|
let renamed_lua_src = LUA_SRC.replace("bread.debounce", "bread.debounce_v2");
|
||||||
let report = check(&renamed_lua_src, IPC_SRC, &full_schema(), DOC_MD).unwrap();
|
let report = check(
|
||||||
|
&renamed_lua_src,
|
||||||
|
IPC_SRC,
|
||||||
|
CLI_SRC,
|
||||||
|
&full_schema(),
|
||||||
|
DOC_MD,
|
||||||
|
README_MD,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert!(!report.is_clean());
|
assert!(!report.is_clean());
|
||||||
assert!(report
|
assert!(report
|
||||||
.missing_from_schema
|
.missing_from_schema
|
||||||
|
|
@ -577,7 +785,15 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn removed_ipc_method_is_caught_as_drift() {
|
fn removed_ipc_method_is_caught_as_drift() {
|
||||||
let without_emit = IPC_SRC.replacen("\"emit\" => {", "\"emit_removed\" => {", 1);
|
let without_emit = IPC_SRC.replacen("\"emit\" => {", "\"emit_removed\" => {", 1);
|
||||||
let report = check(LUA_SRC, &without_emit, &full_schema(), DOC_MD).unwrap();
|
let report = check(
|
||||||
|
LUA_SRC,
|
||||||
|
&without_emit,
|
||||||
|
CLI_SRC,
|
||||||
|
&full_schema(),
|
||||||
|
DOC_MD,
|
||||||
|
README_MD,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert!(!report.is_clean());
|
assert!(!report.is_clean());
|
||||||
assert!(report
|
assert!(report
|
||||||
.stale_in_schema
|
.stale_in_schema
|
||||||
|
|
@ -587,7 +803,15 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_doc_heading_is_caught_as_drift() {
|
fn missing_doc_heading_is_caught_as_drift() {
|
||||||
let doc_without_spawn_heading = DOC_MD.replace("#### `bread.spawn(fn)`\n", "");
|
let doc_without_spawn_heading = DOC_MD.replace("#### `bread.spawn(fn)`\n", "");
|
||||||
let report = check(LUA_SRC, IPC_SRC, &full_schema(), &doc_without_spawn_heading).unwrap();
|
let report = check(
|
||||||
|
LUA_SRC,
|
||||||
|
IPC_SRC,
|
||||||
|
CLI_SRC,
|
||||||
|
&full_schema(),
|
||||||
|
&doc_without_spawn_heading,
|
||||||
|
README_MD,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert!(!report.is_clean());
|
assert!(!report.is_clean());
|
||||||
assert!(report
|
assert!(report
|
||||||
.undocumented
|
.undocumented
|
||||||
|
|
@ -597,10 +821,74 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_kind_is_rejected() {
|
fn unknown_kind_is_rejected() {
|
||||||
let bad_schema = schema_toml_for(&[("on", "lua_thing", "1.0")]);
|
let bad_schema = schema_toml_for(&[("on", "lua_thing", "1.0")]);
|
||||||
let report = check(LUA_SRC, IPC_SRC, &bad_schema, DOC_MD).unwrap();
|
let report = check(LUA_SRC, IPC_SRC, CLI_SRC, &bad_schema, DOC_MD, README_MD).unwrap();
|
||||||
assert!(!report.is_clean());
|
assert!(!report.is_clean());
|
||||||
assert!(report
|
assert!(report
|
||||||
.bad_kind
|
.bad_kind
|
||||||
.contains(&("lua_thing".to_string(), "on".to_string())));
|
.contains(&("lua_thing".to_string(), "on".to_string())));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kebab_converts_pascal_case_correctly() {
|
||||||
|
assert_eq!(kebab("Ping"), "ping");
|
||||||
|
assert_eq!(kebab("ProfileList"), "profile-list");
|
||||||
|
assert_eq!(kebab("InstallShell"), "install-shell");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_cli_commands_with_subcommand_groups_dotted() {
|
||||||
|
let got = extract_cli_commands(CLI_SRC);
|
||||||
|
let want: BTreeSet<String> = ["ping", "modules.list", "modules.info"]
|
||||||
|
.into_iter()
|
||||||
|
.map(String::from)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
got, want,
|
||||||
|
"flat leaf commands stay bare; subcommand-group members get `<group>.<sub>` names"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renamed_cli_command_is_caught_as_drift() {
|
||||||
|
// Simulate a contributor renaming the `modules info` subcommand in
|
||||||
|
// code (e.g. to `Show`) without updating api-schema.toml.
|
||||||
|
let renamed_cli_src = CLI_SRC.replace("Info { name: String }", "Show { name: String }");
|
||||||
|
let report = check(
|
||||||
|
LUA_SRC,
|
||||||
|
IPC_SRC,
|
||||||
|
&renamed_cli_src,
|
||||||
|
&full_schema(),
|
||||||
|
DOC_MD,
|
||||||
|
README_MD,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!report.is_clean());
|
||||||
|
assert!(report
|
||||||
|
.missing_from_schema
|
||||||
|
.contains(&("cli_command".to_string(), "modules.show".to_string())));
|
||||||
|
assert!(report
|
||||||
|
.stale_in_schema
|
||||||
|
.contains(&("cli_command".to_string(), "modules.info".to_string())));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_readme_cli_line_is_caught_as_drift() {
|
||||||
|
// The exact class of drift this coverage exists to catch: a real
|
||||||
|
// command exists in code and api-schema.toml, but README's "CLI
|
||||||
|
// reference" section never got a line added for it.
|
||||||
|
let readme_without_modules_info = README_MD.replace("bread modules info <name>\n", "");
|
||||||
|
let report = check(
|
||||||
|
LUA_SRC,
|
||||||
|
IPC_SRC,
|
||||||
|
CLI_SRC,
|
||||||
|
&full_schema(),
|
||||||
|
DOC_MD,
|
||||||
|
&readme_without_modules_info,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!report.is_clean());
|
||||||
|
assert!(report
|
||||||
|
.missing_from_readme
|
||||||
|
.contains(&("cli_command".to_string(), "modules.info".to_string())));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue