crates/nornir-build-thing/tests/recipe_faithful.rs
//! **Every recipe in the constellation, read in FULL by this reader — or named.**
//!
//! The guard for the 2026-08-30 defect (lane AJ), which was not that a recipe
//! failed to load but that one loaded and lost:
//!
//! ```text
//! nornir-build-thing 0.1.10 over gunnar's committed .nornir/build-variants.toml
//! REFUSED unknown field `kernel_requires` … [[build.iso.options.kernel_requires]]
//! and with that key removed so the load can finish:
//! DROPS build.installer.0.data build.installer.0.target
//! build.installer.1.data build.installer.1.target
//! ```
//!
//! Four declarations — the machine each medium is built for, and what `/data`
//! is laid with — read by nobody for four pin generations, with gunnar's own
//! `tests/toml_parity.rs` GREEN over them the whole time. It was green because a
//! parity mirror compares the file against the SAME loader that dropped the
//! rows: two sides that agree about nothing.
//!
//! This test is the half a parity test structurally cannot be. It does not
//! compare the file to a mirror; it asks the loader **what it did not read**
//! ([`nornir_build_thing::schema`], R3) and fails with the path.
//!
//! # Seen RED
//!
//! ```text
//! $ cargo test -p nornir-build-thing --test recipe_faithful
//! ---- every_declared_recipe_is_read_in_full stdout ----
//! gunnar (…/gunnar/.nornir/build-variants.toml): this reader would SILENTLY
//! DROP 4 declarations:
//! build.installer.0.data
//! build.installer.0.target
//! build.installer.1.data
//! build.installer.1.target
//! ```
//! measured by pointing an OLD reader at the same roster
//! (`crates/nornir-build-thing-skew`, which links 0.1.10 and this crate in one
//! process). Against THIS reader the roster is green, which is the claim being
//! made: nothing in the constellation declares a key this pin cannot read.
//!
//! A guard with no subject on disk is a false green, so a missing checkout is a
//! FAILURE here and not a skip — the same rule `vocabulary_compat.rs` follows.
use std::path::{Path, PathBuf};
use nornir_build_thing::schema::{self, RecipeAudit};
use nornir_build_thing::variant::BuildVariantSet;
/// **The roster.** Every repo in the constellation that ships a
/// `.nornir/build-variants.toml`, measured 2026-08-30. A repo that grows one
/// belongs here the day it does: an omission reads as "there is no such recipe",
/// which is the one thing a roster must never read as.
const ROSTER: &[&str] = &[
"gunnar",
"korp",
"holger",
"skade",
"nornir",
"tunnr",
"znippy",
"znippy-zoomies",
"modgunn",
"ordning",
"dwarves",
"edda",
"knut",
"Skidbladnir",
"draupnir",
];
/// The directory the sibling checkouts live in — edda's parent.
fn constellation_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(3)
.expect("crates/nornir-build-thing has three ancestors up to ~/git")
.to_path_buf()
}
#[test]
fn the_roster_has_a_subject_on_disk() {
let root = constellation_root();
// Two different defects, named apart: a sibling that was never CLONED here
// (the release-3 constellation at ~/d-release3/src had no `korp` and no
// `ordning`), and a checkout that no longer SHIPS a recipe.
let absent: Vec<&str> = ROSTER.iter().copied().filter(|r| !root.join(r).is_dir()).collect();
let no_recipe: Vec<&str> = ROSTER
.iter()
.copied()
.filter(|r| root.join(r).is_dir())
.filter(|r| !root.join(r).join(".nornir/build-variants.toml").is_file())
.collect();
assert!(
absent.is_empty() && no_recipe.is_empty(),
"the guard's subject is the CONSTELLATION under {}.\n \
sibling checkouts ABSENT (clone each beside edda): {absent:?}\n \
checked out but WITHOUT .nornir/build-variants.toml: {no_recipe:?}\n\
A compat guard that skips its missing subjects goes green having read nothing — \
clone them, or take the repo off the roster with the reason.",
root.display()
);
}
/// **R3 over the real declarations.** Every recipe on disk, read in full.
#[test]
fn every_declared_recipe_is_read_in_full() {
let root = constellation_root();
let mut problems: Vec<String> = Vec::new();
let mut read = 0usize;
for repo in ROSTER {
let path = root.join(repo).join(".nornir/build-variants.toml");
let Ok(text) = std::fs::read_to_string(&path) else { continue };
read += 1;
let audit = RecipeAudit::of(&text);
if let Some(why) = &audit.refused {
problems.push(format!("{repo} ({}): REFUSED — {why}", path.display()));
continue;
}
if !audit.dropped.is_empty() {
problems.push(format!(
"{repo} ({}): this reader would SILENTLY DROP {} declaration(s):\n {}",
path.display(),
audit.dropped.len(),
audit.dropped.join("\n "),
));
}
}
assert!(read >= 3, "read {read} recipes — the roster found nothing to judge");
assert!(
problems.is_empty(),
"the recipes below declare something this reader does not read:\n\n{}\n\n\
Every one of these is a group of the build that no artifact will carry. \
Raise the repo's `nornir-build-thing` pin, or change the recipe in Rust \
and re-emit — never hand-edit the row to match what the loader kept.",
problems.join("\n\n")
);
}
/// **R1 over the real declarations.** A recipe that needs more than the floor
/// must SAY so, or an older reader has no way to refuse by name.
#[test]
fn a_recipe_that_needs_a_newer_reader_declares_its_schema() {
let root = constellation_root();
let mut problems: Vec<String> = Vec::new();
for repo in ROSTER {
let path = root.join(repo).join(".nornir/build-variants.toml");
let Ok(text) = std::fs::read_to_string(&path) else { continue };
let Ok(set) = BuildVariantSet::from_toml_str(&text) else { continue };
let (needs, why) = schema::required_by(&set);
let declared = schema::declared_schema(&text).unwrap_or(schema::RECIPE_SCHEMA_FLOOR);
if declared < needs {
problems.push(format!(
"{repo} ({}) declares schema {declared} but needs {needs}, because of:\n {}\n \
An older reader will not refuse it by name — it will drop those rows in \
silence, which is measured to happen (published 0.1.10 over gunnar).",
path.display(),
why.join("\n "),
));
}
}
assert!(problems.is_empty(), "{}", problems.join("\n\n"));
}
/// **The schema header is not itself a compatibility hazard.**
///
/// R1's whole argument is that the ROOT is the one place every published
/// generation ignores. If the header were ever moved inside `[build]` —
/// `deny_unknown_fields` — the version marker would become the next outage. This
/// pins it where it is: a recipe carrying the header still loads, and the header
/// is not reported as a dropped key.
#[test]
fn the_header_lives_where_every_generation_can_ignore_it() {
let text = format!(
"{}\n[build]\noffers = [\"gui\"]\n\n[[build.variant]]\nname = \"app\"\nfeatures = [\"gui\"]\n",
schema::header_toml(schema::RECIPE_SCHEMA)
);
assert!(text.contains("[nornir-build-thing]"), "the header is a ROOT table");
assert!(
!text.contains("[build.nornir-build-thing]"),
"inside [build] it would hit `deny_unknown_fields` on BuildVariantSet and \
make the version marker the next outage"
);
let audit = RecipeAudit::of(&text);
assert!(audit.is_faithful(), "{audit:?}");
assert_eq!(audit.declared, Some(schema::RECIPE_SCHEMA));
}