Add schema-first API docs drift detector (xtask check-docs)
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.
This commit is contained in:
parent
96639516b1
commit
c5e871694a
7 changed files with 1152 additions and 0 deletions
606
xtask/src/main.rs
Normal file
606
xtask/src/main.rs
Normal 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())));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue