diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml index 0297b74..5c41a7c 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -18,6 +18,13 @@ jobs: git clone --branch main --depth 1 \ "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 run: cd src && cargo build --release --locked diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2dac8b5..4d7c202 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,37 +69,27 @@ 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. +`Documentation.md`'s Lua API/IPC protocol sections and README's CLI +reference are hand-written and have drifted from the actual code before — +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 -(`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. -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). +2. Add/update the corresponding section — a `#### bread.` heading in + `Documentation.md` for a Lua binding, a row in `Documentation.md`'s IPC + Methods table for an IPC method, or a `bread ` 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 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. - -`check-docs` is **not yet wired into CI** — it's a local, manually-run -check today, not an enforced gate. That's a deliberate, still-open gap -(not an oversight): CI pipeline changes get a separate review pass before -landing, same as any other workflow-file edit. Wiring `cargo run -p xtask --- check-docs` into `dev-release.yml` (fail the build on drift) is the -natural next step whenever that review happens — until then, discipline -running it before committing is what keeps `api-schema.toml`, the code, -and `Documentation.md` in sync, not anything automatic. - -Also note `check-docs`'s scope: it covers `bread.*` Lua bindings and IPC -methods against `Documentation.md` only. It does not cover the `bread` -CLI's subcommands against `README.md`'s hand-written CLI reference — -that's a separate, currently-unguarded copy of information (see -`README.md`'s "CLI reference" section) and has drifted before for exactly -the same reason `Documentation.md` used to. + undocumented, stale in the schema, or missing a doc line — if + `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 diff --git a/api-schema.toml b/api-schema.toml index 0ff41ed..8179c29 100644 --- a/api-schema.toml +++ b/api-schema.toml @@ -1,13 +1,14 @@ # 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: +# This is Workstream F 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, IPC method, and `bread` CLI command +# that is supposed to exist right now, and `cargo run -p xtask -- check-docs` +# (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 # (`bread.set("name", ...)` calls, the nested `_tbl.set(...)` calls @@ -18,26 +19,49 @@ # 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). +# 3. The actual `bread` CLI commands declared in bread-cli/src/main.rs's +# `Commands`/`ModulesCommand`/`HooksCommand` enums. +# 4. Documentation.md, to make sure each lua_function/lua_table/ipc_method +# entry here still has a `#### bread.` 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 ` 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. -# 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 -# 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". -# `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. +# `kind` is one of: "lua_function", "lua_table", "ipc_method", "cli_command". +# +# `since` means two different things depending on `kind`, because CLI +# commands were never part of the Bread Automation API's own versioned +# 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 # 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 +# attribute macros — overkill for a ~45-entry 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 @@ -498,3 +522,89 @@ since = "1.2" name = "widgets.list" kind = "ipc_method" 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" diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 9603084..2ae2534 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -2,11 +2,20 @@ //! 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. +//! `bread.*` Lua binding surface + IPC method surface + `bread` CLI command +//! surface (as implemented in `breadd/src/lua/mod.rs`, `breadd/src/ipc/mod.rs`, +//! and `bread-cli/src/main.rs`) and the checked-in registry at +//! `api-schema.toml`, cross-referenced against `Documentation.md` (Lua/IPC) +//! 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::path::{Path, PathBuf}; @@ -50,12 +59,16 @@ fn run_check_docs() -> Result { .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 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")) .context("reading api-schema.toml")?; let doc_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(); Ok(report.is_clean()) } @@ -87,7 +100,7 @@ struct SchemaEntry { 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. @@ -259,6 +272,92 @@ fn extract_ipc_methods(src: &str) -> BTreeSet { 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 `.` (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 ` 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 { ... }` 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 { + 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 { + 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 @@ -283,6 +382,13 @@ fn ipc_section(doc: &str) -> &str { section(doc, "## Dictionary: IPC protocol") } +// --------------------------------------------------------------------------- +// README.md cross-check + +fn readme_cli_section(readme: &str) -> &str { + section(readme, "## CLI reference") +} + // --------------------------------------------------------------------------- // Report @@ -293,8 +399,11 @@ struct CheckReport { /// 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. + /// heading/row for it (lua_function/lua_table/ipc_method only). undocumented: Vec<(String, String)>, + /// In api-schema.toml (and in code), but README's "CLI reference" + /// section has no `bread ` line for it (cli_command only). + missing_from_readme: Vec<(String, String)>, /// Schema entries with an unrecognized `kind`. bad_kind: Vec<(String, String)>, /// Schema entries with an empty `since`. @@ -306,13 +415,14 @@ impl CheckReport { self.missing_from_schema.is_empty() && self.stale_in_schema.is_empty() && self.undocumented.is_empty() + && self.missing_from_readme.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."); + println!("check-docs: OK — api-schema.toml matches breadd's Lua/IPC/CLI surface, Documentation.md, and README.md."); return; } @@ -348,6 +458,14 @@ impl CheckReport { println!(); } + if !self.missing_from_readme.is_empty() { + println!("In api-schema.toml but missing from README.md's \"CLI reference\" section (no `bread ` line):"); + for (kind, name) in &self.missing_from_readme { + println!(" - [{kind}] {name}"); + } + println!(); + } + if !self.missing_since.is_empty() { println!("Schema entries with an empty `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 { +fn check( + lua_src: &str, + ipc_src: &str, + cli_src: &str, + schema_toml: &str, + doc_md: &str, + readme_md: &str, +) -> Result { let schema: Schema = toml::from_str(schema_toml).context("parsing api-schema.toml")?; 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 schema_lua: BTreeSet<(String, String)> = schema_set .iter() - .filter(|(kind, _)| kind != "ipc_method") + .filter(|(kind, _)| kind == "lua_function" || kind == "lua_table") .cloned() .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())); } - // --- schema vs Documentation.md --- + // --- code vs schema: CLI --- + let code_cli = extract_cli_commands(cli_src); + let schema_cli: BTreeSet = 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 ipc_tbl_section = ipc_section(doc_md); for entry in &schema.entry { - if !VALID_KINDS.contains(&entry.kind.as_str()) { - continue; // already reported above + if !VALID_KINDS.contains(&entry.kind.as_str()) || entry.kind == "cli_command" { + continue; // already reported above, or checked against README instead } let documented = if entry.kind == "ipc_method" { 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.stale_in_schema.sort(); report.undocumented.sort(); + report.missing_from_readme.sort(); report.bad_kind.sort(); report.missing_since.sort(); @@ -492,6 +652,43 @@ mod tests { | `ping` | - | Connectivity check | | `emit` | - | Inject an event | | `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 +``` + +--- + +## Module system "#; fn schema_toml_for(entries: &[(&str, &str, &str)]) -> String { @@ -517,6 +714,9 @@ mod tests { ("ping", "ipc_method", "1.0"), ("emit", "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] 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:#?}"); } @@ -564,7 +764,15 @@ mod tests { // 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(); + let report = check( + &renamed_lua_src, + IPC_SRC, + CLI_SRC, + &full_schema(), + DOC_MD, + README_MD, + ) + .unwrap(); assert!(!report.is_clean()); assert!(report .missing_from_schema @@ -577,7 +785,15 @@ mod tests { #[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(); + let report = check( + LUA_SRC, + &without_emit, + CLI_SRC, + &full_schema(), + DOC_MD, + README_MD, + ) + .unwrap(); assert!(!report.is_clean()); assert!(report .stale_in_schema @@ -587,7 +803,15 @@ mod tests { #[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(); + let report = check( + LUA_SRC, + IPC_SRC, + CLI_SRC, + &full_schema(), + &doc_without_spawn_heading, + README_MD, + ) + .unwrap(); assert!(!report.is_clean()); assert!(report .undocumented @@ -597,10 +821,74 @@ mod tests { #[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(); + let report = check(LUA_SRC, IPC_SRC, CLI_SRC, &bad_schema, DOC_MD, README_MD).unwrap(); assert!(!report.is_clean()); assert!(report .bad_kind .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 = ["ping", "modules.list", "modules.info"] + .into_iter() + .map(String::from) + .collect(); + assert_eq!( + got, want, + "flat leaf commands stay bare; subcommand-group members get `.` 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 \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()))); + } }