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

nordisk/edda

crates/nornir-build-thing/tests/vocabulary_compat.rs

//! **Backward compatibility is the acceptance criterion of the vocabulary change.**
//!
//! The 2026-08-15 ruling makes `variant` the one noun for a declared buildable and
//! `kind` a FIELD on it. `[[build.container]]` / `[[build.vm]]` / `[[build.iso]]` /
//! `[[build.installer]]` stay legal FOREVER as sugar for `kind = "…"`. This test
//! holds that promise against the REAL, UNEDITED declarations on disk: it loads
//! holger's and korp's `.nornir/build-variants.toml` and asserts that every single
//! declared name resolves through the unified lookup, with the kind its section
//! implies.
//!
//! It is the guard for the defect that motivated the change: `BuildVariantSet::get()`
//! searches `self.variants` and nothing else, so a declared `[[build.installer]]`
//! could not be found by name at all. Both repos declare one.
//!
//! # Seen RED before it was trusted
//!
//! Two independent sabotages, both run 2026-08-15, verbatim:
//!
//! 1. **by removing a sugar mapping** — deleting the `self.installers` arm from
//!    `BuildVariantSet::declarations()` (i.e. "someone cleaned up
//!    `[[build.installer]]` because it was redundant"):
//!    ```text
//!    ---- holgers_real_declaration_resolves_every_name_through_the_unified_lookup stdout ----
//!    panicked at crates/nornir-build-thing/tests/vocabulary_compat.rs:102:13:
//!      holger declares 'holger-installer' — the unified lookup must find it
//!    ---- korps_real_declaration_resolves_every_name_through_the_unified_lookup stdout ----
//!      korp declares 'korp-installer' — the unified lookup must find it
//!    test result: FAILED. 0 passed; 4 failed;
//!    ```
//! 2. **against the OLD lookup** — `declared()` reduced to `self.get(name)`, the
//!    pre-ruling root defect (searches `self.variants` and nothing else):
//!    ```text
//!    ---- holgers_real_declaration_resolves_every_name_through_the_unified_lookup stdout ----
//!    panicked at crates/nornir-build-thing/tests/vocabulary_compat.rs:102:13:
//!      holger declares 'holger-mvp-appliance' — the unified lookup must find it
//!    ---- korps_real_declaration_resolves_every_name_through_the_unified_lookup stdout ----
//!      korp declares 'nordisk-falkordb-valkey' — the unified lookup must find it
//!    test result: FAILED. 1 passed; 3 failed;
//!    ```
//!
//! A compat test with no subject on disk is a false green, so a missing checkout is
//! a FAILURE here, not a skip.
//!
//! # RE-AIMED 2026-08-23 — it had been RED on main, and its guard was blind
//!
//! Two of the four tests were red at `ff5837e`, and neither red was a bug in the
//! crate. Both were expectations pinned to sibling declarations that the owner then
//! changed:
//!
//! * `korp declares an installer` / `korp declares two container images` — korp's
//!   `.nornir/build-variants.toml:1112` records his 2026-08-15 ruling REMOVING the
//!   two container cells and the `korp-installer` cell, with the reason (a cell
//!   cannot carry `suites`, and `suites` is what makes a cell a Build Thing). The
//!   ruling landed the same day this test was written; the test lost the race. korp
//!   now declares nine variants and ONE `[[build.iso]]`, so the iso is the subject.
//! * `the narrow cargo lookup still answers only for cargo rows` — holger now
//!   writes the POINTER VARIANT idiom (`.nornir/build-variants.toml:955`: a
//!   `[[build.variant]] kind = "installer"` beside the `[[build.installer]]` cell of
//!   the same name), so `get("holger-installer")` legitimately answers. The
//!   narrowness that actually matters is asserted below against BOTH a synthetic
//!   unpaired cell and every real declared name.
//!
//! And the payload-survival guard could never fire on the declarations it most
//! needed to check: it was written `ArtifactKind::Installer if d.as_variant().is_none()`,
//! so a PAIRED row — the only shape where `declared()` had to choose between two
//! halves — skipped the check entirely. It was choosing the cargo row, and
//! `as_installer()` / `as_container()` / `as_iso()` answered `None` on every paired
//! declaration in the constellation. The gate is now unconditional and that defect
//! is fixed in `variant.rs` (`payload_of` + the folded `declarations()`).

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

