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/src/emit.rs

//! **THE EMITTER — `code → toml`, one-directional, one writer.**
//!
//! The 2026-08-27 law (dwarves `.nornir/build-thing-code-is-source-design.md`):
//! a build thing IS the Rust `impl BuildThing`, and `.nornir/build-variants.toml`
//! is **emitted** from it — a description of the build, regenerated by
//! `<repo>-build-thing emit`, never edited by hand and never read back as
//! authority. Bijection holds by construction, so a parity mirror stops being a
//! thing anyone has to maintain.
//!
//! # Why this lives in the engine and not in each repo
//!
//! It did not, at first. tunnr's recipe crate (lane E, 2026-08-29) grew a
//! seventy-line `emit.rs` of its own, and the day a second repo needed one the
//! shape of the mistake was obvious: the emission is **three lines of
//! substance** —
//!
//! ```text
//! toml::to_string_pretty(&BuildConfig::emitting(set))   // R1 computes the header
//! format!("{repo_header}\n{body}")                      // the repo's own preface
//! from_toml_str(text) == set                            // it must read back
//! ```
//!
//! — and everything else is the repo's own header prose. Ten copies of those
//! three lines is ten places for the round-trip check to be forgotten, and
//! forgetting it is not hypothetical: an emission that does not parse back is
//! not a description of anything, and the only thing that catches it is running
//! it. LAW 5: one writer. A repo's `emit.rs` is now its `HEADER` const and a
//! call.
//!
//! # What [`recipe_toml`] guarantees
//!
//! * the header the repo wrote, then a blank line, then the body — so a reader
//!   who opens the file is told, in its first line, that editing it does
//!   nothing;
//! * `BuildConfig::emitting`, never a struct literal, so **R1** (the recipe
//!   declares the schema it needs, and the WRITER computes it from what the set
//!   populates — [`crate::schema::required_by`]) cannot be forgotten by an
//!   emitter author. A recipe at the floor grows no header at all, which is the
//!   right answer and not an omission;
//! * the emission is round-tripped through
//!   [`BuildVariantSet::from_toml_str`] — the ONE loader every door uses,
//!   carrying **R2** and **R3** — and refused unless it reads back **equal** to
//!   the declaration. So an emitted file is, by the time it is written, known to
//!   be readable, at a schema it declares, with nothing silently dropped.
//!
//! # What it deliberately does not do
//!
//! Read the committed file. Nothing here parses `.nornir/build-variants.toml` as
//! a source; the only read of it anywhere is a repo's own `toml_parity` test
//! comparing the committed bytes to this function's output.

use std::path::{Path, PathBuf};

use anyhow::{ensure, Context, Result};

use crate::plugin::BuildThing;
use crate::variant::{BuildConfig, BuildVariantSet};

/// The toml text for `set`: `header`, a blank line, then the emitted body.
///
/// `header` is the repo's own preface — what the file is, what it is not, and
/// where the code that produced it lives. It is expected to be a block of `#`
/// comment lines; a trailing newline is normalised so the caller cannot change
/// the emission by adding or dropping one.
pub fn recipe_toml(header: &str, set: &BuildVariantSet) -> Result<String> {
    let body = toml::to_string_pretty(&BuildConfig::emitting(set))
        .context("serialize the build set")?;
    let text = format!("{}\n\n{body}", header.trim_end_matches('\n'));
    // The emission must read back — through the SAME loader every door uses, so
    // R2 (too old a reader refuses by name) and R3 (a dropped key fails) are
    // exercised on the bytes we are about to commit, not on the next reader's.
    let back = BuildVariantSet::from_toml_str(&text)
        .context("the emission must parse back through the one loader")?;
    ensure!(
        &back == set,
        "the emission does not read back to the declaration — a field this recipe \
         declares does not survive a serialize/deserialize round trip, so the file \
         would describe a build nobody declared"
    );
    Ok(text)
}

/// The emission of `thing` — what its `.nornir/build-variants.toml` must contain.
pub fn emitted_toml<T: BuildThing>(header: &str, thing: &T) -> Result<String> {
    recipe_toml(header, &thing.variants())
}

/// Regenerate `<repo_root>/.nornir/build-variants.toml` from `thing`. Returns
/// the path written.
pub fn write_emitted<T: BuildThing>(header: &str, thing: &T, repo_root: &Path) -> Result<PathBuf> {
    let path = BuildVariantSet::conventional_path(repo_root);
    let text = emitted_toml(header, thing)?;
    if let Some(dir) = path.parent() {
        std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
    }
    std::fs::write(&path, &text).with_context(|| format!("write {}", path.display()))?;
    Ok(path)
}

/// The assertion a repo's `toml_parity` test makes: the committed file **is**
/// the code's emission, byte for byte.
///
/// Returns the message a failing test should print — naming the first differing
/// line and the command that fixes it — or `None` when they agree. A helper and
/// not a macro so the repo's test owns its own `assert!`.
#[must_use]
pub fn first_difference(committed: &str, emitted: &str) -> Option<String> {
    if committed == emitted {
        return None;
    }
    let line = committed
        .lines()
        .zip(emitted.lines())
        .enumerate()
        .find(|(_, (a, b))| a != b)
        .map(|(i, (a, b))| format!("line {}: committed {a:?} vs emitted {b:?}", i + 1))
        .unwrap_or_else(|| {
            format!(
                "line counts differ: committed {} vs emitted {}",
                committed.lines().count(),
                emitted.lines().count()
            )
        });
    Some(line)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::variant::{BuildVariant, Profile};

    fn a_set() -> BuildVariantSet {
        BuildVariantSet {
            variants: vec![BuildVariant {
                name: "solo".into(),
                package: Some("thing".into()),
                bin: Some("thing".into()),
                profile: Profile::Release,
                suites: vec!["ORIGINAL".into()],
                ..BuildVariant::default()
            }],
            ..BuildVariantSet::default()
        }
    }

    /// The header is the file's first line, and the emission reads back.
    #[test]
    fn the_emission_starts_with_the_header_and_reads_back_to_the_declaration() {
        let set = a_set();
        let text = recipe_toml("# emitted, not source.", &set).expect("emit");
        assert!(text.starts_with("# emitted, not source.\n\n"), "{text}");
        assert_eq!(BuildVariantSet::from_toml_str(&text).expect("read back"), set);
    }

    /// A header with or without a trailing newline emits the same bytes — the
    /// caller cannot change the file by editing whitespace it cannot see.
    #[test]
    fn the_header_newline_is_normalised() {
        let set = a_set();
        assert_eq!(
            recipe_toml("# h", &set).unwrap(),
            recipe_toml("# h\n\n\n", &set).unwrap()
        );
    }

    /// `first_difference` names the line, and says nothing when they agree.
    #[test]
    fn the_difference_report_names_the_first_differing_line() {
        assert_eq!(first_difference("a\nb\n", "a\nb\n"), None);
        let d = first_difference("a\nb\n", "a\nc\n").expect("a difference");
        assert!(d.contains("line 2"), "{d}");
        let n = first_difference("a\n", "a\nb\n").expect("a difference");
        assert!(n.contains("line counts"), "{n}");
    }
}