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