use nornir_build_thing::variant::{ArtifactKind, BuildVariantSet};

/// The side-by-side constellation root: THIS edda checkout's parent
/// (`…/edda/crates/nornir-build-thing` → `…/edda/crates` → `…/edda` → the root),
/// the same directory edda's own `../../../<repo>` path deps resolve in.
///
/// It used to WALK UP until some ancestor held a `holger/` or a `korp/`. On the
/// release-3 constellation (`~/d-release3/src`, holger present, korp never
/// cloned) that found the right root and then failed on a bare path; on a root
/// with neither it kept climbing and could adopt any unrelated ancestor. The root
/// is now fixed and an absent subject is named as a sibling to clone.
fn constellation() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .ancestors()
        .nth(3)
        .expect("crates/nornir-build-thing sits three levels below the constellation root")
        .to_path_buf()
}

/// Load one repo's REAL, unedited declaration. Panics rather than skips.
fn real_set(repo: &str) -> BuildVariantSet {
    let root = constellation();
    assert!(
        root.join(repo).is_dir(),
        "the sibling checkout `{repo}` is ABSENT under {} — this compatibility test's \
         subject is {repo}'s real `.nornir/build-variants.toml`, and a compat test with no \
         subject is a false green. Clone {repo} beside edda and rerun.",
        root.display()
    );
    let path = BuildVariantSet::conventional_path(&root.join(repo));
    assert!(
        path.is_file(),
        "{repo} is checked out but {} is not on disk — a compatibility test with no \
         subject is a false green",
        path.display()
    );
    BuildVariantSet::load_file(&path)
        .unwrap_or_else(|e| panic!("{} must keep parsing byte-identically: {e:#}", path.display()))
}

