Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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"
    );
}