From c5e871694a897f36eb8b890b518fac16a20a6d05 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 4 Aug 2026 18:21:05 +0800 Subject: [PATCH] Add schema-first API docs drift detector (xtask check-docs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documentation.md's Lua API and IPC protocol sections are hand-written prose and have drifted from the actual bread.* binding / IPC method surface before. This adds a checked-in registry (api-schema.toml) of every current Lua binding and IPC method, plus a new xtask crate (`cargo run -p xtask -- check-docs`) that cross-checks it against breadd/src/lua/mod.rs, breadd/src/ipc/mod.rs, and Documentation.md itself, failing loudly on any mismatch. It's a drift detector, not a doc generator — Documentation.md's prose is untouched except for one genuine gap the bootstrap surfaced: bread.hyprland.eval had no mention anywhere in the docs, now given a one-line example alongside its siblings. CONTRIBUTING.md documents the resulting contributor workflow. --- CONTRIBUTING.md | 18 ++ Cargo.lock | 9 + Cargo.toml | 1 + Documentation.md | 4 + api-schema.toml | 500 ++++++++++++++++++++++++++++++++++++++ xtask/Cargo.toml | 14 ++ xtask/src/main.rs | 606 ++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 1152 insertions(+) create mode 100644 api-schema.toml create mode 100644 xtask/Cargo.toml create mode 100644 xtask/src/main.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 963324f..754ff2c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,6 +67,24 @@ cargo build --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.` 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 - `dev-release.yml` — triggered on push to `main`. diff --git a/Cargo.lock b/Cargo.lock index 00911d8..f70cba7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2414,6 +2414,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "xtask" +version = "0.1.0" +dependencies = [ + "anyhow", + "serde", + "toml", +] + [[package]] name = "zbus" version = "3.15.2" diff --git a/Cargo.toml b/Cargo.toml index 9df1f11..5e67e67 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "breadd", "bread-cli", "bread-emit", + "xtask", ] resolver = "2" diff --git a/Documentation.md b/Documentation.md index 1520ab5..019b838 100644 --- a/Documentation.md +++ b/Documentation.md @@ -543,6 +543,10 @@ bread.hyprland.dispatch("exec", "kitty") -- Set a keyword 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 ` does; returns the raw response string +local result = bread.hyprland.eval("some expression") + -- Query compositor state (returns deserialized Lua tables) local win = bread.hyprland.active_window() local monitors = bread.hyprland.monitors() diff --git a/api-schema.toml b/api-schema.toml new file mode 100644 index 0000000..0ff41ed --- /dev/null +++ b/api-schema.toml @@ -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.(...)` +# 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 `_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.` 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" diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 0000000..a6beaa9 --- /dev/null +++ b/xtask/Cargo.toml @@ -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" diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 0000000..9603084 --- /dev/null +++ b/xtask/src/main.rs @@ -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 = 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 -- \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 { + 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 { + // xtask's own Cargo.toml lives at /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, +} + +#[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. `_tbl.set("name", ...)` - a method nested under table ``. +// 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 `_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 { + 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.` heading for a lua_function/lua_table, or no `` 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 { + 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 = 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 = ["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()))); + } +}