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

nordisk/gunnar

xtask/tests/one_clone.rs

//! **RED-when-broken: gunnar builds from ONE clone.**
//!
//! The gate itself is two set differences over a directory walk, so it compiles
//! nothing and runs in milliseconds — which matters, because a gate slow enough
//! to skip gets skipped, and this one's whole job is to fire on a one-line
//! manifest edit that looks harmless.
//!
//! ## Why the red half is the longer half here
//!
//! A gate over "did the walk find anything outside the tree" goes green for two
//! very different reasons: nothing is outside the tree, or the walk found
//! nothing at all. The second is the easier failure to write — a scanner that
//! silently skips comment lines can just as silently skip every line, a walk
//! that skips `target/` can skip the detached workspaces beside it, and a
//! lockfile parser that looks for `source = ` can decide every package has one.
//! This file asserts against each of those by name:
//!
//!  * [`the_walk_actually_sees_the_tree`] — the manifest and lockfile
//!    populations are real, and specifically include the four DETACHED
//!    workspaces, which are invisible to `cargo metadata` at the root and are
//!    exactly where the five sibling paths of 2026-09-12 were hiding.
//!  * [`the_scanner_reads_real_path_lines`] — in-repo `path = "…"` literals are
//!    SEEN and judged in-repo, so "no escapes" is a verdict and not a silence.
//!  * [`a_sibling_path_is_reported`] — each of the five real lines that were
//!    removed, fed back in as text, is reported with the right resolution.
//!  * [`a_commented_sibling_path_is_not_a_dependency`] — the prose that
//!    FORBIDS the shape must not be mistaken for the shape.
//!  * [`a_foreign_local_package_in_a_lockfile_is_reported`] — the lockfile half
//!    fires on the shape a stale lock has.

use std::collections::BTreeSet;
use std::path::{Path, PathBuf};

use xtask::one_clone::{
    crate_names, declared_siblings, escapes_in, fold, foreign_lock_packages, local_packages_in,
    lockfiles, manifests, missing_siblings, path_literal, sibling_of, undeclared,
    undeclared_escapes,
};

/// `CARGO_MANIFEST_DIR` is `<root>/xtask`.
fn root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .expect("xtask has a parent")
        .to_path_buf()
}

/// **KNOWN EXCEPTION — TEMPORARY (release-3 merge, 2026-10-03).** The crates the
/// root `[patch.crates-io]` table points at sibling checkouts until edda (and
/// znippy) publish them on gix 0.88 / russh 0.63 / wasmtime 49: crates.io's
/// releases are on the older stack, and gunnar cannot link both (see the table's
/// own comment in the root `Cargo.toml`; `bench/` restates it because a detached
/// workspace does not inherit `[patch]`).
///
/// They ARE escapes, the scanner is right to see them, and the gate
/// ([`one_clone_is_the_whole_build`]) passes them only because `edda` and
/// `znippy` are declared siblings. The two tests below read real manifests to
/// prove the scanner reads real lines, so they judge these lines by NAME: every
/// escape must be one of these, in a `[patch.crates-io]` table, into a declared
/// sibling — and the root must still carry every name listed, so the day a crate
/// is published and its patch line goes, this list goes red until the name is
/// struck from it. When it is empty, delete it and the helper below.
///
/// **Since 2026-10-22 the table is not in the root manifest at all**: a lone clone
/// must load the root workspace, and cargo refuses a `[patch]` whose path is
/// missing. **Since G-B7 (2026-10-26) the root takes it from nowhere**: each estate
/// workspace (`xtask::siblings::ESTATE_WORKSPACES`) carries the entries its lock takes
/// in its own `[patch.crates-io]`, and together they hold every name listed here
/// (the same list as `xtask::siblings::ESTATE_PATCHES`).
const TEMPORARY_PATCHES: [&str; 31] = [
    "gunnar-pack",
    "gunnar-delta",
    "gunnar-bitmap",
    "gunnar-sendpack",
    "gunnar-stage",
    "gunnar-pktline",
    "nornir-git",
    "nornir-build-thing",
    "storage-git-gix",
    "git-storage-trait",
    "estate-clock",
    "disk-snapshot",
    "appliance-pid1",
    "wg-appliance-core",
    "edda-admit",
    "nornir-auth-oidc",
    "nornir-auth-saml",
    "nornir-auth-webauthn",
    "nornir-identity",
    "nornir-identity-session",
    "nornir-identity-directory",
    "nornir-identity-sso",
    "nornir-auth-sshsig",
    "nornir-service",
    "nordisk-pairing",
    "nordisk-narrate",
    "korp-installer",
    // monetize's crates.io line, 0.3.0 (A3, 2026-10-26).
    "monetize-product",
    "monetize-embed",
    "monetize-proto",
    "monetize-estate-side",
];

