Merge feature/schema-first-docs (Workstream F)

This commit is contained in:
Breadway 2026-08-04 22:37:16 +08:00
commit 3510c3ba90
7 changed files with 1152 additions and 0 deletions

View file

@ -67,6 +67,24 @@ cargo build --release --workspace
cargo test --release --workspace cargo test --release --workspace
``` ```
### Keeping the API docs honest
`Documentation.md`'s Lua API and IPC protocol sections are hand-written and
have drifted from the actual code before — there's a checked-in registry,
`api-schema.toml`, plus an `xtask` checker that catches it happening again.
Whenever you add, rename, or remove a `bread.*` Lua binding
(`breadd/src/lua/mod.rs`) or an IPC method (`breadd/src/ipc/mod.rs`):
1. Add/update/remove its entry in `api-schema.toml` to match.
2. Add/update the corresponding section in `Documentation.md` (a
`#### bread.<name>` heading for a Lua binding, or a row in the IPC
Methods table for an IPC method).
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
undocumented, stale in the schema, or missing a doc heading/row — if
`api-schema.toml`, the code, and `Documentation.md` don't all agree.
## CI ## CI
- `dev-release.yml` — triggered on push to `main`. - `dev-release.yml` — triggered on push to `main`.

9
Cargo.lock generated
View file

@ -2426,6 +2426,15 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "xtask"
version = "0.1.0"
dependencies = [
"anyhow",
"serde",
"toml",
]
[[package]] [[package]]
name = "zbus" name = "zbus"
version = "3.15.2" version = "3.15.2"

View file

@ -4,6 +4,7 @@ members = [
"breadd", "breadd",
"bread-cli", "bread-cli",
"bread-emit", "bread-emit",
"xtask",
] ]
resolver = "2" resolver = "2"

View file

@ -544,6 +544,10 @@ bread.hyprland.dispatch("exec", "kitty")
-- Set a keyword -- Set a keyword
bread.hyprland.keyword("monitor", "HDMI-A-1, 2560x1440, 0x0, 1") bread.hyprland.keyword("monitor", "HDMI-A-1, 2560x1440, 0x0, 1")
-- Send a raw request to the Hyprland socket, e.g. to evaluate a config-file
-- expression the way `hyprctl eval <expr>` does; returns the raw response string
local result = bread.hyprland.eval("some expression")
-- Query compositor state (returns deserialized Lua tables) -- Query compositor state (returns deserialized Lua tables)
local win = bread.hyprland.active_window() local win = bread.hyprland.active_window()
local monitors = bread.hyprland.monitors() local monitors = bread.hyprland.monitors()

500
api-schema.toml Normal file
View file

