nornir-build-thing-skew/tests/recipe_skew.rs
//! **THE GUARD: the oldest supported reader and the newest, over every repo's
//! recipe, failing by name when they disagree in a way the file never declared.**
//!
//! # What it is for
//!
//! `.nornir/build-variants.toml` has more than one reader at more than one
//! version by construction. The FORGE (`dwarves` CLI and the ⚒ pane) reads every
//! repo's recipe with ITS `nornir-build-thing` pin; the repo's own
//! `<repo>-build-thing` crate — and that crate's `toml_parity` test — reads the
//! same bytes with THEIRS. Measured 2026-08-30 across the constellation, those
//! pins were 0.1.0 · 0.1.3 · 0.1.4 · 0.1.8 · 0.1.9 · 0.1.10 · 0.1.11 (two
//! DIFFERENT crates under that last number), and **nothing compared them.**
//!
//! # The measurement it was written from
//!
//! ```text
//! nornir-build-thing 0.1.10 (published) vs gunnar's committed recipe:
//!
//! REFUSED unknown field `kernel_requires`, expected one of `name`, `label`,
//! `default`, `feature`, `params_when_on`, `params_when_off`,
//! `param_kinds`, `forbid_symbols`, `bakes`
//! at [[build.iso.options.kernel_requires]] — the WHOLE recipe.
//!
//! with that key removed so the load can finish, the SAME reader:
//! DROPS build.installer.0.data build.installer.0.target
//! build.installer.1.data build.installer.1.target
//! ```
//!
//! Four declarations read by nobody for four generations, with gunnar's
//! `tests/toml_parity.rs` GREEN over them — green because it compares the file
//! against the same loader that dropped the rows.
//!
//! # The property, stated exactly
//!
//! It is NOT "every reader must read every recipe": that is false and should be,
//! because a recipe is allowed to declare vocabulary a 2026-08 pin never had.
//! It is:
//!
//! > **A reader's failure must be explained by the recipe's DECLARED schema.**
//!
//! An old reader refusing or dropping is fine when the file says
//! `[nornir-build-thing] schema = N` with `N` past what that reader knew — that
//! is the format working, and a reader from 0.1.12 on turns it into a refusal
//! that names the fix. An old reader refusing or dropping a file that declares
//! **nothing** is undeclared skew, and it is the exact defect above.
//!
//! # Seen RED
//!
//! Permanently, and against the real file rather than a fixture:
//! [`the_guard_reports_the_2026_08_30_skew_when_the_header_is_taken_away`] strips
//! gunnar's `[nornir-build-thing]` header from the bytes in memory and asserts
//! the guard names the skew. Delete the comparison and that test goes green.
//! Its output, run 2026-08-30:
//!
//! ```text
//! gunnar, header removed: 0.1.10: REFUSED — TOML parse error at line 1685, column 21
//! ```
//! and with the `kernel_requires` blocks also removed, so the load can finish:
//! ```text
//! gunnar, header + kernel_requires removed: 0.1.10: SILENTLY DROPPED 4
//! build.installer.0.data
//! build.installer.0.target
//! build.installer.1.data
//! build.installer.1.target
//! ```
use std::path::{Path, PathBuf};
use nornir_build_thing_skew::{Reading, reader};
reader!(read_new, nbt_new, "0.1.12 (edda tree)");
reader!(read_old, nbt_old, "0.1.10 (published)");
/// **The schema the OLD reader's vocabulary corresponds to.** 0.1.10 predates
/// the header entirely, so everything it can read is schema 1 — and a file that
/// declares more than 1 is a file it is ENTITLED to refuse.
const OLD_READER_SCHEMA: u32 = 1;
/// Every repo in the constellation that ships a recipe (measured 2026-08-30).
const ROSTER: &[&str] = &[
"gunnar",
"korp",
"holger",
"skade",
"nornir",
"tunnr",
"znippy",
"znippy-zoomies",
"modgunn",
"ordning",
"dwarves",
"edda",
"knut",
"Skidbladnir",
"draupnir",
];
fn constellation_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(2)
.expect("edda/nornir-build-thing-skew has two ancestors up to ~/git")
.to_path_buf()
}
fn recipe_of(repo: &str) -> Option<(PathBuf, String)> {
let p = constellation_root().join(repo).join(".nornir/build-variants.toml");
std::fs::read_to_string(&p).ok().map(|t| (p, t))
}
/// What the recipe declares it needs — read with the NEW reader, which is the
/// only one that knows the header exists.
fn declared_schema(text: &str) -> u32 {
nbt_new::schema::declared_schema(text).unwrap_or(nbt_new::schema::RECIPE_SCHEMA_FLOOR)
}
/// The disagreement, or `None`.
fn skew(text: &str) -> Option<(u32, Reading, Reading)> {
let declared = declared_schema(text);
let new = read_new(text);
let old = read_old(text);
if declared > OLD_READER_SCHEMA {
// The old reader is ENTITLED to fail: the file says so. What is not
// allowed is the NEW reader failing on a file at its own level.
return (!new.is_faithful()).then_some((declared, new, old));
}
(!new.is_faithful() || !old.is_faithful()).then_some((declared, new, old))
}
fn report(repo: &str, path: &Path, declared: u32, new: &Reading, old: &Reading) -> String {
format!(
"{repo} ({})\n the recipe declares schema {declared}\n {}\n {}",
path.display(),
new.line(),
old.line(),
)
}
#[test]
fn the_guard_has_a_subject_on_disk() {
let missing: Vec<&str> = ROSTER.iter().copied().filter(|r| recipe_of(r).is_none()).collect();
assert!(
missing.is_empty(),
"the guard's subject is the CONSTELLATION and these checkouts are missing under {}: \
{missing:?}. A cross-version guard that skips its subjects compares nothing.",
constellation_root().display()
);
}
/// **THE GUARD.**
#[test]
fn no_repos_recipe_skews_between_the_oldest_and_the_newest_reader() {
let mut problems: Vec<String> = Vec::new();
let mut compared = 0usize;
for repo in ROSTER {
let Some((path, text)) = recipe_of(repo) else { continue };
compared += 1;
if let Some((declared, new, old)) = skew(&text) {
problems.push(report(repo, &path, declared, &new, &old));
}
}
assert!(compared >= 3, "compared {compared} recipes — the roster found nothing to judge");
assert!(
problems.is_empty(),
"these recipes are read DIFFERENTLY by the oldest supported reader and the \
newest, and the file does not declare a schema that explains it:\n\n{}\n\n\
Every silent drop above is a group of the build that reader will not carry. \
The fix is one of two, never a third: raise the repo's `nornir-build-thing` \
pin to a reader that understands the recipe, or — if the recipe genuinely \
needs newer vocabulary — make it declare `[nornir-build-thing] schema = N` \
so the old reader refuses BY NAME instead of dropping rows.",
problems.join("\n\n")
);
}
/// **The guard, seen RED — against the real file, not a fixture.**
///
/// gunnar's recipe declares `schema = 2` today, so the guard correctly forgives
/// 0.1.10 for refusing it. Take the header away and the SAME bytes become the
/// 2026-08-30 defect: an old reader that fails, and a file that never said it
/// would. If this test ever goes green, the comparison above has stopped
/// comparing.
#[test]
fn the_guard_reports_the_2026_08_30_skew_when_the_header_is_taken_away() {
let (_, text) = recipe_of("gunnar").expect("gunnar is checked out");
assert_eq!(
declared_schema(&text),
2,
"gunnar's recipe must DECLARE the schema it needs, or this probe is \
measuring a file that was never fixed"
);
assert!(
skew(&text).is_none(),
"with the header, the old reader's refusal is explained and the guard is quiet"
);
let headerless = strip_schema_header(&text);
assert_eq!(declared_schema(&headerless), 1, "the probe removed the header");
let (declared, new, old) = skew(&headerless).expect(
"WITHOUT the header this is exactly the 2026-08-30 defect: an old reader that \
fails on a file declaring nothing. A guard that stays quiet here would have \
watched gunnar lose four declarations for four generations, which is what \
happened.",
);
assert_eq!(declared, 1);
assert!(new.is_faithful(), "the NEW reader reads gunnar in full: {}", new.line());
assert!(
old.refused.is_some(),
"0.1.10 refuses gunnar's recipe outright on `kernel_requires`: {}",
old.line()
);
assert!(
old.refused.as_deref().unwrap_or_default().contains("kernel_requires"),
"and the refusal is the measured one: {}",
old.line()
);
// And the SILENT half — the one a refusal hides. With the key that stops the
// load also removed, 0.1.10 finishes and throws two whole groups away.
let quiet = strip_kernel_requires(&headerless);
let old = read_old(&quiet);
assert_eq!(
old.dropped,
vec![
"build.installer.0.data".to_string(),
"build.installer.0.target".to_string(),
"build.installer.1.data".to_string(),
"build.installer.1.target".to_string(),
],
"the four paths published 0.1.10 swallowed out of gunnar's recipe while \
tests/toml_parity.rs was green: {}",
old.line()
);
assert!(read_new(&quiet).is_faithful(), "the new reader keeps them");
}
/// Remove the `[nornir-build-thing]` root table (header to the next table).
fn strip_schema_header(text: &str) -> String {
let mut out = String::new();
let mut skipping = false;
for line in text.lines() {
let t = line.trim_start();
if t.starts_with('[') {
skipping = t.starts_with("[nornir-build-thing]");
}
if !skipping {
out.push_str(line);
out.push('\n');
}
}
out
}
/// Remove every `[[….kernel_requires]]` table-array block, so a reader that
/// would refuse on the key gets far enough to show what it drops instead.
fn strip_kernel_requires(text: &str) -> String {
let mut out = String::new();
let mut skipping = false;
for line in text.lines() {
let t = line.trim_start();
if t.starts_with('[') {
skipping = t.contains("kernel_requires");
}
if !skipping {
out.push_str(line);
out.push('\n');
}
}
out
}