xtask/tests/readme_version.rs
//! The front page may not name a version this tree cannot produce.
//!
//! # The defect this witnesses
//!
//! On 2026-08-05, release day, `README.md:50` said the commands below it were
//! run against `gunnar 0.1.0` while `Cargo.toml`'s `[workspace.package]`
//! said `1.1.0` and `v1.1` was tagged and pushed. `cargo build -p gunnar-cli`
//! at that commit produces a binary whose `--version` begins `1.1.0`; the
//! number in the prose named no build that has ever existed.
//!
//! It survived because the root `README.md` is **generated** from
//! `.nornir/README.md` and everyone knows not to edit the root — so the stale
//! literal in the source was regenerated, faithfully, into the artifact, every
//! time. A generated file is not a checked one.
//!
//! The neighbouring precedent is the same shape: `**1082 tests**` was typed
//! into this README with no producer behind it.
//!
//! # What it asserts
//!
//! Every `gunnar <version>` literal in `.nornir/README.md` **and** in the
//! generated root `README.md` equals `[workspace.package] version`. Both files
//! are scanned rather than only the source, because "the root is generated"
//! is a convention, not a mechanism: if a hand edit ever lands there, this is
//! the thing that says so.
//!
//! # Why it also counts what it found
//!
//! A scanner that matches nothing passes. Deleting the sentence — or renaming
//! the binary in it — would turn this guard off silently, which is the exact
//! disease it was written for. So it asserts a **non-zero census** in the
//! README source and names the count.
use std::path::{Path, PathBuf};
use xtask::release_version::{disagreements, scan, triple, workspace_version};
fn workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("xtask/ has a parent")
.to_path_buf()
}
fn read(root: &Path, rel: &str) -> String {
std::fs::read_to_string(root.join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}"))
}
#[test]
fn the_readme_source_names_the_workspace_version() {
let root = workspace_root();
let version = workspace_version(&root);
let text = read(&root, ".nornir/README.md");
let census = scan(&text).found;
assert!(
!census.is_empty(),
".nornir/README.md contains no `gunnar <version>` literal at all, so this guard \
checked nothing. That is how it would be turned off by accident: the sentence \
naming the binary under test was removed or reworded. Restore a line that names \
the version, or delete this guard on purpose."
);
let wrong = disagreements(&text, &version);
assert!(
wrong.is_empty(),
"[workspace.package] version is `{version}`, but .nornir/README.md names {wrong:?}. \
The root README.md is GENERATED from this file, so the front page of the release \
advertises a build this tree cannot produce. Fix the source, never the root, then \
`nornir docs render`."
);
}
#[test]
fn the_generated_root_readme_names_the_workspace_version() {
let root = workspace_root();
let version = workspace_version(&root);
let text = read(&root, "README.md");
// No census assertion here: the root is a rendering of the source, and the
// source is where the census is asserted. What this catches is a root that
// has drifted from the source it is generated from.
let wrong = disagreements(&text, &version);
assert!(
wrong.is_empty(),
"[workspace.package] version is `{version}`, but the generated README.md names \
{wrong:?}. If .nornir/README.md is already correct this is stale output: run \
`nornir docs render`."
);
}
/// "The root README is generated, never edit it" is a convention. This makes
/// it a check.
///
/// `nornir docs render` prepends its generated-by banner and copies the source
/// through — except the `<!-- nornir:gen:start:… -->` … `<!-- nornir:gen:end:… -->`
/// regions, which are GENERATOR-OWNED: the source is a template whose markers
/// are empty by design, and the rendered tables exist only in the artifact
/// (nornir `assemble_managed_doc`, SK190 — a render with no fresh data must
/// never blank a committed table, so the artifact's block content survives
/// re-renders the source never sees). The renderer also normalises marker
/// ATTRIBUTE order, so markers are compared by generator NAME.
///
/// This test asserted byte-for-byte equality below the banner until
/// 2026-08-16, and that contract was wrong twice over: the artifact carries
/// the class-marker line the banner was said to replace, and any repo whose
/// warehouse holds a bench row renders a table into a block the source keeps
/// empty. It went red on gunnar the first day the suite actually ran it — on a
/// correctly rendered file, since `nornir docs render gunnar` on this tree
/// answers "unchanged" — and a guard that cannot pass says nothing about the
/// thing it guards. What survives is the part that catches the real bug:
/// everything OUTSIDE the generator-owned regions must match, or someone
/// hand-edited the root / forgot to re-render.
#[test]
fn the_root_readme_is_the_nornir_source_verbatim_outside_the_generated_blocks() {
let root = workspace_root();
let source = read(&root, ".nornir/README.md");
let generated = read(&root, "README.md");
assert!(
generated.starts_with("<!-- ⚠ GENERATED by nornir"),
"README.md has lost its generated-by banner, so nothing on the front page says it \
is an artifact"
);
assert!(
source.starts_with("<!-- nornir:"),
".nornir/README.md no longer opens with its `nornir:class` marker, so the render \
rule this test encodes no longer applies"
);
let (_, generated_after_banner) = generated
.split_once('\n')
.expect("README.md is more than one line");
let source_skeleton = outside_gen_blocks(&source);
let generated_skeleton = outside_gen_blocks(generated_after_banner.trim_start_matches('\n'));
assert_eq!(
generated_skeleton, source_skeleton,
"README.md is no longer `.nornir/README.md` below the banner (generator-owned \
`nornir:gen` blocks excluded). The root is GENERATED: fix the source and \
re-render, never edit the root. (This is how `gunnar 0.1.0` survived — the \
stale literal lived in the source and was faithfully copied out every time.)"
);
// Not vacuous: two empty skeletons are equal too.
assert!(
source_skeleton.len() > 1000,
".nornir/README.md's body outside gen blocks is {} bytes, which is too small to \
be the front page; the equality above would pass against almost anything",
source_skeleton.len()
);
// …and the comparison did not silently drop everything into a gen block.
//
// Conditional, because a front page with NO generator-owned block is the
// strictly stronger case: nothing is excluded and the whole file is
// compared. What is being guarded against is a source that HAS markers
// whose boundaries the skeleton then failed to keep — that would let the
// interiors drift unnoticed. `.nornir/README.md` carried a `benches` block
// until 2026-09-08 and may carry one again.
if source.contains("nornir:gen:start:") {
assert!(
source_skeleton.contains("nornir:gen:start:"),
"the skeleton lost the gen markers themselves, so block boundaries were not \
compared at all"
);
}
}
/// Everything except the generator-owned interiors: gen-block content is
/// dropped, and each start marker is reduced to its generator NAME (the
/// renderer reorders marker attributes, e.g. `compare=… winner=…` ↔
/// `winner=… compare=…`). Unbalanced markers are a template bug and panic.
fn outside_gen_blocks(text: &str) -> String {
let mut out = String::new();
let mut in_gen = false;
for line in text.lines() {
let t = line.trim_start();
if let Some(rest) = t.strip_prefix("<!-- nornir:gen:start:") {
assert!(!in_gen, "nested nornir:gen:start marker: {line:?}");
in_gen = true;
let name = rest
.split([' ', '-'])
.next()
.expect("split always yields at least one piece");
out.push_str("<!-- nornir:gen:start:");
out.push_str(name);
out.push_str(" -->\n");
continue;
}
if t.starts_with("<!-- nornir:gen:end:") {
assert!(in_gen, "nornir:gen:end without a start: {line:?}");
in_gen = false;
out.push_str(line);
out.push('\n');
continue;
}
if !in_gen {
out.push_str(line);
out.push('\n');
}
}
assert!(!in_gen, "a nornir:gen block was never closed");
out
}
/// The scanner must be able to say NO, and must be seen finding the thing it
/// is looking for.
///
/// Driven against synthetic inputs, because the live tree is (by the tests
/// above) the matching case — and a scanner that answered "no disagreements"
/// to every input would satisfy both of them and be worth nothing.
#[test]
fn the_scanner_finds_a_wrong_version_and_is_not_satisfied_by_an_empty_file() {
// ── red: the exact byte sequence that shipped ───────────────────────────
let shipped = "against\n`gunnar 0.1.0 (v1.1-3-g3cfbf17)`. One binary is the server.\n";
let wrong = disagreements(shipped, "1.1.0");
assert_eq!(wrong.len(), 1, "the shipped line must be caught: {wrong:?}");
assert_eq!(wrong[0].version, "0.1.0");
assert_eq!(wrong[0].line, 2, "the line number must name where to look");
// ── green, and for the right reason: it FOUND one and it agreed ─────────
let fixed = "against\n`gunnar 1.1.0`. One binary is the server.\n";
assert!(disagreements(fixed, "1.1.0").is_empty());
assert_eq!(
scan(fixed).found.len(),
1,
"the fixed line must still be SEEN, or the green above is vacuous"
);
// ── the census, seen empty ──────────────────────────────────────────────
// A README with the sentence deleted has no disagreements either. That is
// why `found` is asserted separately above.
let deleted = "One binary is the server and the client.\n";
assert!(disagreements(deleted, "1.1.0").is_empty());
assert!(
scan(deleted).found.is_empty(),
"and the census must report zero, so the test above can refuse it"
);
// ── every spelling of the claim ─────────────────────────────────────────
for token in [
"gunnar 0.1.0",
"gunnar/0.1.0", // the `agent=` capability
"gunnar_0.1.0", // the SSH banner
"gunnar-cli 0.1.0", // the package
] {
assert_eq!(
disagreements(token, "1.1.0").len(),
1,
"`{token}` names a stale version and was not caught"
);
}
// ── two-component versions are the same release ─────────────────────────
assert!(disagreements("gunnar 1.1", "1.1.0").is_empty());
assert_eq!(triple("1.1"), triple("1.1.0"));
// ── things that are NOT a claim about gunnar's version ──────────────────
// The README quotes upstream versions. Flagging them would get the guard
// switched off within a day.
for quiet in [
"measured against `git 2.53.0` and OpenSSH_10.2p1",
"gunnar serve --ssh-bind 0.0.0.0:2222",
"gunnar/master",
"the gunnar 1 release",
] {
assert!(
scan(quiet).found.is_empty(),
"`{quiet}` is not a version claim about this workspace, but was scanned as one: \
{:?}",
scan(quiet).found
);
}
// ── trailing punctuation is not a fourth component ──────────────────────
let sentence = "built by gunnar 1.1.0.";
assert_eq!(scan(sentence).found[0].version, "1.1.0");
assert!(disagreements(sentence, "1.1.0").is_empty());
// ── more than one literal on one line, both judged ──────────────────────
let two = "gunnar 0.1.0 talks to gunnar/1.1.0";
assert_eq!(scan(two).found.len(), 2);
assert_eq!(disagreements(two, "1.1.0").len(), 1);
}
/// `workspace_version` must read the real field, not the first `version =` it
/// trips over.
///
/// The root manifest has ~20 `version = "1.1.0"` lines under
/// `[workspace.dependencies]`, so a reader that took the first match would
/// answer correctly today and go blind the moment those diverge.
#[test]
fn the_workspace_version_comes_from_the_workspace_package_table() {
let root = workspace_root();
let version = workspace_version(&root);
assert!(
triple(&version).is_some(),
"[workspace.package] version `{version}` is not a numeric release name"
);
let text = read(&root, "Cargo.toml");
let after_table: String = text
.split("[workspace.package]")
.nth(1)
.expect("the root manifest has a [workspace.package] table")
.lines()
.take_while(|l| !l.trim_start().starts_with('['))
.collect::<Vec<_>>()
.join("\n");
assert!(
after_table.contains(&format!("version = \"{version}\"")),
"`{version}` was not read out of the [workspace.package] table itself"
);
}