/// The core assertion, shared by both repos (one writer, not two copies).
///
/// * every name in every one of the five storage collections resolves through
///   [`BuildVariantSet::declared`];
/// * it resolves to the kind its section implies (the sugar mapping);
/// * `declared_count` / `declared_names` / `artifacts` / `kind_of` all agree with it;
/// * the concrete per-kind payload is still reachable (nothing was flattened).
fn every_declared_name_resolves(repo: &str, set: &BuildVariantSet) {
    // The RAW collections, each with the kind its storage implies. Note this list
    // may name the same buildable twice — a pointer variant and the sugar cell it
    // is the pointer FOR are two rows here and ONE declared buildable.
    let raw: Vec<(String, ArtifactKind)> = set
        .variants
        .iter()
        .map(|v| (v.name.clone(), v.kind))
        .chain(
            set.containers
                .iter()
                .map(|c| (c.name.clone(), ArtifactKind::Container)),
        )
        .chain(set.vms.iter().map(|v| (v.name.clone(), ArtifactKind::Vm)))
        .chain(set.isos.iter().map(|i| (i.name.clone(), ArtifactKind::Iso)))
        .chain(
            set.installers
                .iter()
                .map(|i| (i.name.clone(), ArtifactKind::Installer)),
        )
        .collect();

    assert!(
        !raw.is_empty(),
        "{repo} declares nothing — the fixture is not the real file"
    );

    for (name, kind) in &raw {
        let d = set.declared(name).unwrap_or_else(|| {
            panic!("{repo} declares '{name}' — the unified lookup must find it")
        });
        assert_eq!(
            d.kind,
            *kind,
            "{repo}: '{name}' must resolve as kind '{}', got '{}'",
            kind.as_str(),
            d.kind.as_str()
        );
        assert_eq!(d.name, name.as_str());
        assert_eq!(
            set.kind_of(name),
            Some(*kind),
            "{repo}: kind_of('{name}') must agree with declared()"
        );
        // **The per-kind payload survived — UNCONDITIONALLY.** This used to be
        // gated on `d.as_variant().is_none()`, which skipped exactly the rows that
        // needed it: on a PAIRED declaration `declared()` had two halves to choose
        // between, chose the cargo row, and answered `None` here. If a cell of this
        // kind and name is declared, the descriptor must be reachable from the
        // unified row — that is what "nothing is flattened" means.
        match kind {
            ArtifactKind::Container if set.containers.iter().any(|c| c.name == *name) => {
                assert!(
                    d.as_container().is_some(),
                    "{repo}: '{name}' declares a [[build.container]] — declared() must \
                     hand back its ContainerBuild, not swallow it"
                );
            }
            ArtifactKind::Vm if set.vms.iter().any(|v| v.name == *name) => {
                assert!(d.as_vm().is_some(), "{repo}: '{name}' keeps its VmBuild");
            }
            ArtifactKind::Iso if set.isos.iter().any(|i| i.name == *name) => {
                assert!(
                    d.as_iso().is_some(),
                    "{repo}: '{name}' declares a [[build.iso]] — declared() must hand \
                     back its IsoBuild, not swallow it"
                );
            }
            ArtifactKind::Installer if set.installers.iter().any(|i| i.name == *name) => {
                assert!(
                    d.as_installer().is_some(),
                    "{repo}: '{name}' declares a [[build.installer]] — declared() must \
                     hand back its InstallerBuild, not swallow it"
                );
            }
            _ => {}
        }
        // `require_declared` names the row instead of erroring past it.
        set.require_declared(name)
            .unwrap_or_else(|e| panic!("{repo}: require_declared('{name}') must succeed: {e:#}"));
    }

    // ── the build list is a LIST OF BUILDABLES, each named once ───────────────
    let names: Vec<String> = set.declared_names().iter().map(|s| s.to_string()).collect();
    let mut seen: BTreeMap<&str, usize> = BTreeMap::new();
    for n in &names {
        *seen.entry(n.as_str()).or_default() += 1;
    }
    let dups: Vec<&&str> = seen.iter().filter(|(_, c)| **c > 1).map(|(n, _)| n).collect();
    assert!(
        dups.is_empty(),
        "{repo}: declared_names() names the same buildable twice: {dups:?} — a paired \
         declaration (pointer variant + its sugar cell) is ONE row, and this list is \
         what `dwarves buildthing --list` prints and the GUI picker offers"
    );
    // Every raw name still appears — the fold removes duplicates, never rows.
    for (name, _) in &raw {
        assert!(
            names.iter().any(|n| n == name),
            "{repo}: '{name}' is declared but absent from declared_names()"
        );
    }
    // The variant rows come first, in declaration order, all of them.
    let cargo_rows: Vec<&str> = set.variants.iter().map(|v| v.name.as_str()).collect();
    assert_eq!(
        names[..cargo_rows.len()].iter().map(|s| s.as_str()).collect::<Vec<_>>(),
        cargo_rows,
        "{repo}: the [[build.variant]] rows lead declared_names(), in declaration order"
    );

    // The derived views cannot drift from the enumeration.
    assert_eq!(
        set.declared_count(),
        names.len(),
        "{repo}: declared_count must be the length of the build list beside it"
    );
    let arts: Vec<(ArtifactKind, String)> = set
        .artifacts()
        .into_iter()
        .map(|(k, n)| (k, n.to_string()))
        .collect();
    let want_arts: Vec<(ArtifactKind, String)> = names
        .iter()
        .map(|n| (set.kind_of(n).expect("declared"), n.clone()))
        .collect();
    assert_eq!(arts, want_arts, "{repo}: artifacts() is the same list");

    // **The narrow lookup is still narrow.** `get()` must answer for a name iff a
    // `[[build.variant]]` row carries it — no wider (it hands back a `BuildVariant`)
    // and no narrower (a pointer variant IS a cargo row and must be findable).
    for name in &names {
        assert_eq!(
            set.get(name).is_some(),
            set.variants.iter().any(|v| v.name == *name),
            "{repo}: get('{name}') must answer exactly for the [[build.variant]] rows"
        );
    }

    // A name nobody declared is still absent — the lookup widened, it did not
    // start inventing rows.
    assert!(set.declared("no-such-thing-anywhere").is_none());
    assert!(set.require_declared("no-such-thing-anywhere").is_err());
}

