crates/nornir-build-thing/tests/public_shape.rs
//! **A field added to a struct the consumers build by literal is a MINOR bump** (lane
//! GUN-INSTALL, 2026-10-26).
//!
//! What happened: 0.2.6 added `InstallerBuild::payload_entries` in a patch release.
//! gunnar-recipe 0.1.0 (on crates.io, written for 0.2.5) names every field of
//! `InstallerBuild` in a struct literal, as gunnar's recipe does on purpose, so
//! `cargo install gunnar` resolved 0.2.6 and stopped at E0063. For a struct whose
//! fields are all public, a new field is a breaking change (the Cargo book's
//! SemVer reference, "struct-add-public-field-when-no-private"), and 0.x.y → 0.x.(y+1)
//! promises none. 0.2.2 (`ParamSpec::lands`) did the same.
//!
//! Why not `#[non_exhaustive]`: it forbids struct literals outside this crate,
//! `..Default::default()` included, so it is itself a breaking change for every
//! consumer that builds these structs today (gunnar-recipe, dwarves, tunnr's korp,
//! holger, monetize, nornir and facett, all on this crate's main by path), and it
//! takes away gunnar's deliberate rule that a field added here gets a decision there.
//! It belongs to a planned 0.3.0, not to a fix.
//!
//! So the shape is held instead: [`SHAPES`] records every field (read from the source,
//! `#[serde(skip)]` ones included) of each struct the
//! consumers write literals of, per `major.minor` line. A field added or removed
//! without moving to a new line is a red here. A new line is a new `SHAPES` row and
//! a minor bump (`0.3.0`); a published line's row is never edited.
/// The source of each struct the constellation builds by literal: `(struct, file text)`.
const SOURCES: &[(&str, &str)] = &[
("InstallerBuild", include_str!("../src/installer.rs")),
("IsoBuild", include_str!("../src/iso.rs")),
("ContainerBuild", include_str!("../src/container.rs")),
("ImageLayer", include_str!("../src/container.rs")),
("KernelDonor", include_str!("../src/installer.rs")),
("OptionSpec", include_str!("../src/options.rs")),
("OptionBake", include_str!("../src/options.rs")),
("ParamSpec", include_str!("../src/params.rs")),
];
/// The field names of `pub struct <name> { … }` in a source text, in declaration order
/// (every field: a `#[serde(skip)]` one is a field a literal must name too).
fn fields_in(source: &str, name: &str) -> Vec<String> {
let open = format!("pub struct {name} {{");
let start = source.find(&open).unwrap_or_else(|| panic!("no `{open}`"));
let body = &source[start + open.len()..];
let end = body.find("\n}").expect("the struct closes");
body[..end]
.lines()
.map(str::trim)
.filter(|l| !l.starts_with("//") && !l.starts_with('#'))
// Every field of these structs is `pub` (a private one would forbid the literals
// this guard is about), so a field line starts with `pub `; a wrapped type's
// continuation line (`crate::tree::Presets,`) does not.
.filter_map(|l| {
l.strip_prefix("pub ")?
.split_once(':')
.map(|(f, _)| f.trim().to_string())
})
.filter(|f| {
!f.is_empty()
&& f.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
})
.collect()
}
/// The structs the constellation builds by literal, and their fields on each line:
/// `(line, struct, fields)`.
const SHAPES: &[(&str, &str, &[&str])] = &[
// The 0.2 line as 0.2.6 published it. 0.2.6 broke the line once (payload_entries,
// which 0.2.5's literal-writers lack; gunnar-recipe 0.1.1 is written for it); this row
// is the line's shape from here on, never edited again.
(
"0.2",
"InstallerBuild",
&[
"name",
"variant",
"installer_variant",
"volume_id",
"payload",
"payload_entries",
"container_backend",
"target_disk_gib",
"gpu",
"boot_params",
"cmd",
"env",
"boot_logo",
"kernel_donor",
"target",
"data",
"medium_boot",
"edges",
"presets",
"applied_preset",
"params",
"fixtures",
"options",
"requires",
"applied_params",
"applied_options",
],
),
(
"0.2",
"IsoBuild",
&[
"name",
"kernel",
"rootfs",
"staging",
"volume_id",
"boot_params",
"format",
"from_variant",
"boot_logo",
"params",
"fixtures",
"applied_params",
"options",
"requires",
"applied_options",
"kernel_donor",
"edges",
"presets",
"applied_preset",
],
),
(
"0.2",
"ContainerBuild",
&[
"name",
"base",
"layers",
"entrypoint",
"cmd",
"workdir",
"env",
"expose",
"labels",
"tags",
"expected_sha256",
"format",
"containerfile",
"context",
"archive",
"edges",
"base_recipe",
"requires",
],
),
(
"0.2",
"ImageLayer",
&["source", "dest", "from_variant", "executable"],
),
("0.2", "KernelDonor", &["repo", "kernel"]),
(
"0.2",
"OptionSpec",
&[
"name",
"label",
"default",
"feature",
"params_when_on",
"params_when_off",
"param_kinds",
"forbid_symbols",
"bakes",
"kernel_requires",
"requires",
],
),
("0.2", "OptionBake", &["boot_param", "when_on", "when_off"]),
(
"0.2",
"ParamSpec",
&[
"name",
"label",
"kind",
"required",
"boot_param",
"staged_file",
"lands",
],
),
];
fn line() -> String {
let v = env!("CARGO_PKG_VERSION");
let mut it = v.split('.');
let (major, minor) = (it.next().unwrap(), it.next().unwrap());
if major == "0" {
format!("0.{minor}")
} else {
major.to_string()
}
}
#[test]
fn the_reader_finds_every_field_and_no_comment() {
let src = "/// doc: x\npub struct A {\n /// a: doc\n pub a: u8,\n #[serde(skip)]\n pub b_2: Option<std::collections::BTreeMap<String, String>>,\n // c: gone\n}\n";
assert_eq!(fields_in(src, "A"), ["a", "b_2"]);
assert!(
fields_in(include_str!("../src/container.rs"), "ContainerBuild")
.contains(&"expected_sha256".to_string()),
"a serde(skip) field counts"
);
}
#[test]
fn a_field_added_without_a_minor_bump_is_red() {
let line = line();
let mut bad = Vec::new();
for (name, source) in SOURCES {
let now = fields_in(source, name);
match SHAPES.iter().find(|(l, s, _)| *l == line && s == name) {
None => bad.push(format!(
"{name}: no shape recorded for the {line} line; its fields are {now:?}"
)),
Some((_, _, was)) => {
let added: Vec<&String> =
now.iter().filter(|f| !was.contains(&f.as_str())).collect();
let removed: Vec<&&str> = was
.iter()
.filter(|f| !now.iter().any(|n| n == *f))
.collect();
if !added.is_empty() || !removed.is_empty() {
bad.push(format!(
"{name} on the {line} line: added {added:?}, removed {removed:?}. Consumers build {name} by struct literal, so this breaks them in a semver-compatible release (E0063, `cargo install gunnar` 2026-10-26): bump to the next minor version and record its shape as a NEW row of SHAPES"
));
}
}
}
}
assert!(bad.is_empty(), "\n {}", bad.join("\n "));
}