crates/nornir-docgen/tests/claims_gate.rs
//! The `nornir:claim:` gate on the ENGINE's own entry points — the road the
//! forge takes.
//!
//! # Why this file exists and nornir's `tests/docs_claims.rs` was not enough
//!
//! The gate was written in `nornir/src/docs/` and wired into six wrapper
//! functions there, with genuinely red-first tests. Those tests pass and always
//! did — they drive `nornir::docs::render_all`, which is nornir's wrapper. The
//! wrapper is not the engine.
//!
//! `dwarves` depends on THIS crate directly and calls `nornir_docgen::render_all`,
//! `render_sources_in_place`, `build_book`, `build_books` and
//! `build_book_svg_pages` raw from `dwarves/crates/dwarves/src/docs/mod.rs`.
//! `gate_doc_claims` had zero occurrences anywhere in the dwarves tree. So the
//! forge that actually burns the books went round the gate, and MEASURED on
//! 2026-08-27 with the real binary (`dwarves docs render` on a fixture carrying
//! one claim that was false against its own tree) it rendered, exit 0, and
//! stamped `⚠ GENERATED … DO NOT EDIT` over the contradicted sentence.
//!
//! These tests call the engine functions with no wrapper in between, which is
//! what makes them able to see that hole. Every assertion below would have been
//! RED before the gate moved into `layout`/`book`; nornir's suite would not.
//!
//! Run: `cargo test -p nornir-docgen --features export --test claims_gate`
//! (the book builders are behind `export`; the render half needs no feature).
use std::fs;
use std::path::Path;
use nornir_docgen::{Ctx, RepoLayout, claims};
/// A minimal repo the render path accepts: a `.nornir/` source set and a `src/`
/// tree for claims to speak about.
fn fixture(root: &Path) {
fs::create_dir_all(root.join(".nornir")).unwrap();
fs::create_dir_all(root.join("src")).unwrap();
fs::write(
root.join("src/lib.rs"),
"pub fn shipped() {}\n// the OLD_WIRING was removed\n",
)
.unwrap();
}
fn ctx(root: &Path) -> Ctx<'_> {
Ctx::new(root, root, None)
}
/// The exact source shape that went through the forge: prose plus one claim the
/// tree contradicts.
fn a_false_claim(root: &Path) {
fs::write(
root.join(".nornir/README.md"),
"# fixture\n\n\
The NORNIR_MONITOR wiring was removed in June.\n\n\
<!-- nornir:claim: present \"fn removed_in_june\" in src/lib.rs -->\n",
)
.unwrap();
}
/// ★ THE ONE THIS LANE IS ABOUT. `render_all` is what `dwarves docs render`
/// calls. Before the move it returned `Ok`, wrote the artifact and stamped the
/// DO-NOT-EDIT banner over a false sentence.
#[test]
fn the_engines_render_all_refuses_a_false_claim_and_writes_nothing() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fixture(root);
a_false_claim(root);
let err = nornir_docgen::render_all(&RepoLayout::new(root), &ctx(root))
.expect_err(
"the ENGINE entry point must refuse — this is the call dwarves makes, and it \
rendered happily before the gate moved out of nornir's wrapper",
)
.to_string();
assert!(err.contains("docs claims REFUSED"), "{err}");
assert!(err.contains("claimed PRESENT"), "{err}");
assert!(
!root.join("README.md").exists(),
"a refusal must leave no artifact at all — the banner is the thing that re-authorises \
the sentence"
);
}
/// The in-place verb rewrites the SOURCE, so a refusal here is what stops the
/// claim's own file from being re-stamped.
#[test]
fn the_engines_render_sources_in_place_refuses_and_the_source_survives_byte_for_byte() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fixture(root);
let src = "# design\n<!-- nornir:claim: present \"fn removed_in_june\" in src/lib.rs -->\n";
fs::write(root.join(".nornir/design.md"), src).unwrap();
let err = nornir_docgen::render_sources_in_place(&RepoLayout::new(root), &ctx(root))
.expect_err("the engine's in-place verb must refuse")
.to_string();
assert!(err.contains("claimed PRESENT"), "{err}");
assert_eq!(
src,
fs::read_to_string(root.join(".nornir/design.md")).unwrap(),
"refusal must not touch the source"
);
}
/// `render_doc` is the WRITER both `render_all` and every single-doc caller route
/// through; gating it is what makes the rule hold by construction rather than by
/// remembering to gate each verb.
#[test]
fn the_engines_render_doc_refuses_too() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fixture(root);
a_false_claim(root);
let err = nornir_docgen::render_doc(&RepoLayout::new(root), "README.md", &ctx(root))
.expect_err("the single-doc writer must refuse")
.to_string();
assert!(err.contains("docs claims REFUSED"), "{err}");
}
/// A malformed marker must be an error, never a silent pass — otherwise a typo
/// disarms the claim it was meant to make.
#[test]
fn a_marker_the_engine_does_not_recognise_refuses_rather_than_passing() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fixture(root);
fs::write(
root.join(".nornir/README.md"),
"# fixture\n<!-- nornir:claim: verified \"anything\" in src -->\n",
)
.unwrap();
let err = nornir_docgen::render_all(&RepoLayout::new(root), &ctx(root))
.expect_err("an unknown verb must refuse")
.to_string();
assert!(err.contains("MALFORMED"), "{err}");
assert!(err.contains("unknown verb `verified`"), "{err}");
assert!(!root.join("README.md").exists(), "{err}");
}
/// THE OTHER HALF OF LAW 2: the gate must also let a TRUE claim through, or
/// "it refuses" is just "it is broken". A repo whose claims hold renders, and a
/// repo with no claims at all renders exactly as before the gate existed.
#[test]
fn a_true_claim_renders_and_no_claim_renders() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fixture(root);
fs::write(
root.join(".nornir/README.md"),
"# fixture\n\
<!-- nornir:claim: absent \"OLD_WIRING was reintroduced\" in src -->\n\
<!-- nornir:claim: present \"pub fn shipped\" in src/lib.rs -->\n\
the wiring is gone\n",
)
.unwrap();
let reports = nornir_docgen::render_all(&RepoLayout::new(root), &ctx(root))
.expect("two true claims must not block a render");
assert_eq!(reports.len(), 1);
assert!(fs::read_to_string(root.join("README.md")).unwrap().contains("the wiring is gone"));
// And the incremental-adoption promise: no marker, no constraint.
let tmp2 = tempfile::tempdir().unwrap();
let root2 = tmp2.path();
fixture(root2);
fs::write(root2.join(".nornir/README.md"), "# fixture\n\nplain prose\n").unwrap();
let reports = nornir_docgen::render_all(&RepoLayout::new(root2), &ctx(root2)).unwrap();
assert_eq!(reports.len(), 1);
assert!(fs::read_to_string(root2.join("README.md")).unwrap().contains("plain prose"));
}
/// The claim that was TRUE at the last render and is false now — the actual
/// two-month defect. It must go green→red as the TREE changes under a doc nobody
/// edited, and the already-committed artifact must survive the refusal untouched.
#[test]
fn a_claim_that_goes_false_under_an_unchanged_doc_refuses_the_next_render() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fixture(root);
let layout = RepoLayout::new(root);
fs::write(
root.join(".nornir/README.md"),
"# fixture\n\
<!-- nornir:claim: absent \"OLD_WIRING was reintroduced\" in src -->\n\
the wiring is gone\n",
)
.unwrap();
nornir_docgen::render_all(&layout, &ctx(root)).expect("green while the claim holds");
let committed = fs::read_to_string(root.join("README.md")).unwrap();
// Nobody touches the doc. The CODE moves.
fs::write(
root.join("src/lib.rs"),
"pub fn shipped() {}\n// OLD_WIRING was reintroduced\n",
)
.unwrap();
let err = nornir_docgen::render_all(&layout, &ctx(root))
.expect_err("the very next render must refuse")
.to_string();
assert!(err.contains("claimed ABSENT"), "{err}");
assert!(err.contains(".nornir/README.md:2"), "{err}");
assert_eq!(
committed,
fs::read_to_string(root.join("README.md")).unwrap(),
"refusing is fine; silently re-emitting is the defect"
);
}
// ── the book builders, the other three raw calls dwarves makes ──────────────
/// `dwarves docs book` calls `build_books` (and `build_book` for the `outputs =
/// ["pdf"]` shape, and `build_book_svg_pages` for `--format svg`). All three were
/// ungated on the forge's road.
#[cfg(feature = "export")]
#[test]
fn every_book_builder_refuses_a_false_claim() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fixture(root);
a_false_claim(root);
let c = ctx(root);
let format = nornir_docgen::DocFormat::parse("md").expect("md is a format");
// `BuiltBook` is not `Debug`, so unwrap the Result by hand rather than
// through `expect_err`.
let e = match nornir_docgen::build_books(root, &c, format) {
Ok(books) => panic!("build_books must refuse; it produced {} book(s)", books.len()),
Err(e) => e.to_string(),
};
assert!(e.contains("docs claims REFUSED"), "build_books: {e}");
let e = match nornir_docgen::build_book(root, &c, format) {
Ok((bytes, _)) => panic!("build_book must refuse; it produced {} bytes", bytes.len()),
Err(e) => e.to_string(),
};
assert!(e.contains("docs claims REFUSED"), "build_book: {e}");
let e = match nornir_docgen::build_book_svg_pages(root, &c) {
Ok((pages, ..)) => {
panic!("build_book_svg_pages must refuse; it produced {} page(s)", pages.len())
}
Err(e) => e.to_string(),
};
assert!(e.contains("docs claims REFUSED"), "build_book_svg_pages: {e}");
}
// ── the gate must be reachable by name from this crate ─────────────────────
/// `nornir_docgen::gate_doc_claims` is the name nornir re-exports and any other
/// consumer can call directly. If the re-export goes missing the six gated entry
/// points still work, so nothing else would notice.
#[test]
fn the_gate_is_callable_by_name_off_this_crate() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fixture(root);
a_false_claim(root);
assert!(nornir_docgen::gate_doc_claims(root).is_err());
assert!(claims::gate_repo(root).is_err());
// A repo with no `.nornir/` at all is not a violation.
let empty = tempfile::tempdir().unwrap();
assert!(nornir_docgen::gate_doc_claims(empty.path()).is_ok());
}