/// holger's real file (2026-08-23): 16 `[[build.variant]]`, 1 `[[build.container]]`,
/// 1 `[[build.installer]]` — and the container and the installer are both PAIRED
/// with a pointer variant of the same name, so holger declares SIXTEEN buildables,
/// not eighteen.
#[test]
fn holgers_real_declaration_resolves_every_name_through_the_unified_lookup() {
    let set = real_set("holger");
    every_declared_name_resolves("holger", &set);

    assert!(
        !set.installers.is_empty(),
        "holger declares an installer — that is the point of this test"
    );
    assert!(!set.containers.is_empty(), "holger declares a container image");

    // Both cells are PAIRED, and the pair is ONE row carrying BOTH halves. This is
    // the assertion that replaced `get(installer).is_none()`, which had been red
    // since holger wrote its pointer variant.
    for i in &set.installers {
        let d = set
            .declared(&i.name)
            .unwrap_or_else(|| panic!("holger declares '{}'", i.name));
        assert_eq!(d.kind, ArtifactKind::Installer);
        assert_eq!(
            d.as_installer().map(|x| x.volume_id.as_str()),
            Some(i.volume_id.as_str()),
            "the InstallerBuild rides the unified row — this is what a builder needs"
        );
        assert!(
            d.as_variant().is_some(),
            "holger's '{}' is a POINTER VARIANT + cell: the cargo row rides the same \
             declaration, so a picker can still offer it",
            i.name
        );
    }
    for c in &set.containers {
        let d = set
            .declared(&c.name)
            .unwrap_or_else(|| panic!("holger declares '{}'", c.name));
        assert_eq!(d.kind, ArtifactKind::Container);
        assert_eq!(
            d.as_container().map(|x| x.base.as_str()),
            Some(c.base.as_str()),
            "the ContainerBuild rides the unified row"
        );
    }
    // The count the CLI and the GUI print. 18 was the bug.
    assert_eq!(
        set.declared_count(),
        set.variants.len(),
        "every holger sugar cell is paired, so the build list is exactly the cargo rows"
    );
}

/// korp's real file, whatever korp currently declares.
///
/// # RE-AIMED AGAIN, 2026-09-22 — and this time at something korp cannot move
///
/// The previous version asserted korp's INVENTORY: no installer cells, no
/// container cells, exactly one iso, recorded from the owner's 2026-08-15 ruling.
/// korp has since declared five `[[build.installer]]` cells again, and this test
/// went red for it — the second time it lost a race against a sibling's editor.
/// The first re-aim (2026-08-23) is documented at the top of this file; a guard
/// that has to be re-aimed every time another repository changes its mind is not
/// guarding the vocabulary, it is transcribing an inventory.
///
/// So it now asserts only what the VOCABULARY promises, which is what this file is
/// for and what korp cannot invalidate by declaring one more cell:
///
/// * every declared name — cargo row or sugar cell — resolves through the unified
///   lookup, with the kind its section implies (`every_declared_name_resolves`);
/// * every sugar cell is PAIRED with a pointer variant of the same name, which is
///   the idiom the constellation settled on;
/// * `declared_count()` equals the cargo rows, because pairing means one buildable
///   per name and not two.
///
/// The counts it used to pin are printed instead, so a reader still sees what korp
/// declares today without the test owning that number.
#[test]
fn korps_real_declaration_resolves_every_name_through_the_unified_lookup() {
    let set = real_set("korp");
    every_declared_name_resolves("korp", &set);

    eprintln!(
        "korp declares {} cargo row(s), {} container(s), {} iso(s), {} installer(s), {} vm(s)",
        set.variants.len(),
        set.containers.len(),
        set.isos.len(),
        set.installers.len(),
        set.vms.len(),
    );

    // Every sugar cell is paired with a pointer variant of the same name, and the
    // concrete payload still rides the unified row.
    let cells: Vec<(String, ArtifactKind)> = set
        .isos
        .iter()
        .map(|i| (i.name.clone(), ArtifactKind::Iso))
        .chain(
            set.installers
                .iter()
                .map(|i| (i.name.clone(), ArtifactKind::Installer)),
        )
        .chain(
            set.containers
                .iter()
                .map(|c| (c.name.clone(), ArtifactKind::Container)),
        )
        .chain(set.vms.iter().map(|v| (v.name.clone(), ArtifactKind::Vm)))
        .collect();
    assert!(
        !cells.is_empty(),
        "korp declares no sugar cell at all — this test then has no subject, and a \
         compat test with no subject is a false green"
    );
    for (name, kind) in &cells {
        let d = set
            .declared(name)
            .unwrap_or_else(|| panic!("korp declares '{name}' — the unified lookup must find it"));
        assert_eq!(d.kind, *kind, "'{name}' resolved to the wrong kind");
        assert!(
            d.as_variant().is_some(),
            "korp's '{name}' cell has no pointer variant — the paired idiom is what keeps \
             a cell buildable by name"
        );
    }
    assert_eq!(
        set.declared_count(),
        set.variants.len(),
        "every korp cell is paired, so the build list is exactly the cargo rows"
    );
}

