crates/nornir-build-thing/src/schema.rs
//! **The recipe format's compatibility rule** — how one file survives five
//! readers at four pin generations.
//!
//! # The measurement this module exists for (2026-08-30, lane AJ)
//!
//! `.nornir/build-variants.toml` is read by more than one program, at more than
//! one version, by construction: the FORGE (`dwarves` CLI and the ⚒ pane) reads
//! every repo's recipe with ITS pin, and each repo's own `<repo>-build-thing`
//! crate — and that crate's `toml_parity` test — reads the same file with
//! THEIRS. Nothing compared them, and both possible failures had already
//! happened to ONE file, gunnar's:
//!
//! ```text
//! nornir-build-thing 0.1.10 (published) reading 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]], line 1685 — the WHOLE
//! recipe unloadable for one additive key (`OptionSpec` is
//! `deny_unknown_fields`). Lane AE had to invent a side table.
//!
//! and 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
//! …silently — `InstallerBuild` is NOT `deny_unknown_fields`, so two
//! whole groups (the machine, and what /data is laid with) went four
//! generations unread while `tests/toml_parity.rs` was GREEN. It was
//! green because both sides of the comparison were the same loader:
//! a mirror compared against a reader that discards what it cannot
//! parse agrees about nothing, loudly.
//! ```
//!
//! Two failure shapes, one file, one reader version. **Neither of them is
//! fixable by choosing between them**: turning `deny_unknown_fields` off
//! everywhere converts the loud one into four more quiet ones, and turning it on
//! everywhere makes every additive key a repo-wide outage. So the rule is three
//! parts, and each part owns exactly one of the three ways a key can go wrong.
//!
//! # THE RULE
//!
//! **R1 — the recipe declares the schema it needs, and the WRITER computes it.**
//! A root-level `[nornir-build-thing] schema = N` table, OUTSIDE `[build]`. Root
//! level is not a taste: [`BuildVariantSet`] is `deny_unknown_fields` and
//! [`BuildConfig`](crate::variant::BuildConfig) is not, so the root is the one
//! place in this format that **every published generation ignores instead of
//! refusing** — measured across 0.1.3 · 0.1.4 · 0.1.7 · 0.1.8 · 0.1.10 · 0.1.11.
//! A key inside `[build]` would have made the version marker itself the next
//! outage. `N` comes from [`required_by`], derived from what the set actually
//! populates — never hand-set, never bumped by an emission that says nothing
//! new. A recipe at the floor stays readable by every reader that ever existed.
//!
//! **R2 — a reader that is too old refuses BY NAME, before serde.**
//! [`check_readable`] compares the declared `N` against [`RECIPE_SCHEMA`] and
//! says which version to upgrade to and which declaration asked for it. Never a
//! serde error naming a field the reader has never heard of; never a line
//! number in a file the operator did not write.
//!
//! **R3 — at a schema it DOES understand, a reader that drops a key fails.**
//! [`dropped_keys`] reports every `build.*` path serde ignored, via
//! `serde_ignored` — one deserialize, not a second parser (LAW 5). Zero is the
//! law, enforced inside
//! [`BuildVariantSet::from_toml_str`](crate::variant::BuildVariantSet::from_toml_str)
//! itself, so the DISCARD cannot happen behind a green test again. This is the
//! half `deny_unknown_fields` was standing in for on the closed types, and the
//! half NOTHING was standing in for on the open ones — `InstallerBuild`,
//! `IsoBuild`, `ContainerBuild` — which is exactly where the four dropped paths
//! above lived.
//!
//! `deny_unknown_fields` **stays** on the types that carry it. Under R1+R2 a
//! reader only ever reaches serde with a file at a schema it understands, and at
//! that point an unknown key is a typo or a hand-edit — the code is the source
//! and the toml is emitted — and both of those must be loud.
//!
//! # What each failure shape now costs
//!
//! | the file says | this reader | what happens |
//! |---|---|---|
//! | schema > mine | too old | R2: named refusal + the version to upgrade to |
//! | schema ≤ mine, key unknown to a CLOSED type | any | serde: named refusal (a typo) |
//! | schema ≤ mine, key unknown to an OPEN type | any | R3: named refusal, listing every dropped path |
//! | schema ≤ mine, nothing unknown | any | loads, and has read every declared byte |
//!
//! None of the four is silence. That is the whole property.
//!
//! # The honest limit
//!
//! A reader that is ALREADY PUBLISHED cannot be taught R2 — nornir-build-thing
//! 0.1.10 will keep answering `unknown field kernel_requires` until nothing runs
//! it. What R1 buys immediately is that the file RECORDS what it needs, and what
//! R3 buys immediately is that the drop is detectable from the new side TODAY
//! (`nornir-build-thing-skew`, which links an old reader and a new one in one
//! process and names the disagreement). Every reader from this version on
//! refuses by name; the ones behind us are caught by the guard instead.
//!
//! MEASURED 2026-09-05, which is what that limit costs when it lands on a
//! person rather than on a guard. `MediumBoot::Gpt` shipped at 03:15 and
//! gunnar's declaration adopted it at 03:16. The forge installed at
//! `~/.local/bin/dwarves` had been built on 2026-09-02 — hours before 0.1.12
//! reached the registry — so it linked a reader with no schema code at all, and
//! `dwarves buildthing gunnar --variant gunnar-appliance-iso` answered
//!
//! ```text
//! unknown variant `gpt`, expected `el-torito` or `hybrid`
//! ```
//!
//! at line 912, column 15. The declaration was carrying `schema = 2` and
//! [`FIRST_READER`] maps that to `0.1.12`, so the named refusal this module
//! exists to produce — "upgrade to >= 0.1.12" — was available and unreachable:
//! the code that would have said it ships IN the version being asked for. The
//! whole gunnar ISO/kernel door was shut, and the operator's only clue was a
//! serde field error about a word they had just correctly written.
//!
//! `nornir-build-thing-skew` does not catch this one either. It links an old
//! reader and a new one in one process, so it sees a drop the moment BOTH are
//! in a build graph — but a stale BINARY on someone's PATH is in no build
//! graph. The residual exposure is exactly that: an installed forge older than
//! the schema it is asked to read. There is no version of this module that can
//! close it, and rebuilding the forge is the only remedy.
use std::collections::BTreeSet;
use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};
use crate::variant::{BuildConfig, BuildVariantSet};
/// **The schema this reader understands.** Bump it in the same commit that adds
/// a key `required_by` can ask for, and add the row to [`FIRST_READER`].
pub const RECIPE_SCHEMA: u32 = 6;
/// **What an ABSENT header means.** Every recipe written before 2026-08-30 —
/// which is every recipe in the constellation but one — carries no header and is
/// schema 1, readable by every generation back to 0.1.3. A file only grows a
/// header when it needs more than this.
pub const RECIPE_SCHEMA_FLOOR: u32 = 1;
/// The root table the header lives in. Hyphenated, because it names the CRATE.
pub const SCHEMA_TABLE: &str = "nornir-build-thing";
/// **The first published `nornir-build-thing` that understands each schema** —
/// what R2's refusal tells the operator to upgrade to.
///
/// Schema 1 is 0.1.3 because that is the oldest copy any lockfile in the
/// constellation still resolves (znippy's, measured 2026-08-30).
///
/// Schema 2 is **0.1.12 and not 0.1.11**, and that gap is a finding, not a
/// rounding: the PUBLISHED 0.1.11 has neither `target`/`data` on
/// [`InstallerBuild`](crate::installer::InstallerBuild) nor `kernel_requires` on
/// [`OptionSpec`](crate::options::OptionSpec) — the vocabulary lanes AE and AF
/// added lives only in edda's tree, under the same version number. Two crates,
/// one version, and `nornir`'s lockfile holds BOTH (one path, one registry) with
/// nothing able to tell them apart. Naming 0.1.11 here would make the refusal
/// message a lie, which is the one thing a refusal that names the fix may never
/// be. Hence this crate is 0.1.12.
/// Schema 3 is 0.1.13: the `package` VOCABULARY —
/// `[[build.package]]` ([`PackageBuild`](crate::package::PackageBuild)) and the
/// `kind = "package"` a variant declares to point at one. It is a schema bump and
/// not a quiet addition for the reason [`BuildVariantSet`] is
/// `deny_unknown_fields`: a 0.1.12 reader handed a recipe with a `[[build.package]]`
/// table refuses the WHOLE FILE with `unknown field \`package\``, at a line number
/// in a file the operator did not write, naming a word they spelled correctly.
/// That is exactly the failure R2 exists to convert into "upgrade
/// nornir-build-thing to >= 0.1.13, because facett's recipe declares
/// [[build.package]]" — and it only works if the bump happens in the same commit
/// as the key. The header is emitted only for a set that actually declares one, so
/// every recipe in the constellation that has no package stays at the floor and
/// keeps loading in every reader back to 0.1.3.
/// Schema 4 is 0.1.14: the `wasm` CELL — `[[build.wasm]]`
/// ([`WasmBuild`](crate::wasm::WasmBuild)) and its typed post-build stages. Same
/// reasoning as schema 3: `BuildVariantSet` is `deny_unknown_fields`, so a 0.1.13
/// reader handed a recipe with a `[[build.wasm]]` table refuses the WHOLE file with
/// `unknown field \`wasm\``, and R2 exists to turn that into "upgrade
/// nornir-build-thing to >= 0.1.14". A recipe whose wasm variants declare no cell —
/// which is every wasm variant written before 2026-09-06 — asks for nothing and
/// stays exactly where it was.
/// Schema 5 is 0.1.24: `sign = { purpose = … }` on a variant
/// ([`SignSpec`](crate::sign::SignSpec)) and `[[build.web_app]]`
/// ([`WebAppBuild`](crate::web_app::WebAppBuild)). Both land in
/// `deny_unknown_fields` structs, so a 0.1.23 reader refuses the whole file on
/// either; R2 turns that into "upgrade to >= 0.1.24".
/// Schema 6 is the release after 0.1.24: `siblings = [...]` on a variant row
/// ([`BuildVariant::siblings`](crate::variant::BuildVariant::siblings)), the
/// row's own sibling checkouts. `BuildVariant` is `deny_unknown_fields`, so a
/// 0.1.24 reader refuses the whole file on it; R2 names the upgrade instead.
pub const FIRST_READER: &[(u32, &str)] =
&[(1, "0.1.3"), (2, "0.1.12"), (3, "0.1.13"), (4, "0.1.14"), (5, "0.1.24"), (6, "0.1.25")];
/// The version string R2 points at for `schema`, or the newest known.
pub fn first_reader_of(schema: u32) -> &'static str {
FIRST_READER
.iter()
.find(|(n, _)| *n == schema)
.map(|(_, v)| *v)
.unwrap_or("a newer nornir-build-thing than any this reader knows")
}
/// **The root header.** Its own struct so it can be a real field of
/// [`BuildConfig`](crate::variant::BuildConfig) rather than a key R3 would then
/// report as dropped — the version marker may not be the first thing the drop
/// detector trips over.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SchemaHeader {
/// The schema level this recipe needs a reader to understand.
pub schema: u32,
}
/// The root header as it is written into an emission, comment and all.
pub fn header_toml(schema: u32) -> String {
format!(
"# The schema this recipe NEEDS a reader to understand. Computed by the\n\
# emitter from what the recipe declares (nornir_build_thing::schema::\n\
# required_by) — never hand-set. A reader that understands less refuses\n\
# BY NAME and says which version to upgrade to; it does not drop rows.\n\
[{SCHEMA_TABLE}]\n\
schema = {schema}\n"
)
}
/// **Read the declared schema without deserializing the recipe** — so an
/// unreadable file is refused by R2's message and never by serde's.
///
/// A partial parse into `toml::Value` on purpose: the whole point is to answer
/// "can I read this" for a file whose `[build]` subtree this reader may not
/// understand, and a struct-shaped parse could not do that.
pub fn declared_schema(text: &str) -> Result<u32> {
let doc: toml::Value = toml::from_str(text)
.map_err(|e| anyhow::anyhow!("{e}"))
.map_err(|e| anyhow::anyhow!("the recipe is not valid TOML at all: {e}"))?;
let Some(table) = doc.get(SCHEMA_TABLE) else {
return Ok(RECIPE_SCHEMA_FLOOR);
};
let Some(n) = table.get("schema").and_then(toml::Value::as_integer) else {
bail!(
"[{SCHEMA_TABLE}] is present but declares no integer `schema` — a header that \
cannot be read is worse than none, because a reader that skips it believes \
the file is schema {RECIPE_SCHEMA_FLOOR}"
);
};
if n < 1 {
bail!("[{SCHEMA_TABLE}] schema = {n}: schemas start at {RECIPE_SCHEMA_FLOOR}");
}
Ok(n as u32)
}
/// **THE STALE READER, as a value.** R2's finding before it is a sentence: the
/// recipe's level, this reader's level, this reader's version, and the version to
/// upgrade to.
///
/// It exists as a TYPE and not only as an `Err` because of what the four seats of
/// 2026-09-05/06 measured. Every one of them met this exact condition, and every
/// one of them met it as a string already wrapped in somebody else's context —
/// `facett: build-variants.toml could not be parsed: …`, which names a PARSE
/// failure for a file that parses perfectly. A caller that wants to say "your forge
/// is older than this recipe; rebuild it" cannot get there by matching on prose, so
/// it never said it: the operator read "could not be parsed" and went to look at
/// the recipe. A refusal that sends its reader to the wrong file is barely better
/// than silence.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StaleReader {
/// The schema the RECIPE declares.
pub declared: u32,
/// [`RECIPE_SCHEMA`] — the newest schema this reader understands.
pub reader_schema: u32,
/// `CARGO_PKG_VERSION` of this reader.
pub reader_version: &'static str,
/// The first published `nornir-build-thing` that understands `declared`.
pub upgrade_to: &'static str,
}
impl StaleReader {
/// The refusal, in the one wording every door states it in.
///
/// It names BOTH versions and the FIX, and it says out loud that the recipe is
/// not the problem — because the message it replaces sent four people to read a
/// file that was correct.
#[must_use]
pub fn sentence(&self) -> String {
format!(
"this recipe is schema {}; this nornir-build-thing ({}) understands up to \
schema {} — upgrade nornir-build-thing to >= {}. \
(The recipe is not malformed and nothing in it is a typo: it declares \
vocabulary that arrived after this reader's pin. See edda \
.nornir/build-design.md, 'the compatibility rule'.)",
self.declared, self.reader_version, self.reader_schema, self.upgrade_to,
)
}
}
/// **Is THIS reader older than the recipe in `text`?**
///
/// `None` for every other outcome, including a header that cannot be read at all —
/// that is [`declared_schema`]'s refusal, a different problem with a different fix.
/// This answers exactly one question, so a caller can route on it without parsing
/// prose.
#[must_use]
pub fn stale_reader(text: &str) -> Option<StaleReader> {
let declared = declared_schema(text).ok()?;
(declared > RECIPE_SCHEMA).then(|| StaleReader {
declared,
reader_schema: RECIPE_SCHEMA,
reader_version: env!("CARGO_PKG_VERSION"),
upgrade_to: first_reader_of(declared),
})
}
/// [`stale_reader`] asked of a FILE, without loading it — the cheap gate a door can
/// run BEFORE it commits to anything, which is the half R2 was missing: the
/// sentence existed, and it only ever arrived from inside a parse the caller had
/// already labelled something else.
///
/// A path that cannot be read is `None`: "the file is not there" is the caller's
/// own refusal to make, and answering it here would be this function pretending to
/// know two things.
#[must_use]
pub fn stale_reader_at(path: &std::path::Path) -> Option<StaleReader> {
stale_reader(&std::fs::read_to_string(path).ok()?)
}
/// **R2.** `Ok(n)` when this reader can read the file; a refusal that names the
/// fix when it cannot.
pub fn check_readable(text: &str) -> Result<u32> {
let n = declared_schema(text)?;
if let Some(stale) = stale_reader(text) {
bail!("{}", stale.sentence());
}
Ok(n)
}
/// **R1 — the schema this SET needs, and the declarations that ask for it.**
///
/// Derived from content, so an emission that says nothing new stays at the floor
/// and every reader back to 0.1.3 keeps loading it. Each arm names the exact
/// declaration, because that string is what R2's operator reads when a build box
/// refuses a recipe it used to accept.
pub fn required_by(set: &BuildVariantSet) -> (u32, Vec<String>) {
let mut why: Vec<String> = Vec::new();
// The HIGHEST schema anything in this set asks for. A running max and not a
// constant, because with three levels "some declaration is past the floor"
// stopped being the same question as "which level": a set carrying both an
// installer `target` (schema 2) and a package cell (schema 3) must emit 3, and
// the version that answered a flat 2 for anything non-floor would have written
// a header that reads back as a promise the file cannot keep.
let mut need = RECIPE_SCHEMA_FLOOR;
for i in &set.installers {
if !crate::target::Target::is_default(&i.target) {
why.push(format!("[build.installer.target] on '{}'", i.name));
}
if !crate::data::Data::is_default(&i.data) {
why.push(format!("[build.installer.data] on '{}'", i.name));
}
if !i.medium_boot.is_default() {
why.push(format!("`medium_boot = \"{}\"` on '{}'", i.medium_boot.as_str(), i.name));
}
for o in &i.options {
if !o.kernel_requires.is_empty() {
why.push(format!(
"`kernel_requires` on option '{}' of installer '{}'",
o.name, i.name
));
}
if !o.requires.is_empty() {
why.push(format!(
"`requires` on option '{}' of installer '{}'",
o.name, i.name
));
}
}
if !i.requires.is_empty() {
why.push(format!("[[build.installer.requires]] on '{}'", i.name));
}
}
for iso in &set.isos {
for o in &iso.options {
if !o.kernel_requires.is_empty() {
why.push(format!(
"`kernel_requires` on option '{}' of iso '{}'",
o.name, iso.name
));
}
if !o.requires.is_empty() {
why.push(format!("`requires` on option '{}' of iso '{}'", o.name, iso.name));
}
}
}
if !why.is_empty() {
need = need.max(2);
}
// ── schema 6 — a row's own sibling checkouts ─────────────────────────────
for v in &set.variants {
if !v.siblings.is_empty() {
why.push(format!("`siblings = [{}]` on '{}'", v.siblings.join(", "), v.name));
need = need.max(6);
}
}
// ── schema 5 — signed variants and browser web apps ──────────────────────
for v in &set.variants {
if let Some(s) = &v.sign {
why.push(format!("`sign = {{ purpose = \"{}\" }}` on '{}'", s.purpose.as_str(), v.name));
need = need.max(5);
}
}
for w in &set.web_apps {
why.push(format!("[[build.web_app]] '{}'", w.name));
need = need.max(5);
}
// ── schema 4 — the wasm cell ─────────────────────────────────────────────
// Named per CELL, like the package rows below: an operator reading a refusal
// off a build box needs to know WHICH declaration asked for a newer reader.
for w in &set.wasms {
why.push(format!("[[build.wasm]] '{}'", w.name));
need = need.max(4);
}
// ── schema 3 — the package vocabulary ────────────────────────────────────
// Named per CELL, not once for the set: R2's operator reads this string off a
// build box that just refused a recipe, and "a package cell" would leave them
// to find which one.
for p in &set.packages {
why.push(format!("[[build.package]] '{}'", p.name));
need = need.max(3);
}
// A `kind = "package"` variant is the same vocabulary reached by the other
// spelling, and it asks for the same reader even in the (invalid, but
// emittable) case where the cell is absent — `validate_kind` refuses that, and
// an emission is not the place to discover it.
if set
.variants
.iter()
.any(|v| v.kind == crate::variant::ArtifactKind::Package)
&& set.packages.is_empty()
{
why.push("`kind = \"package\"` on a variant".to_string());
need = need.max(3);
}
if why.is_empty() {
(RECIPE_SCHEMA_FLOOR, why)
} else {
(need, why)
}
}
/// **R3 — every `build.*` key path this reader IGNORED.**
///
/// Scoped to the `build` subtree deliberately. [`BuildConfig`] parses the same
/// `[build]` schema whether it stands alone or is embedded in a `nornir.toml`
/// full of other root tables (`[repo]`, `[docs]`, …), so the ROOT is open on
/// purpose and its extra keys are not this format's business. The recipe is the
/// `[build]` subtree, and inside it there is no such thing as a key that is
/// somebody else's.
///
/// One deserialize through `serde_ignored`, not a second parser walking
/// `toml::Value` beside the real one — that second walk is precisely the twin
/// that let `allow_unbuilt_bins` live five months without a schema
/// (`variant.rs`, `buildgate::read_allowlist`).
pub fn dropped_keys(text: &str) -> Result<Vec<String>> {
let mut seen: BTreeSet<String> = BTreeSet::new();
// toml >= 0.9: building the deserializer parses, so it can fail here.
let de = toml::Deserializer::parse(text).map_err(|e| anyhow::anyhow!("{e}"))?;
let _cfg: BuildConfig = serde_ignored::deserialize(de, |path| {
seen.insert(path.to_string());
})
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(seen.into_iter().filter(|p| is_recipe_path(p)).collect())
}
/// Is this ignored path inside the recipe (the `[build]` subtree)?
fn is_recipe_path(path: &str) -> bool {
path == "build" || path.starts_with("build.")
}
/// The refusal R3 raises. Split out so the loader states it once and the guard
/// can render the same sentence without loading anything.
pub fn refuse_dropped(path_hint: &str, dropped: &[String]) -> Result<()> {
if dropped.is_empty() {
return Ok(());
}
bail!(
"this reader (nornir-build-thing {}, schema {RECIPE_SCHEMA}) would SILENTLY DROP {} \
declaration{} from {path_hint}:\n {}\n\
A recipe key this reader cannot parse is not a shrug — it is a group of the build \
that no artifact will carry, and a `toml_parity` mirror written against the dropped \
value agrees with the drop. Either the writer is newer than this reader (raise the \
`nornir-build-thing` pin, and the recipe should have declared a [{SCHEMA_TABLE}] \
schema saying so), or the key is a hand-edit of an EMITTED file, which the code-is-\
the-source law forbids: change the recipe in Rust and re-emit.",
env!("CARGO_PKG_VERSION"),
dropped.len(),
if dropped.len() == 1 { "" } else { "s" },
dropped.join("\n "),
)
}
/// **What a reader can say about a recipe without agreeing to load it.**
///
/// The guard's unit of comparison: two readers produce two of these over the
/// same bytes, and the disagreement is a field-by-field diff rather than "one of
/// them panicked".
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecipeAudit {
/// The schema the file declares (the floor when it carries no header).
pub declared: Option<u32>,
/// [`RECIPE_SCHEMA`] of the reader that produced this audit.
pub reader_schema: u32,
/// `CARGO_PKG_VERSION` of the reader that produced this audit.
pub reader_version: &'static str,
/// Every `build.*` path this reader ignored — R3's answer. Empty is the law.
pub dropped: Vec<String>,
/// The refusal, when this reader cannot load the file at all.
pub refused: Option<String>,
}
impl RecipeAudit {
/// This reader's honest reading of `text`.
pub fn of(text: &str) -> Self {
let declared = declared_schema(text).ok();
let mut audit = RecipeAudit {
declared,
reader_schema: RECIPE_SCHEMA,
reader_version: env!("CARGO_PKG_VERSION"),
dropped: Vec::new(),
refused: None,
};
if let Err(e) = check_readable(text) {
audit.refused = Some(format!("{e:#}"));
return audit;
}
match dropped_keys(text) {
Ok(d) => audit.dropped = d,
Err(e) => audit.refused = Some(format!("{e:#}")),
}
audit
}
/// Did this reader read every declared byte of the recipe?
pub fn is_faithful(&self) -> bool {
self.refused.is_none() && self.dropped.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The smallest recipe that is a recipe.
const FLOOR: &str = r#"
[build]
offers = ["gui"]
[[build.variant]]
name = "app"
features = ["gui"]
"#;
#[test]
fn a_recipe_with_no_header_is_the_floor_and_every_reader_may_read_it() {
assert_eq!(declared_schema(FLOOR).unwrap(), RECIPE_SCHEMA_FLOOR);
assert_eq!(check_readable(FLOOR).unwrap(), RECIPE_SCHEMA_FLOOR);
assert!(dropped_keys(FLOOR).unwrap().is_empty());
}
/// R2, and the half of it that matters: the message names the FIX.
#[test]
fn a_recipe_from_the_future_is_refused_by_name_and_told_what_to_upgrade_to() {
let future = format!("{}\n{FLOOR}", header_toml(RECIPE_SCHEMA + 7));
let err = check_readable(&future).expect_err("a schema this reader cannot read");
let msg = format!("{err:#}");
assert!(
msg.contains(&format!("schema {}", RECIPE_SCHEMA + 7)),
"the refusal must name the schema the RECIPE declares: {msg}"
);
assert!(
msg.contains("upgrade nornir-build-thing"),
"the refusal must name the fix, not the field: {msg}"
);
assert!(
!msg.contains("unknown field"),
"R2 exists precisely so this is NOT a serde field error: {msg}"
);
}
/// **R2 as a VALUE.** The four seats of 2026-09-05/06 all met this condition
/// and none of them could route on it: the finding only existed as prose,
/// already wrapped in a caller's "could not be parsed". A door that wants to
/// say "rebuild the forge" needs the two versions, not a string to grep.
#[test]
fn the_stale_reader_is_a_value_a_door_can_route_on() {
let future = format!("{}\n{FLOOR}", header_toml(RECIPE_SCHEMA + 1));
let stale = stale_reader(&future).expect("this reader is older than that recipe");
assert_eq!(stale.declared, RECIPE_SCHEMA + 1);
assert_eq!(stale.reader_schema, RECIPE_SCHEMA);
assert_eq!(stale.reader_version, env!("CARGO_PKG_VERSION"));
// Both versions and the fix, in the sentence itself.
let msg = stale.sentence();
assert!(msg.contains(env!("CARGO_PKG_VERSION")), "{msg}");
assert!(msg.contains("upgrade nornir-build-thing"), "{msg}");
// R2's refusal IS this sentence — one wording, not two that can drift.
let err = format!("{:#}", check_readable(&future).expect_err("refused"));
assert_eq!(err, msg);
// Everything else is None, including a header that cannot be read: that is
// a different refusal with a different fix.
assert!(stale_reader(FLOOR).is_none());
assert!(stale_reader(&format!("{}\n{FLOOR}", header_toml(RECIPE_SCHEMA))).is_none());
assert!(stale_reader(&format!("[{SCHEMA_TABLE}]\nschema = \"two\"\n{FLOOR}")).is_none());
}
/// The FILE half — the gate a door runs before it commits to anything.
#[test]
fn a_door_can_ask_a_path_whether_this_reader_is_too_old_for_it() {
let dir = std::env::temp_dir().join(format!("nbt-stale-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("build-variants.toml");
std::fs::write(&path, format!("{}\n{FLOOR}", header_toml(RECIPE_SCHEMA + 3))).unwrap();
let stale = stale_reader_at(&path).expect("the file is from the future");
assert_eq!(stale.declared, RECIPE_SCHEMA + 3);
std::fs::write(&path, FLOOR).unwrap();
assert!(stale_reader_at(&path).is_none(), "a floor recipe is readable by everything");
assert!(
stale_reader_at(&dir.join("nope.toml")).is_none(),
"an absent file is the CALLER's refusal to make, not this one's"
);
let _ = std::fs::remove_dir_all(&dir);
}
/// A header at or below the reader's level is not in the way.
#[test]
fn a_recipe_at_this_readers_schema_loads_and_the_header_is_not_a_dropped_key() {
let here = format!("{}\n{FLOOR}", header_toml(RECIPE_SCHEMA));
assert_eq!(check_readable(&here).unwrap(), RECIPE_SCHEMA);
assert!(
dropped_keys(&here).unwrap().is_empty(),
"the version marker may not be the first thing the drop detector trips over"
);
let set = BuildVariantSet::from_toml_str(&here).expect("loads");
assert_eq!(set.variants.len(), 1);
}
/// **R3, seen RED by construction.**
///
/// `InstallerBuild` is not `deny_unknown_fields` — that is not an oversight,
/// it is what let `[build.installer.fixtures]`, `.target` and `.data` be
/// added at all — so serde takes this file happily and throws the group
/// away. Before this module, THAT was the whole story and the loader
/// returned `Ok`. Delete the `refuse_dropped` call in `from_toml_str` and
/// this test goes green while the declaration is gone.
#[test]
fn a_group_an_open_type_would_swallow_is_named_and_refused() {
let text = r#"
[build]
[[build.installer]]
name = "appliance"
variant = "app"
[build.installer.made_up_group]
fs = "xfs"
"#;
let dropped = dropped_keys(text).expect("serde takes the file");
assert_eq!(
dropped,
vec!["build.installer.0.made_up_group".to_string()],
"the drop must be reported BY PATH — 'something was dropped' is the \
silence this module exists to end"
);
let err = BuildVariantSet::from_toml_str(text)
.expect_err("the loader must refuse a recipe it cannot read in full");
let msg = format!("{err:#}");
assert!(
msg.contains("build.installer.0.made_up_group"),
"the refusal must carry the path: {msg}"
);
assert!(
msg.contains("SILENTLY DROP"),
"and must say what it is refusing to do: {msg}"
);
}
/// The measured 2026-08-30 shape, in miniature: the two groups nornir-build-
/// thing 0.1.10 swallowed out of gunnar's recipe were spelled EXACTLY like
/// this, and `tests/toml_parity.rs` was green over them.
#[test]
fn the_2026_08_30_swallowed_groups_are_what_this_test_is_shaped_like() {
for group in ["target", "data"] {
let text = format!(
"[build]\n\n[[build.installer]]\nname = \"gunnar-installer\"\nvariant = \"app\"\n\n\
[build.installer.{group}_typo]\nfs = \"xfs\"\n"
);
let dropped = dropped_keys(&text).unwrap();
assert_eq!(dropped, vec![format!("build.installer.0.{group}_typo")]);
}
}
/// R1: content decides, not the writer's mood.
#[test]
fn the_schema_a_set_needs_is_derived_from_what_it_declares() {
let plain = BuildVariantSet::from_toml_str(FLOOR).unwrap();
let (n, why) = required_by(&plain);
assert_eq!(n, RECIPE_SCHEMA_FLOOR);
assert!(why.is_empty(), "nothing in a plain cargo recipe asks for more: {why:?}");
let declaring = BuildVariantSet::from_toml_str(
r#"
[build]
[[build.installer]]
name = "appliance"
variant = "app"
[build.installer.data]
fs = "xfs"
"#,
)
.unwrap();
let (n, why) = required_by(&declaring);
assert_eq!(n, 2, "a declared [build.installer.data] needs schema 2");
assert_eq!(why, vec!["[build.installer.data] on 'appliance'".to_string()]);
assert_eq!(
first_reader_of(n),
"0.1.12",
"and 0.1.12 is the first PUBLISHED reader with the field — 0.1.11 is \
published WITHOUT it, under the same version number as edda's tree"
);
}
/// A malformed header is a refusal, not a shrug back to the floor: a reader
/// that skipped it would believe the file is schema 1 and drop the very rows
/// the header was written to protect.
#[test]
fn a_header_that_cannot_be_read_is_refused_rather_than_defaulted() {
let text = format!("[{SCHEMA_TABLE}]\nschema = \"two\"\n{FLOOR}");
let err = declared_schema(&text).expect_err("a non-integer schema");
assert!(format!("{err:#}").contains("no integer `schema`"), "{err:#}");
}
/// The audit is what the cross-version guard compares.
#[test]
fn an_audit_says_who_read_it_and_whether_it_read_all_of_it() {
let a = RecipeAudit::of(FLOOR);
assert!(a.is_faithful());
assert_eq!(a.reader_schema, RECIPE_SCHEMA);
assert_eq!(a.reader_version, env!("CARGO_PKG_VERSION"));
let future = format!("{}\n{FLOOR}", header_toml(RECIPE_SCHEMA + 1));
let b = RecipeAudit::of(&future);
assert!(!b.is_faithful());
assert_eq!(b.declared, Some(RECIPE_SCHEMA + 1));
assert!(b.refused.is_some());
}
}