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