/// **The surface a UI reads carries the same list.** facett's variant ComboBox
/// populates from `BuildSurface::declared_names()`; if that ever narrows back to the
/// cargo rows, the second click loses every installer again.
#[test]
fn the_ui_surface_lists_every_declared_variant_for_both_repos() {
    for repo in ["holger", "korp"] {
        let set = real_set(repo);
        let surface = nornir_build_thing::surface::BuildSurface::from_set(repo, &set);
        assert_eq!(
            surface.declared_names(),
            set.declared_names(),
            "{repo}: the surface's build list is the declaration's build list"
        );
        for i in &set.installers {
            assert!(
                surface.declared_names().contains(&i.name.as_str()),
                "{repo}: a picker reading the surface can offer '{}'",
                i.name
            );
        }
    }
}

/// **The section spelling and the field spelling are one thing.** A repo may write
/// either; both must land in the unified view with the same kind. This is the
/// promise that keeps the sugar sections from being "cleaned up".
#[test]
fn the_sugar_sections_and_the_kind_field_are_the_same_declaration() {
    let sugar = BuildVariantSet::from_toml_str(
        r#"
[build]
[[build.container]]
name = "img"
base = "docker.io/library/debian:12"

[[build.vm]]
name = "box"
kernel = "target/vmlinuz"
rootfs = "target/initramfs.img"

[[build.iso]]
name = "disc"
kernel = "target/vmlinuz"
rootfs = "target/initramfs.img"

[[build.installer]]
name = "layer"
installer_variant = "pid1"
target_disk_gib = 64
"#,
    )
    .expect("the sugar sections parse");

    let field = BuildVariantSet::from_toml_str(
        r#"
[build]
[[build.variant]]
name = "img"
kind = "container"
container_ref = "img"

[[build.variant]]
name = "box"
kind = "vm"
vm_ref = "box"

[[build.variant]]
name = "disc"
kind = "iso"

[[build.variant]]
name = "layer"
kind = "installer"

[[build.container]]
name = "img"
base = "docker.io/library/debian:12"

[[build.vm]]
name = "box"
kernel = "target/vmlinuz"
rootfs = "target/initramfs.img"
"#,
    )
    .expect("the field spelling parses — `kind = \"installer\"` included");

    for (name, kind) in [
        ("img", ArtifactKind::Container),
        ("box", ArtifactKind::Vm),
        ("disc", ArtifactKind::Iso),
        ("layer", ArtifactKind::Installer),
    ] {
        assert_eq!(sugar.declared(name).map(|d| d.kind), Some(kind), "sugar: {name}");
        assert_eq!(field.declared(name).map(|d| d.kind), Some(kind), "field: {name}");
    }

    // And the field spelling validates — the pre-ruling code REFUSED
    // `kind = "installer"` outright.
    field.validate_all().expect("a variant may declare any of the six kinds");
}