/// The escapes in `manifest` (its real `text`) that the [`TEMPORARY_PATCHES`]
/// exception does NOT cover, plus the names of the exception it found there.
///
/// An escape is covered only when ALL of: its key is a listed name, its line sits
/// in a `[patch.crates-io]` table, and it lands in a declared sibling. Anything
/// else — a member path misjudged as an escape, a new path dependency out of the
/// tree, a patch into an undeclared checkout — is returned as uncovered.
fn beyond_the_temporary_patches(
    manifest: &str,
    text: &str,
) -> (Vec<xtask::one_clone::Escape>, BTreeSet<String>) {
    let siblings = declared_siblings();
    let lines: Vec<&str> = text.lines().collect();
    let section_of = |line: usize| -> &str {
        lines[..line]
            .iter()
            .rev()
            .map(|l| l.trim())
            .find(|l| l.starts_with('['))
            .unwrap_or("")
    };
    let mut uncovered = Vec::new();
    let mut named = BTreeSet::new();
    for e in escapes_in(manifest, text) {
        let key = lines[e.line - 1].split('=').next().unwrap_or("").trim();
        let covered = TEMPORARY_PATCHES.contains(&key)
            && section_of(e.line - 1) == "[patch.crates-io]"
            && sibling_of(&e.resolved).is_some_and(|s| siblings.iter().any(|d| d == s));
        if covered {
            named.insert(key.to_string());
        } else {
            uncovered.push(e);
        }
    }
    (uncovered, named)
}

/// **THE GATE.** Nothing in this repository names a path outside it except into
/// a sibling the recipe declares, and no lockfile records having resolved a
/// local package that neither this repository nor a declared sibling defines.
///
/// Until 2026-09-29 this asserted the stricter sentence — nothing outside the
/// tree at all — and was red on master, release-2 and release-3 alike: 21
/// lines, every one into `edda`, `Skidbladnir`, `monetize` or `gunnar-ui`, all
/// four listed in `[build.toolchain] siblings` in the commit that added the
/// path (see `xtask/src/one_clone.rs`, "Why the rule grew a declared
/// exception"). The escapes are still printed and still judged; what changed
/// is which of them the BUILD can resolve.
#[test]
fn one_clone_is_the_whole_build() {
    let siblings = declared_siblings();
    let missing = missing_siblings(&root(), &siblings);
    assert!(
        missing.is_empty(),
        "the recipe declares siblings {missing:?} that are not checked out beside {} — the \
         workspace cannot resolve without them, and the lockfile half below cannot vouch \
         for a package it cannot read",
        root().display()
    );

    let escapes = undeclared_escapes(&root(), &siblings);
    assert!(
        escapes.is_empty(),
        "gunnar must build from ONE clone plus its DECLARED siblings {siblings:?}, and these \
         lines say otherwise:\n{}\n\n\
         `cargo` reads a path dependency's manifest at RESOLVE time, so each of \
         these makes `cargo metadata` — not merely `cargo build` — a hard error in \
         the build container, which mounts only the declared siblings. Fix: take the \
         dependency to a crates.io VERSION, or declare its sibling in \
         `crates/gunnar-recipe/src/recipe.rs` `toolchain().siblings` in the SAME commit.",
        escapes
            .iter()
            .map(|e| format!("  {}", e.describe()))
            .collect::<Vec<_>>()
            .join("\n")
    );

    let foreign = foreign_lock_packages(&root(), &siblings);
    assert!(
        foreign.is_empty(),
        "a lockfile records a LOCAL package neither this repository nor a declared sibling \
         defines, so a resolve reached a checkout the build container will not have, even if \
         the manifests no longer ask for one:\n{}",
        foreign
            .iter()
            .map(|f| format!("  {}", f.describe()))
            .collect::<Vec<_>>()
            .join("\n")
    );
}

