Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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