/// **A PAIRED declaration is ONE buildable carrying BOTH halves — on a synthetic
/// file, so it holds no matter what holger and korp do next.**
///
/// This is the guard for the defect the real-declaration tests above could not see
/// while their payload check was gated on `as_variant().is_none()`: a pointer
/// variant plus its sugar cell was enumerated TWICE, and `declared()` returned the
/// cargo row, so the descriptor a builder needs was unreachable.
///
/// It also pins the narrowness of [`BuildVariantSet::get`] against an UNPAIRED
/// cell, which is the assertion that used to ride holger's declaration and broke
/// the day holger wrote a pointer variant.
#[test]
fn a_pointer_variant_and_its_cell_are_one_row_and_an_unpaired_cell_is_its_own() {
    let set = BuildVariantSet::from_toml_str(
        r#"
[build]
# PAIRED: pointer variant + cell of the same name (holger's installer idiom).
[[build.variant]]
name = "paired-installer"
kind = "installer"
linkage = "fat"

[[build.installer]]
name = "paired-installer"
installer_variant = "pid1"
volume_id = "PAIRED_VOL"
target_disk_gib = 64

# PAIRED through a ref, not a name (holger's container idiom, gunnar's shape).
[[build.variant]]
name = "pointer-to-image"
kind = "container"
container_ref = "the-image"

[[build.container]]
name = "the-image"
base = "scratch"

# UNPAIRED: a cell nobody points at. Legal, and its own row.
[[build.installer]]
name = "lonely-installer"
installer_variant = "pid1"
volume_id = "LONELY_VOL"
target_disk_gib = 64
"#,
    )
    .expect("the synthetic declaration parses");

    // THREE buildables from two variant rows and three cells.
    assert_eq!(
        set.declared_names(),
        vec!["paired-installer", "pointer-to-image", "lonely-installer"],
        "a paired cell is its pointer's payload, not a second buildable"
    );
    assert_eq!(set.declared_count(), 3);

    // The paired installer: one row, both halves.
    let d = set.declared("paired-installer").expect("declared");
    assert_eq!(d.kind, ArtifactKind::Installer);
    assert_eq!(
        d.as_installer().map(|i| i.volume_id.as_str()),
        Some("PAIRED_VOL"),
        "the cell IS the row's payload — this answered None before 2026-08-23"
    );
    assert!(d.as_variant().is_some(), "and the cargo row is still there");
    assert_eq!(d.as_variant_node().kind, ArtifactKind::Installer);

    // The ref-paired container: the cell is named differently from the variant, so
    // the row keeps the VARIANT's name and carries the cell it points at.
    let d = set.declared("pointer-to-image").expect("declared");
    assert_eq!(d.kind, ArtifactKind::Container);
    assert_eq!(
        d.as_container().map(|c| c.name.as_str()),
        Some("the-image"),
        "a container_ref pointer carries the image it delegates to"
    );
    assert!(
        set.declared("the-image").is_none(),
        "the cell is the pointer's payload and is not a buildable of its own"
    );

    // The unpaired cell: its own row, and `get()` does NOT answer for it — that is
    // the narrowness the old holger-shaped assertion meant to state.
    let d = set.declared("lonely-installer").expect("declared");
    assert_eq!(d.kind, ArtifactKind::Installer);
    assert_eq!(d.as_installer().map(|i| i.volume_id.as_str()), Some("LONELY_VOL"));
    assert!(
        d.as_variant().is_none(),
        "nothing points at it, so there is no cargo row to hand back"
    );
    assert!(
        set.get("lonely-installer").is_none(),
        "the narrow cargo lookup answers only for [[build.variant]] rows"
    );
    assert!(set.get("paired-installer").is_some(), "…and it does answer for those");
}

/// **A DANGLING pointer keeps its row.** A `kind = "installer"` variant with no
/// cell, and a `container_ref` naming nothing, are declaration defects — but they
/// must stay VISIBLE in the build list. A fold that dropped them would turn a
/// broken declaration into an invisible one, which is worse.
#[test]
fn a_dangling_pointer_variant_still_appears_in_the_build_list() {
    let set = BuildVariantSet::from_toml_str(
        r#"
[build]
[[build.variant]]
name = "installer-with-no-cell"
kind = "installer"

[[build.variant]]
name = "container-with-no-image"
kind = "container"
container_ref = "ghost"
"#,
    )
    .expect("it parses — validation is a separate question from enumeration");

    assert_eq!(
        set.declared_names(),
        vec!["installer-with-no-cell", "container-with-no-image"]
    );
    let d = set.declared("installer-with-no-cell").expect("declared");
    assert_eq!(d.kind, ArtifactKind::Installer);
    assert!(d.as_installer().is_none(), "there is no cell to hand back");
    assert!(d.as_variant().is_some(), "but the cargo row is the row");
    // And the defect is still reported by the validator, not swallowed here.
    assert!(
        set.validate_all().is_err(),
        "a dangling container_ref is still a validation failure"
    );
}