/// **The declaration narrows the gate by NAME, and nothing else.**
///
/// A path into a sibling that is not declared is reported, one into a declared
/// sibling is not, and the shapes no declaration can make whole — a path above
/// the checkout's parent, an absolute path — are reported whatever is declared.
/// A gate that took the declaration as "anything goes" would be red here.
#[test]
fn a_path_into_an_undeclared_sibling_is_still_reported() {
    let declared = vec!["edda".to_string()];
    let line = |l: &str| escapes_in("crates/gunnar-server/Cargo.toml", l);

    let into_edda = line(r#"disk-snapshot = { path = "../../../edda/crates/disk-snapshot" }"#);
    assert_eq!(into_edda.len(), 1, "the lexical half still SEES the escape");
    assert!(
        undeclared(into_edda, &declared).is_empty(),
        "edda is declared"
    );

    let into_tunnr = line(r#"tunnr = { path = "../../../tunnr/crates/tunnr" }"#);
    assert_eq!(
        undeclared(into_tunnr, &declared).len(),
        1,
        "tunnr is NOT declared, so the build container would not have it"
    );

    let above = line(r#"far = { path = "../../../../edda/crates/x" }"#);
    assert_eq!(above[0].resolved, "../../edda/crates/x");
    assert_eq!(
        undeclared(above, &declared).len(),
        1,
        "a path above the checkout's parent is not the sibling `edda`, whatever its last name"
    );

    let absolute = line(r#"abs = { path = "/home/alice/git/edda" }"#);
    assert_eq!(
        undeclared(absolute, &declared).len(),
        1,
        "absolute stays refused"
    );

    assert_eq!(sibling_of("../edda/crates/admit"), Some("edda"));
    assert_eq!(sibling_of("../../edda"), None);
    assert_eq!(sibling_of("crates/gunnar-store"), None);
    assert_eq!(sibling_of("/abs"), None);
}

/// **The population is real**, and it includes the trees that hid the problem.
#[test]
fn the_walk_actually_sees_the_tree() {
    let root = root();
    let found: BTreeSet<String> = manifests(&root).into_iter().collect();
    assert!(
        found.len() >= 20,
        "the manifest walk found only {} Cargo.toml files — it is not seeing the \
         tree, and a gate over an empty population is not a pass: {found:?}",
        found.len()
    );
    // The four DETACHED workspaces. `cargo metadata` at the root cannot see any
    // of them — they are not members and they carry their own `[workspace]` —
    // and two of the four are where the sibling paths lived.
    for detached in [
        "bench/Cargo.toml",
        "bench-stock/Cargo.toml",
        "deploy/upcloud/Cargo.toml",
        "installtest/Cargo.toml",
    ] {
        assert!(
            found.contains(detached),
            "the walk missed the detached workspace `{detached}`, which is precisely \
             the kind of tree a crate-graph walk cannot see and where the sibling \
             paths removed on 2026-09-12 were living"
        );
    }
    // And the vendored copy, which is an in-repo path dep and must be judged
    // rather than skipped.
    assert!(
        found.contains("vendor/gix-parallel/Cargo.toml"),
        "the walk missed the vendored gix-parallel"
    );

    let locks: BTreeSet<String> = lockfiles(&root).into_iter().collect();
    for lock in [
        "Cargo.lock",
        "bench/Cargo.lock",
        "bench-stock/Cargo.lock",
        "deploy/upcloud/Cargo.lock",
        "installtest/Cargo.lock",
    ] {
        assert!(locks.contains(lock), "the lockfile walk missed `{lock}`");
    }

    let ours = crate_names(&root);
    for name in [
        "gunnar-cli",
        "gunnar-store",
        "gunnar-upcloud",
        "gunnar-bench-stock",
        "xtask",
    ] {
        assert!(
            ours.contains(name),
            "`{name}` is defined by a Cargo.toml in this repository but the name scan \
             missed it — an allowlist that misses our own crates makes the lockfile \
             half red for the wrong reason"
        );
    }
}

/// **In-repo `path = "…"` lines are SEEN**, not skipped. Without this the gate
/// above would be green on a scanner that reads nothing.
#[test]
fn the_scanner_reads_real_path_lines() {
    assert_eq!(
        path_literal(r#"gunnar-store = { version = "1.1.0", path = "crates/gunnar-store" }"#),
        Some("crates/gunnar-store")
    );
    assert_eq!(path_literal(r#"path = "src/main.rs""#), Some("src/main.rs"));
    assert_eq!(path_literal(r#"gunnar-core = { version = "1.1.0" }"#), None);
    // `rust-version`, `repository` and friends end in something that is not
    // `path`; a substring match would misread them.
    assert_eq!(path_literal(r#"searchpath = "../../elsewhere""#), None);

    // The root manifest names seventeen in-repo crates by path. Every one of
    // them is read, and every one of them is judged INSIDE the tree.
    let text = std::fs::read_to_string(root().join("Cargo.toml")).expect("the root manifest");
    let seen = text.lines().filter(|l| path_literal(l).is_some()).count();
    assert!(
        seen >= 15,
        "the scanner found only {seen} `path =` lines in the root manifest, which \
         names every workspace member by path — it is reading almost nothing"
    );
    // Every escape the root holds is the TEMPORARY patch table, by name — and
    // the table is still whole, so the exception cannot outlive it.
    let (uncovered, named) = beyond_the_temporary_patches("Cargo.toml", &text);
    assert!(
        uncovered.is_empty(),
        "the root manifest's own member paths were misjudged as escapes, or an escape \
         beyond the TEMPORARY patch table appeared:\n{}",
        uncovered
            .iter()
            .map(|e| format!("  {}", e.describe()))
            .collect::<Vec<_>>()
            .join("\n")
    );
    assert!(
        named.is_empty(),
        "the root manifest patches into a sibling again: {named:?}"
    );
    // The table itself, in the estate workspaces' own manifests (G-B7), each judged at its
    // own path; together they name every listed crate.
    let mut named = BTreeSet::new();
    for ws in xtask::siblings::ESTATE_WORKSPACES {
        let rel = format!("{ws}/Cargo.toml");
        let text = std::fs::read_to_string(root().join(&rel)).expect(&rel);
        let (uncovered, here) = beyond_the_temporary_patches(&rel, &text);
        // Only the patch tables are judged here: an estate workspace's direct `path =`
        // dependencies into a declared sibling are the gate's business, not this list's.
        let lines: Vec<&str> = text.lines().collect();
        let in_patch = |line: usize| {
            lines[..line]
                .iter()
                .rev()
                .map(|l| l.trim())
                .find(|l| l.starts_with('['))
                == Some("[patch.crates-io]")
        };
        let beyond: Vec<_> = uncovered.iter().filter(|e| in_patch(e.line - 1)).collect();
        assert!(
            beyond.is_empty(),
            "{rel}'s sibling patches hold an escape beyond TEMPORARY_PATCHES:\n{}",
            beyond
                .iter()
                .map(|e| format!("  {}", e.describe()))
                .collect::<Vec<_>>()
                .join("\n")
        );
        named.extend(here);
    }
    let listed: BTreeSet<String> = TEMPORARY_PATCHES.iter().map(|s| s.to_string()).collect();
    assert_eq!(
        named, listed,
        "the estate workspaces' TEMPORARY [patch.crates-io] tables no longer match TEMPORARY_PATCHES — \
         strike a published crate from the list (and delete the list when it is empty)"
    );
    let shared: BTreeSet<String> = xtask::siblings::ESTATE_PATCHES
        .iter()
        .map(|s| s.to_string())
        .collect();
    assert_eq!(
        shared, listed,
        "xtask::siblings::ESTATE_PATCHES and TEMPORARY_PATCHES name the same crates"
    );

    // The folding is the part that decides, so it is asserted directly.
    assert_eq!(
        fold("deploy/upcloud", "../../../edda/crates/nornir-hash"),
        "../edda/crates/nornir-hash"
    );
    assert_eq!(
        fold("bench-stock", "../../znippy-zoomies"),
        "../znippy-zoomies"
    );
    assert_eq!(
        fold("bench-stock", "../bench/examples/deltachain-probe.rs"),
        "bench/examples/deltachain-probe.rs"
    );
    assert_eq!(
        fold("crates/gunnar-cli", "../gunnar-store"),
        "crates/gunnar-store"
    );
    assert_eq!(fold("crates/gunnar-cli", "."), "crates/gunnar-cli");
}

/// **Each of the five real lines, fed back in, is reported.**
///
/// These are the literal dependency lines this repository carried until
/// 2026-09-12, verbatim. A gate proven only against an invented example can be
/// green on the shape that actually occurred.
#[test]
fn a_sibling_path_is_reported() {
    let cases: [(&str, &str, &str); 5] = [
        (
            "deploy/upcloud/Cargo.toml",
            r#"nornir-hash = { path = "../../../edda/crates/nornir-hash" }"#,
            "../edda/crates/nornir-hash",
        ),
        (
            "deploy/upcloud/Cargo.toml",
            r#"nornir-xtask-support = { path = "../../../edda/crates/nornir-xtask-support" }"#,
            "../edda/crates/nornir-xtask-support",
        ),
        (
            "deploy/upcloud/Cargo.toml",
            r#"nornir-build-thing = { path = "../../../edda/crates/nornir-build-thing", default-features = false }"#,
            "../edda/crates/nornir-build-thing",
        ),
        (
            "deploy/upcloud/Cargo.toml",
            r#"nornir-git = { version = "0.1.9", path = "../../../edda/crates/nornir-git", default-features = false }"#,
            "../edda/crates/nornir-git",
        ),
        (
            "bench-stock/Cargo.toml",
            r#"znippy-zoomies = { path = "../../znippy-zoomies", default-features = false }"#,
            "../znippy-zoomies",
        ),
    ];
    for (manifest, line, expect) in cases {
        let found = escapes_in(manifest, line);
        assert_eq!(
            found.len(),
            1,
            "the gate did not report the sibling path it was written for: {manifest} — {line}"
        );
        assert_eq!(found[0].resolved, expect, "wrong resolution for {line}");
        assert_eq!(found[0].line, 1);
        assert!(found[0].describe().contains(expect));
    }

    // An ABSOLUTE path is the other way out of the tree and is reported too.
    let abs = escapes_in(
        "crates/gunnar-cli/Cargo.toml",
        r#"thing = { path = "/home/alice/git/edda" }"#,
    );
    assert_eq!(
        abs.len(),
        1,
        "an absolute path out of the tree was not reported"
    );
}

/// **The prose that forbids the shape is not the shape.**
///
/// `bench/Cargo.toml` spells `path = "../../edda/…"` out in full in order to
/// ban it, and `bench-stock/Cargo.toml` now carries a commented-out
/// `[patch.crates-io]` recipe for local work. Both must read as documentation.
/// This is not a hypothetical: the gate above is red on this repository without
/// it.
#[test]
fn a_commented_sibling_path_is_not_a_dependency() {
    assert_eq!(path_literal(r#"#  * NEVER `path = "../../edda/…"`."#), None);
    assert_eq!(
        path_literal(r#"#     znippy-zoomies = { path = "../../znippy-zoomies" }"#),
        None
    );
    assert_eq!(
        path_literal("    # nornir-hash = { path = \"../../../edda/crates/nornir-hash\" }"),
        None
    );
    assert!(escapes_in("bench/Cargo.toml", "# path = \"../../edda/x\"\n").is_empty());

    // And the two real files prove it on their real contents: both DO contain
    // the forbidden literal in a comment, so a scanner that ignored comments
    // only by accident would be red here.
    for f in ["bench/Cargo.toml", "bench-stock/Cargo.toml"] {
        let text = std::fs::read_to_string(root().join(f)).expect(f);
        assert!(
            text.contains("edda/") || text.contains("znippy-zoomies\" }"),
            "{f} no longer documents the forbidden shape, so this proof measures nothing"
        );
        // `bench/` restates the root's TEMPORARY patch table (a detached
        // workspace does not inherit `[patch]`); those lines are escapes and are
        // judged by name. Nothing else in either file may be.
        let (uncovered, _) = beyond_the_temporary_patches(f, &text);
        assert!(
            uncovered.is_empty(),
            "{f} was misjudged on its own comments:\n{}",
            uncovered
                .iter()
                .map(|e| format!("  {}", e.describe()))
                .collect::<Vec<_>>()
                .join("\n")
        );
    }
}

/// **The lockfile half fires**, on the shape a stale lock has: a package with
/// no `source =` line whose name this repository does not define.
#[test]
fn a_foreign_local_package_in_a_lockfile_is_reported() {
    let lock = r#"
[[package]]
name = "gunnar-store"
version = "1.1.0"
dependencies = [
 "gix-hash",
]

[[package]]
name = "nornir-hash"
version = "0.1.0"
dependencies = [
 "sha2",
]

[[package]]
name = "nornir-git"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e4c150914645358e55af06edebadbaa5a1d94ed9aceea638001ba5929826c07"
"#;
    let locals = local_packages_in(lock);
    assert_eq!(
        locals,
        vec!["gunnar-store".to_string(), "nornir-hash".to_string()],
        "the parser must call a source-less package LOCAL and a registry one not — \
         if it returned everything or nothing the gate would be meaningless"
    );
    let ours = crate_names(&root());
    assert!(
        ours.contains("gunnar-store"),
        "our own crate must not be foreign"
    );
    assert!(
        !ours.contains("nornir-hash"),
        "`nornir-hash` lives in edda; if this repository ever defines a crate by \
         that name this proof stops measuring anything"
    );
}