@ -0,0 +1,500 @@
# Bread Automation API schema — checked-in source-of-truth registry.
#
# This is Workstream F (scoped down) from the governance-hardening report:
# a *drift detector*, not a doc generator. `Documentation.md`'s "Dictionary:
# Lua API" section is hand-written prose (one `#### bread.<name>(...)`
# heading per binding, with worked examples and edge-case notes) — nothing
# here regenerates or reformats that prose. Instead, this file is the
# checked-in list of every `bread.*` Lua binding and IPC method that is
# supposed to exist right now, and `cargo run -p xtask -- check-docs`
# cross-checks it against:
#
# 1. The actual bindings registered in breadd/src/lua/mod.rs
# (`bread.set("name", ...)` calls, the nested `<x>_tbl.set(...)` calls
# for state/profile/hyprland/widget/machine/fs/json/bluetooth, and the
# handful of bindings defined via plain embedded Lua source rather than
# `bread.set` — log/warn/error/debounce/spawn/wait/wait_any/wait_all/
# workflow.*).
# 2. The actual IPC methods dispatched in breadd/src/ipc/mod.rs's
# `match req.method.as_str() { ... }` block, plus the specially-cased
# `events.subscribe` streaming upgrade.
# 3. Documentation.md itself, to make sure each entry here still has a
# `#### bread.<name>` heading (Lua) or a row in the IPC Methods table
# (IPC methods).
#
# Whenever you add, rename, or remove a `bread.*` binding or IPC method:
# 1. Update this file to match.
# 2. Update/add the corresponding section in Documentation.md.
# 3. Run `cargo run -p xtask -- check-docs` before committing — it fails
# loudly (non-zero exit) if the three are out of sync.
#
# `kind` is one of: "lua_function", "lua_table", "ipc_method".
# `since` is the API version (Documentation.md's "API Stability &
# Versioning" section) 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.
#
# 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
# build if they drift" option the source report names, as opposed to Rust
# attribute macros — overkill for a ~30-binding surface with no existing
# proc-macro infrastructure in this workspace). TOML specifically because
# `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
# format/parser needed anywhere in the ecosystem.
# ---------------------------------------------------------------------------
# Lua API — Events (breadd/src/lua/mod.rs install_api + install_wait_helper)
[[entry]]
name = "on"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "once"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "filter"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "off"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "emit"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "wait"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "spawn"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "wait_any"
kind = "lua_function"
since = "1.2"
[[entry]]
name = "wait_all"
kind = "lua_function"
since = "1.2"
# ---------------------------------------------------------------------------
# Lua API — Workflows (install_workflow_helpers) *(Since: v1.2)*
[[entry]]
name = "workflow"
kind = "lua_table"
since = "1.2"
[[entry]]
name = "workflow.define"
kind = "lua_function"
since = "1.2"
[[entry]]
name = "workflow.start"
kind = "lua_function"
since = "1.2"
[[entry]]
name = "workflow.step"
kind = "lua_function"
since = "1.2"
[[entry]]
name = "workflow.status"
kind = "lua_function"
since = "1.2"
[[entry]]
name = "workflow.list"
kind = "lua_function"
since = "1.2"
# ---------------------------------------------------------------------------
# Lua API — Widgets (widget_tbl) *(Since: v1.3)*
[[entry]]
name = "widget"
kind = "lua_table"
since = "1.3"
[[entry]]
name = "widget.register"
kind = "lua_function"
since = "1.3"
[[entry]]
name = "widget.update"
kind = "lua_function"
since = "1.3"
[[entry]]
name = "widget.remove"
kind = "lua_function"
since = "1.3"
[[entry]]
name = "widget.list"
kind = "lua_function"
since = "1.3"
# ---------------------------------------------------------------------------
# Lua API — State (state_tbl)
[[entry]]
name = "state"
kind = "lua_table"
since = "1.0"
[[entry]]
name = "state.get"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "state.monitors"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "state.active_workspace"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "state.active_window"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "state.devices"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "state.power"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "state.network"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "state.profile"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "state.watch"
kind = "lua_function"
since = "1.0"
# ---------------------------------------------------------------------------
# Lua API — Profiles (profile_tbl)
[[entry]]
name = "profile"
kind = "lua_table"
since = "1.0"
[[entry]]
name = "profile.activate"
kind = "lua_function"
since = "1.0"
# ---------------------------------------------------------------------------
# Lua API — Execution, notifications, timers
[[entry]]
name = "exec"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "exec_capture"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "notify"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "after"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "every"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "cancel"
kind = "lua_function"
since = "1.0"
# ---------------------------------------------------------------------------
# Lua API — Hyprland (hyprland_tbl)
[[entry]]
name = "hyprland"
kind = "lua_table"
since = "1.0"
[[entry]]
name = "hyprland.dispatch"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "hyprland.keyword"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "hyprland.eval"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "hyprland.active_window"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "hyprland.monitors"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "hyprland.workspaces"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "hyprland.clients"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "hyprland.on_raw"
kind = "lua_function"
since = "1.0"
# ---------------------------------------------------------------------------
# Lua API — Module declaration
[[entry]]
name = "module"
kind = "lua_function"
since = "1.0"
# ---------------------------------------------------------------------------
# Lua API — Machine and filesystem (machine_tbl / fs_tbl / json_tbl)
[[entry]]
name = "machine"
kind = "lua_table"
since = "1.0"
[[entry]]
name = "machine.name"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "machine.tags"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "machine.has_tag"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "fs"
kind = "lua_table"
since = "1.0"
[[entry]]
name = "fs.write"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "fs.read"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "fs.exists"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "fs.readlink"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "fs.expand"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "json"
kind = "lua_table"
since = "1.0"
[[entry]]
name = "json.decode"
kind = "lua_function"
since = "1.0"
# ---------------------------------------------------------------------------
# Lua API — Bluetooth (bluetooth_tbl)
[[entry]]
name = "bluetooth"
kind = "lua_table"
since = "1.0"
[[entry]]
name = "bluetooth.power"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "bluetooth.powered"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "bluetooth.connect"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "bluetooth.disconnect"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "bluetooth.scan"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "bluetooth.devices"
kind = "lua_function"
since = "1.0"
# ---------------------------------------------------------------------------
# Lua API — Utilities (install_log_helpers / install_debounce)
[[entry]]
name = "log"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "warn"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "error"
kind = "lua_function"
since = "1.0"
[[entry]]
name = "debounce"
kind = "lua_function"
since = "1.0"
# ---------------------------------------------------------------------------
# IPC methods (breadd/src/ipc/mod.rs handle_request + events.subscribe)
[[entry]]
name = "ping"
kind = "ipc_method"
since = "1.0"
[[entry]]
name = "health"
kind = "ipc_method"
since = "1.0"
[[entry]]
name = "state.get"
kind = "ipc_method"
since = "1.0"
[[entry]]
name = "state.dump"
kind = "ipc_method"
since = "1.0"
[[entry]]
name = "modules.list"
kind = "ipc_method"
since = "1.0"
[[entry]]
name = "modules.reload"
kind = "ipc_method"
since = "1.0"
[[entry]]
name = "profile.list"
kind = "ipc_method"
since = "1.0"
[[entry]]
name = "profile.activate"
kind = "ipc_method"
since = "1.0"
[[entry]]
name = "emit"
kind = "ipc_method"
since = "1.0"
[[entry]]
name = "events.subscribe"
kind = "ipc_method"
since = "1.0"
[[entry]]
name = "events.replay"
kind = "ipc_method"
since = "1.0"
[[entry]]
name = "workflows.list"
kind = "ipc_method"
since = "1.2"
[[entry]]
name = "widgets.list"
kind = "ipc_method"
since = "1.3"

14
xtask/Cargo.toml Normal file
View file

@ -0,0 +1,14 @@
[package]
name = "xtask"
version = "0.1.0"
edition = "2021"
publish = false
[[bin]]
name = "xtask"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
serde.workspace = true
toml = "0.8"

606
xtask/src/main.rs Normal file
View file

@ -0,0 +1,606 @@
//! Repo maintenance tasks that don't belong in the `breadd`/`bread-cli`
//! binaries themselves.
//!
//! Currently just `check-docs`: a drift detector between the actual
//! `bread.*` Lua binding surface + IPC method surface (as implemented in
//! `breadd/src/lua/mod.rs` / `breadd/src/ipc/mod.rs`) and the checked-in
//! registry at `api-schema.toml`, cross-referenced against `Documentation.md`.
//! See `api-schema.toml`'s header comment for why this exists and why it's a
//! *drift detector*, not a doc generator.
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use anyhow::{Context, Result};
use serde::Deserialize;
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
match args.first().map(String::as_str) {
Some("check-docs") => match run_check_docs() {
Ok(true) => ExitCode::SUCCESS,
Ok(false) => ExitCode::FAILURE,
Err(err) => {
eprintln!("xtask check-docs: error: {err:#}");
ExitCode::FAILURE
}
},
Some(other) => {
eprintln!("unknown xtask command '{other}'\n{}", usage());
ExitCode::FAILURE
}
None => {
eprintln!("{}", usage());
ExitCode::FAILURE
}
}
}
fn usage() -> &'static str {
"usage: cargo run -p xtask -- <command>\n\n\
Available commands:\n \
check-docs verify api-schema.toml matches breadd's Lua/IPC surface and Documentation.md"
}
fn run_check_docs() -> Result<bool> {
let root = repo_root()?;
let lua_src = std::fs::read_to_string(root.join("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"))
.context("reading breadd/src/ipc/mod.rs")?;
let schema_toml = std::fs::read_to_string(root.join("api-schema.toml"))
.context("reading api-schema.toml")?;
let doc_md =
std::fs::read_to_string(root.join("Documentation.md")).context("reading Documentation.md")?;
let report = check(&lua_src, &ipc_src, &schema_toml, &doc_md)?;
report.print();
Ok(report.is_clean())
}
fn repo_root() -> Result<PathBuf> {
// xtask's own Cargo.toml lives at <repo_root>/xtask/Cargo.toml, so its
// parent is the workspace root regardless of the caller's cwd (`cargo
// run -p xtask` sets CARGO_MANIFEST_DIR to xtask/, not the invocation dir).
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.map(Path::to_path_buf)
.context("xtask has no parent directory (unexpected workspace layout)")
}
// ---------------------------------------------------------------------------
// Schema
#[derive(Debug, Deserialize)]
struct Schema {
#[serde(default)]
entry: Vec<SchemaEntry>,
}
#[derive(Debug, Clone, Deserialize)]
struct SchemaEntry {
name: String,
kind: String,
#[serde(default)]
since: String,
}
const VALID_KINDS: &[&str] = &["lua_function", "lua_table", "ipc_method"];
// ---------------------------------------------------------------------------
// Extraction: breadd/src/lua/mod.rs -> the current bread.* Lua API surface.
//
// This is deliberately line/substring scanning, not a real Lua or Rust
// parser (see api-schema.toml's header comment) — precise enough because
// `install_api` and its `install_*_helpers` siblings follow a small, stable
// set of textual patterns:
//
// 1. `bread.set("name", ...)` - a top-level binding (function or,
// for the known sub-table variables
// below, a table).
// 2. `<x>_tbl.set("name", ...)` - a method nested under table `<x>`.
// 3. `function _bread.name(` /
// `function bread.name(` - a plain-Lua-defined top-level
// function (log/warn/error/debounce).
// 4. `bread.name = function` - ditto, assignment form
// (spawn/wait/wait_any/wait_all).
// 5. `bread.workflow = {}` and
// `bread.workflow.name = function` - the `bread.workflow` table and its
// members.
/// Sub-table variables built in `install_api` and registered onto the
/// `bread` global, mapped to the dotted parent name they hang off.
/// Deliberately excludes `module_tbl` / `store_tbl`: those back the
/// per-module `M` object returned by `bread.module(...)` (i.e. `M.store.get`
/// etc.), which is a different namespace from `bread.*` itself.
const TABLE_VARS: &[(&str, &str)] = &[
("state_tbl", "state"),
("profile_tbl", "profile"),
("hyprland_tbl", "hyprland"),
("widget_tbl", "widget"),
("machine_tbl", "machine"),
("fs_tbl", "fs"),
("json_tbl", "json"),
("bluetooth_tbl", "bluetooth"),
];
/// Read a bare identifier (`[A-Za-z0-9_]+`) starting at byte offset `start`.
fn ident_at(text: &str, start: usize) -> &str {
let rest = &text[start..];
let end = rest
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
.unwrap_or(rest.len());
&rest[..end]
}
/// Read up to (not including) the next `"` starting at byte offset `start`.
fn ident_upto_quote(text: &str, start: usize) -> &str {
let rest = &text[start..];
let end = rest.find('"').unwrap_or(rest.len());
&rest[..end]
}
fn extract_lua_bindings(src: &str) -> BTreeSet<(String, String)> {
let mut out = BTreeSet::new();
// 1. Top-level `bread.set("name", ...)`.
for (idx, _) in src.match_indices("bread.set(\"") {
let name = ident_upto_quote(src, idx + "bread.set(\"".len());
if name.is_empty() || name.starts_with("__") {
continue; // internal Rust<->Lua bridge fn (e.g. __log_info), not public API
}
let is_table = TABLE_VARS.iter().any(|(_, parent)| *parent == name);
out.insert((
(if is_table { "lua_table" } else { "lua_function" }).to_string(),
name.to_string(),
));
}
// 2. Nested `<x>_tbl.set("name", ...)` for the known sub-tables.
for (var, parent) in TABLE_VARS {
let prefix = format!("{var}.set(\"");
for (idx, _) in src.match_indices(prefix.as_str()) {
let name = ident_upto_quote(src, idx + prefix.len());
if name.is_empty() {
continue;
}
out.insert(("lua_function".to_string(), format!("{parent}.{name}")));
}
}
// 3. `function _bread.name(` / `function bread.name(`.
for prefix in ["function _bread.", "function bread."] {
for (idx, _) in src.match_indices(prefix) {
let name = ident_at(src, idx + prefix.len());
if !name.is_empty() {
out.insert(("lua_function".to_string(), name.to_string()));
}
}
}
// 4. `bread.name = function` (top-level only; `bread.workflow.*` is
// handled separately in step 5 since it's a nested table's members).
for (idx, _) in src.match_indices("bread.") {
let name_start = idx + "bread.".len();
let name = ident_at(src, name_start);
if name.is_empty() || name == "workflow" {
continue;
}
if src[name_start + name.len()..].trim_start().starts_with("= function") {
out.insert(("lua_function".to_string(), name.to_string()));
}
}
// 5. `bread.workflow = {}` and `bread.workflow.name = function`.
if src.contains("bread.workflow = {}") {
out.insert(("lua_table".to_string(), "workflow".to_string()));
}
for (idx, _) in src.match_indices("bread.workflow.") {
let name_start = idx + "bread.workflow.".len();
let name = ident_at(src, name_start);
if name.is_empty() {
continue;
}
if src[name_start + name.len()..].trim_start().starts_with("= function") {
out.insert(("lua_function".to_string(), format!("workflow.{name}")));
}
}
out
}
// ---------------------------------------------------------------------------
// Extraction: breadd/src/ipc/mod.rs -> the current IPC method surface.
fn extract_ipc_methods(src: &str) -> BTreeSet<String> {
let mut out = BTreeSet::new();
// `events.subscribe` is special-cased ahead of the dispatch `match`
// (it upgrades the connection to a streaming socket instead of
// returning a single response), so it never appears as a match arm.
if src.contains("req.method == \"events.subscribe\"") {
out.insert("events.subscribe".to_string());
}
let Some(match_start) = src.find("match req.method.as_str() {") else {
return out;
};
// Track brace depth so a `"literal" => ...` pattern belonging to some
// *other*, nested match inside an arm's body (e.g. the `"emit"` arm's
// own `match source_str { "terminal" => ..., "git" => ... }`) isn't
// mistaken for a top-level IPC method arm. Only depth == 1 (directly
// inside the outer `match req.method.as_str() { ... }`) counts.
let mut depth: i32 = 0;
for line in src[match_start..].lines() {
let pre_depth = depth;
let trimmed = line.trim_start();
if pre_depth == 1 {
if let Some(rest) = trimmed.strip_prefix('"') {
if let Some(end) = rest.find('"') {
let name = &rest[..end];
if rest[end + 1..].trim_start().starts_with("=>") {
out.insert(name.to_string());
}
}
}
}
depth += line.matches('{').count() as i32;
depth -= line.matches('}').count() as i32;
if pre_depth >= 1 && depth <= 0 {
break; // closed the outer match block
}
}
out
}
// ---------------------------------------------------------------------------
// Documentation.md cross-checks
/// Slice out a `## `-level section (from its heading up to, but not
/// including, the next `## `-level heading).
fn section<'a>(doc: &'a str, heading: &str) -> &'a str {
let Some(start) = doc.find(heading) else {
return "";
};
let rest = &doc[start..];
match rest[3..].find("\n## ") {
Some(i) => &rest[..i + 3],
None => rest,
}
}
fn lua_api_section(doc: &str) -> &str {
section(doc, "## Dictionary: Lua API")
}
fn ipc_section(doc: &str) -> &str {
section(doc, "## Dictionary: IPC protocol")
}
// ---------------------------------------------------------------------------
// Report
#[derive(Debug, Default)]
struct CheckReport {
/// In code, not in api-schema.toml.
missing_from_schema: Vec<(String, String)>,
/// In api-schema.toml, no longer in code.
stale_in_schema: Vec<(String, String)>,
/// In api-schema.toml (and in code), but Documentation.md has no
/// heading/row for it.
undocumented: Vec<(String, String)>,
/// Schema entries with an unrecognized `kind`.
bad_kind: Vec<(String, String)>,
/// Schema entries with an empty `since`.
missing_since: Vec<(String, String)>,
}
impl CheckReport {
fn is_clean(&self) -> bool {
self.missing_from_schema.is_empty()
&& self.stale_in_schema.is_empty()
&& self.undocumented.is_empty()
&& self.bad_kind.is_empty()
&& self.missing_since.is_empty()
}
fn print(&self) {
if self.is_clean() {
println!("check-docs: OK — api-schema.toml matches breadd's Lua/IPC surface and Documentation.md.");
return;
}
if !self.bad_kind.is_empty() {
println!("Schema entries with an unrecognized `kind` (expected one of {VALID_KINDS:?}):");
for (kind, name) in &self.bad_kind {
println!(" - {name} (kind = \"{kind}\")");
}
println!();
}
if !self.missing_from_schema.is_empty() {
println!("Added but undocumented in api-schema.toml (present in code, missing from schema):");
for (kind, name) in &self.missing_from_schema {
println!(" - [{kind}] {name}");
}
println!();
}
if !self.stale_in_schema.is_empty() {
println!("Stale in api-schema.toml (no longer found in code):");
for (kind, name) in &self.stale_in_schema {
println!(" - [{kind}] {name}");
}
println!();
}
if !self.undocumented.is_empty() {
println!("In api-schema.toml but missing from Documentation.md (no `#### bread.<name>` heading for a lua_function/lua_table, or no `<name>` row in the IPC Methods table):");
for (kind, name) in &self.undocumented {
println!(" - [{kind}] {name}");
}
println!();
}
if !self.missing_since.is_empty() {
println!("Schema entries with an empty `since`:");
for (kind, name) in &self.missing_since {
println!(" - [{kind}] {name}");
}
println!();
}
println!("check-docs: FAILED — see above.");
}
}
fn check(lua_src: &str, ipc_src: &str, schema_toml: &str, doc_md: &str) -> Result<CheckReport> {
let schema: Schema = toml::from_str(schema_toml).context("parsing api-schema.toml")?;
let mut report = CheckReport::default();
let mut schema_set: BTreeSet<(String, String)> = BTreeSet::new();
for entry in &schema.entry {
if !VALID_KINDS.contains(&entry.kind.as_str()) {
report.bad_kind.push((entry.kind.clone(), entry.name.clone()));
continue;
}
if entry.since.trim().is_empty() {
report
.missing_since
.push((entry.kind.clone(), entry.name.clone()));
}
schema_set.insert((entry.kind.clone(), entry.name.clone()));
}
// --- code vs schema: Lua ---
let code_lua = extract_lua_bindings(lua_src);
let schema_lua: BTreeSet<(String, String)> = schema_set
.iter()
.filter(|(kind, _)| kind != "ipc_method")
.cloned()
.collect();
for entry in code_lua.difference(&schema_lua) {
report.missing_from_schema.push(entry.clone());
}
for entry in schema_lua.difference(&code_lua) {
report.stale_in_schema.push(entry.clone());
}
// --- code vs schema: IPC ---
let code_ipc = extract_ipc_methods(ipc_src);
let schema_ipc: BTreeSet<String> = schema_set
.iter()
.filter(|(kind, _)| kind == "ipc_method")
.map(|(_, name)| name.clone())
.collect();
for name in code_ipc.difference(&schema_ipc) {
report
.missing_from_schema
.push(("ipc_method".to_string(), name.clone()));
}
for name in schema_ipc.difference(&code_ipc) {
report
.stale_in_schema
.push(("ipc_method".to_string(), name.clone()));
}
// --- schema vs Documentation.md ---
let lua_section = lua_api_section(doc_md);
let ipc_tbl_section = ipc_section(doc_md);
for entry in &schema.entry {
if !VALID_KINDS.contains(&entry.kind.as_str()) {
continue; // already reported above
}
let documented = if entry.kind == "ipc_method" {
ipc_tbl_section.contains(&format!("`{}`", entry.name))
} else {
lua_section.contains(&format!("bread.{}", entry.name))
};
if !documented {
report
.undocumented
.push((entry.kind.clone(), entry.name.clone()));
}
}
report.missing_from_schema.sort();
report.stale_in_schema.sort();
report.undocumented.sort();
report.bad_kind.sort();
report.missing_since.sort();
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
const LUA_SRC: &str = r#"
bread.set("on", on_fn)?;
bread.set("state", state_tbl)?;
state_tbl.set("get", get_fn)?;
state_tbl.set("watch", watch_fn)?;
bread.set("__log_info", info_fn)?;
function bread.debounce(delay_ms, fn)
bread.spawn = function(fn)
bread.workflow = {}
bread.workflow.define = function(name, fn)
"#;
const IPC_SRC: &str = r#"
if req.method == "events.subscribe" {
}
let result = match req.method.as_str() {
"ping" => Ok(json!({ "ok": true })),
"emit" => {
let source = match source_str {
"terminal" => AdapterSource::Terminal,
"git" => AdapterSource::Git,
other => AdapterSource::App(other.to_string()),
};
Ok(json!({ "emitted": true }))
}
_ => Err("unknown method".to_string()),
};
"#;
const DOC_MD: &str = r#"
## Dictionary: Lua API
#### `bread.on(pattern, fn) -> id`
#### `bread.state.get(path)`
#### `bread.state.watch(path, fn) -> id`
#### `bread.debounce(delay_ms, fn) -> wrapped_fn`
#### `bread.spawn(fn)`
### Workflows
#### `bread.workflow.define(name, fn)`
## Dictionary: IPC protocol
| Method | Params | Description |
|--------|--------|-------------|
| `ping` | - | Connectivity check |
| `emit` | - | Inject an event |
| `events.subscribe` | - | Upgrade to streaming mode |
"#;
fn schema_toml_for(entries: &[(&str, &str, &str)]) -> String {
let mut out = String::new();
for (name, kind, since) in entries {
out.push_str(&format!(
"[[entry]]\nname = \"{name}\"\nkind = \"{kind}\"\nsince = \"{since}\"\n\n"
));
}
out
}
fn full_schema() -> String {
schema_toml_for(&[
("on", "lua_function", "1.0"),
("state", "lua_table", "1.0"),
("state.get", "lua_function", "1.0"),
("state.watch", "lua_function", "1.0"),
("debounce", "lua_function", "1.0"),
("spawn", "lua_function", "1.0"),
("workflow", "lua_table", "1.2"),
("workflow.define", "lua_function", "1.2"),
("ping", "ipc_method", "1.0"),
("emit", "ipc_method", "1.0"),
("events.subscribe", "ipc_method", "1.0"),
])
}
#[test]
fn extracts_lua_bindings_precisely() {
let got = extract_lua_bindings(LUA_SRC);
let want: BTreeSet<(String, String)> = [
("lua_function", "on"),
("lua_table", "state"),
("lua_function", "state.get"),
("lua_function", "state.watch"),
("lua_function", "debounce"),
("lua_function", "spawn"),
("lua_table", "workflow"),
("lua_function", "workflow.define"),
]
.into_iter()
.map(|(k, n)| (k.to_string(), n.to_string()))
.collect();
assert_eq!(got, want, "must exclude __-prefixed internals");
}
#[test]
fn extracts_ipc_methods_without_leaking_nested_match_arms() {
let got = extract_ipc_methods(IPC_SRC);
let want: BTreeSet<String> = ["events.subscribe", "ping", "emit"]
.into_iter()
.map(String::from)
.collect();
assert_eq!(
got, want,
"must not pick up \"terminal\"/\"git\" from the nested `match source_str` inside the emit arm"
);
}
#[test]
fn clean_state_passes() {
let report = check(LUA_SRC, IPC_SRC, &full_schema(), DOC_MD).unwrap();
assert!(report.is_clean(), "{report:#?}");
}
#[test]
fn renamed_schema_entry_is_caught_as_drift() {
// Simulate a contributor renaming `bread.debounce` in code without
// updating api-schema.toml: schema still says "debounce", code no
// longer does (only "debounce_v2" now exists).
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();
assert!(!report.is_clean());
assert!(report
.missing_from_schema
.contains(&("lua_function".to_string(), "debounce_v2".to_string())));
assert!(report
.stale_in_schema
.contains(&("lua_function".to_string(), "debounce".to_string())));
}
#[test]
fn removed_ipc_method_is_caught_as_drift() {
let without_emit = IPC_SRC.replacen("\"emit\" => {", "\"emit_removed\" => {", 1);
let report = check(LUA_SRC, &without_emit, &full_schema(), DOC_MD).unwrap();
assert!(!report.is_clean());
assert!(report
.stale_in_schema
.contains(&("ipc_method".to_string(), "emit".to_string())));
}
#[test]
fn missing_doc_heading_is_caught_as_drift() {
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();
assert!(!report.is_clean());
assert!(report
.undocumented
.contains(&("lua_function".to_string(), "spawn".to_string())));
}
#[test]
fn unknown_kind_is_rejected() {
let bad_schema = schema_toml_for(&[("on", "lua_thing", "1.0")]);
let report = check(LUA_SRC, IPC_SRC, &bad_schema, DOC_MD).unwrap();
assert!(!report.is_clean());
assert!(report
.bad_kind
.contains(&("lua_thing".to_string(), "on".to_string())));
}
}