crates/nornir-build-thing/src/spec.rs
//! **An application describing itself** — the build spec as a compiled-in
//! declaration, the JSON the app emits, and the runtime loader the forge reads.
//!
//! # Why this exists
//!
//! The declarations used to be data files: `<repo>/.nornir/build-variants.toml`,
//! hand-written, one per repo. That is why a build-space coverage table had rows
//! reading SPACE UNDECLARED and NO BUILD SPACE — a repo described itself only if
//! someone remembered to write the file, and **nothing failed when they didn't**.
//!
//! The fix is a placement rule: *put an application's build declaration in a crate
//! the application depends on.* Then an app that compiles has described itself **by
//! construction**, and a missing declaration is a compile error rather than a silent
//! gap. Each app owns a small edda leaf — `nornir-buildspec-<app>` — that declares
//! its [`BuildVariantSet`] in Rust.
//!
//! # Why the forge does NOT depend on those crates
//!
//! The obvious next step — have dwarves `dep:` every buildspec leaf so it can
//! enumerate them — is a trap. It would mean adding an app, or an app adding a
//! variant, requires editing the forge's `Cargo.toml` and rebuilding it: the forge
//! could only ever build what it knew at its OWN compile time.
//!
//! So the spec travels as **data at the boundary and code at the source**:
//!
//! | step | who | what |
//! |---|---|---|
//! | 1 | the buildspec leaf | declares the set in Rust — cannot be absent, cannot drift |
//! | 2 | the app | `<app> --describe-build --json` emits [`SpecDocument`] — the app describes *itself* |
//! | 3 | the repo | the emitted JSON is checked in at [`SPEC_FILE`] as a generated cache |
//! | 4 | the forge | reads that JSON at runtime via [`BuildSpecSource`] — **no dep on any app, and none on any buildspec leaf** |
//!
//! Step 3 is not redundancy, it is what breaks the chicken-and-egg: you need the
//! spec in order to build the app, so you cannot require a built app in order to
//! read the spec. A drift test ([`check_against_file`]) keeps the cache honest — the
//! crate is the source of truth and the file is its shadow, never a second owner.
//!
//! # Why not WASM (yet)
//!
//! A loadable WASM module was considered for step 4 and deliberately deferred. A
//! variant list is *data*; WASM is a *code sandbox*. Shipping a sandbox to return a
//! struct is heavy, and — decisively — a loadable WASM artifact can be absent,
//! stale, or lying in exactly the way the TOML could, so it would not buy the
//! guarantee we are actually after. The compiled-in crate is what buys that.
//!
//! [`BuildSpecSource`] is nonetheless a trait with one implementation
//! ([`JsonSpecSource`]) precisely so WASM can become a second one when it earns it.
//! **The condition that would flip the decision:** a spec that must COMPUTE rather
//! than declare — host-conditional variants (a CUDA variant only where a GPU
//! exists), or specs arriving from a node the forge does not control and must not
//! trust. Running untrusted decision logic is what a sandbox is for; returning a
//! list is not.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::feature_axis::FeatureAxis;
use crate::surface::BuildSurface;
use crate::variant::BuildVariantSet;
/// The generated cache an app emits and the forge reads:
/// `<repo>/.nornir/build-spec.json`.
pub const SPEC_FILE: &str = ".nornir/build-spec.json";
/// The legacy hand-written declaration, still read as a fallback for repos that have
/// not yet moved their declaration into a crate. A repo with one of these is a repo
/// that can still silently lose its build space.
pub const LEGACY_FILE: &str = ".nornir/build-variants.toml";
/// The spec document's schema tag — bumped only on a breaking shape change.
pub const SPEC_SCHEMA: &str = "nornir.build-spec/1";
/// **What an app emits about itself.** The `build` field is the declaration
/// verbatim, so this document deserializes into a [`BuildVariantSet`] through the
/// SAME [`BuildConfig`] shape the TOML uses — one type, two encodings, no second
/// parser.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpecDocument {
/// Schema tag ([`SPEC_SCHEMA`]).
pub schema: String,
/// The repo / suite member this describes.
pub repo: String,
/// The crate the declaration is compiled in from — the source of truth's name,
/// recorded so a reader of the cache knows where to go to change it.
pub source_crate: String,
/// The exact command that regenerates this file.
pub generated_by: String,
/// The declaration.
pub build: BuildVariantSet,
}
impl SpecDocument {
/// Wrap a declaration for emission.
pub fn new(repo: &str, source_crate: &str, set: &BuildVariantSet) -> Self {
SpecDocument {
schema: SPEC_SCHEMA.to_string(),
repo: repo.to_string(),
source_crate: source_crate.to_string(),
generated_by: format!("{repo} --describe-build --json > {SPEC_FILE}"),
build: set.clone(),
}
}
/// The file text to check in — pretty JSON with a trailing newline, so the
/// generated cache diffs like a source file rather than as one long line.
pub fn to_file_text(&self) -> Result<String> {
let mut s = serde_json::to_string_pretty(self).context("serialize build spec")?;
s.push('\n');
Ok(s)
}
}
/// One app's build space, however it was obtained.
#[derive(Debug, Clone)]
pub struct BuildSpec {
/// The repo / suite member.
pub repo: String,
/// The declaration.
pub set: BuildVariantSet,
/// Where this came from — the honesty field. A pane must be able to say "this
/// repo describes itself" versus "someone hand-wrote a TOML" versus "nothing".
pub origin: SpecOrigin,
}
/// Where a [`BuildSpec`] came from, worst-to-best as a *guarantee*.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SpecOrigin {
/// The app emitted it from its compiled-in declaration — the strong case. Carries
/// the source crate named in the document.
AppEmitted {
/// The buildspec crate the app compiles in.
source_crate: String,
/// The file it was read from.
path: PathBuf,
},
/// A hand-written `.nornir/build-variants.toml`. Works, but nothing guarantees
/// it matches what the app actually builds — this is the state being retired.
LegacyToml {
/// The file it was read from.
path: PathBuf,
},
/// Compiled straight in (the app describing itself in-process, no file at all).
CompiledIn {
/// The buildspec crate.
source_crate: String,
},
/// The repo declares nothing. NOT the same as "declares an empty space".
Undeclared,
}
impl SpecOrigin {
/// Stable label for a UI / `state_json`.
pub fn label(&self) -> &'static str {
match self {
SpecOrigin::AppEmitted { .. } => "app-emitted",
SpecOrigin::LegacyToml { .. } => "legacy-toml",
SpecOrigin::CompiledIn { .. } => "compiled-in",
SpecOrigin::Undeclared => "undeclared",
}
}
/// True when the declaration is backed by something the app compiles in, so it
/// cannot silently disagree with what the app actually builds.
pub fn is_guaranteed(&self) -> bool {
matches!(self, SpecOrigin::AppEmitted { .. } | SpecOrigin::CompiledIn { .. })
}
/// The sentence a pane prints under the repo name.
pub fn line(&self) -> String {
match self {
SpecOrigin::AppEmitted { source_crate, .. } => {
format!("the app describes itself — compiled in from `{source_crate}`")
}
SpecOrigin::LegacyToml { path } => format!(
"hand-written {} — nothing guarantees it matches what the app builds",
path.display()
),
SpecOrigin::CompiledIn { source_crate } => {
format!("compiled in from `{source_crate}`")
}
SpecOrigin::Undeclared => {
"declares NO build space — undeclared, which is not the same as empty".to_string()
}
}
}
}
impl BuildSpec {
/// The projected surface (one row per variant, with its axes).
pub fn surface(&self) -> BuildSurface {
BuildSurface::from_set(&self.repo, &self.set)
}
/// The feature axis, optionally measured against the repo's manifests.
pub fn feature_axis(&self, repo_root: Option<&Path>) -> FeatureAxis {
let manifest = repo_root
.filter(|r| r.join("Cargo.toml").is_file())
.map(crate::feature_axis::manifest_features);
FeatureAxis::with_manifest_features(&self.repo, &self.set, manifest.as_ref())
}
/// The `state_json` shape.
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"repo": self.repo,
"origin": self.origin.label(),
"origin_line": self.origin.line(),
"guaranteed": self.origin.is_guaranteed(),
"variant_count": self.set.variants.len(),
"variants": self.set.names(),
})
}
}
// ── the loader seam ───────────────────────────────────────────────────────────
/// **How the forge obtains an app's build space at runtime.**
///
/// One method. The shipped implementation ([`JsonSpecSource`]) reads the emitted
/// JSON off disk, which is why the forge depends on no app and on no buildspec
/// crate: a new app, or an app growing a variant, needs **zero forge rebuild**.
///
/// This is a trait rather than a function so a second implementation can arrive
/// without disturbing callers — see the module docs for the WASM decision and the
/// specific condition that would justify it.
pub trait BuildSpecSource {
/// Resolve `repo`'s build space, given its checkout root. Returns a spec whose
/// [`origin`](BuildSpec::origin) says how strong the answer is; a repo that
/// declares nothing resolves to [`SpecOrigin::Undeclared`], never to an error —
/// "no declaration" is a finding to render, not a failure to abort on.
fn resolve(&self, repo: &str, repo_root: &Path) -> BuildSpec;
}
/// The shipped source: read `<repo_root>/.nornir/build-spec.json` (what the app
/// emitted about itself), falling back to the legacy hand-written TOML so a repo
/// that has not migrated is still listed — visibly, as `legacy-toml`.
#[derive(Debug, Clone, Copy, Default)]
pub struct JsonSpecSource;
impl BuildSpecSource for JsonSpecSource {
fn resolve(&self, repo: &str, repo_root: &Path) -> BuildSpec {
let json = repo_root.join(SPEC_FILE);
if json.is_file() {
if let Ok(text) = std::fs::read_to_string(&json) {
if let Ok(doc) = serde_json::from_str::<SpecDocument>(&text) {
return BuildSpec {
repo: repo.to_string(),
set: doc.build,
origin: SpecOrigin::AppEmitted {
source_crate: doc.source_crate,
path: json,
},
};
}
}
}
let toml = repo_root.join(LEGACY_FILE);
if toml.is_file() {
if let Ok(set) = BuildVariantSet::load_file(&toml) {
return BuildSpec {
repo: repo.to_string(),
set,
origin: SpecOrigin::LegacyToml { path: toml },
};
}
}
BuildSpec {
repo: repo.to_string(),
set: BuildVariantSet::default(),
origin: SpecOrigin::Undeclared,
}
}
}
// ── what a buildspec leaf and its app share ──────────────────────────────────
// Four leaves would otherwise each grow their own describe / emit / drift-check.
// That is a twin four times over, so the bodies live here and each leaf is only
// its DECLARATION.
/// The human `--describe-build` report: every variant with all four axes, the
/// feature space, and what each one materializes to.
pub fn describe(repo: &str, source_crate: &str, set: &BuildVariantSet) -> String {
let mut s = String::new();
s.push_str(&format!("{repo} — build space (compiled in from `{source_crate}`)\n"));
if set.variants.is_empty() {
s.push_str(" <no variants declared>\n");
return s;
}
if !set.offers.is_empty() {
s.push_str(&format!(
" offers: {}\n",
set.offers.iter().cloned().collect::<Vec<_>>().join(", ")
));
}
for g in &set.exclusive {
s.push_str(&format!(" exclusive: {}\n", g.join(" | ")));
}
s.push('\n');
for v in &set.variants {
let valid = match set.validate(v) {
Ok(()) => "ok ".to_string(),
Err(_) => "INVALID".to_string(),
};
s.push_str(&format!(
" {valid} {:<16} {:<9} {:<7} {:<26} {}\n",
v.name,
v.kind.as_str(),
String::from(v.shape),
v.target.as_deref().unwrap_or("host"),
if v.features.is_empty() { "(default features)".to_string() } else { v.features.join(" + ") },
));
s.push_str(&format!(" $ {}\n", v.command_line()));
if let Err(e) = set.validate(v) {
s.push_str(&format!(" ! {e}\n"));
}
}
let axis = FeatureAxis::of(repo, set);
s.push_str(&format!("\n {}\n", axis.headline()));
s
}
/// The JSON `--describe-build --json` emits — the exact bytes to check in at
/// [`SPEC_FILE`].
pub fn describe_json(repo: &str, source_crate: &str, set: &BuildVariantSet) -> Result<String> {
SpecDocument::new(repo, source_crate, set).to_file_text()
}
/// **The drift guard.** Assert the checked-in cache at `path` still says what the
/// crate says. `Ok(None)` when the file is absent (an isolated/published build has
/// no sibling repo to compare against — nothing to check, and nothing to fail).
///
/// This is what keeps the cache a SHADOW rather than a second owner: the crate is
/// the source of truth, and if the two disagree the crate wins and this goes red.
pub fn check_against_file(
repo: &str,
source_crate: &str,
set: &BuildVariantSet,
path: &Path,
) -> Result<Option<String>> {
let Ok(on_disk) = std::fs::read_to_string(path) else { return Ok(None) };
let expected = describe_json(repo, source_crate, set)?;
if on_disk == expected {
return Ok(None);
}
// Compare the DECLARATION, not the bytes, so the message can say which it is.
let parsed: std::result::Result<SpecDocument, _> = serde_json::from_str(&on_disk);
let same_declaration = match &parsed {
Ok(doc) => {
serde_json::to_value(&doc.build).ok() == serde_json::to_value(set).ok()
}
Err(_) => false,
};
let what = if same_declaration {
"the declarations agree but the file bytes differ (re-emit it)"
} else {
"the checked-in cache declares something the crate does NOT"
};
Ok(Some(format!(
"{} drifted from `{source_crate}`: {what}.\n regenerate: {}\n file: {}",
path.display(),
SpecDocument::new(repo, source_crate, set).generated_by,
path.display(),
)))
}
#[cfg(test)]
mod tests {
use super::*;
fn holgerish() -> BuildVariantSet {
BuildVariantSet::from_toml_str(
r#"
[build]
offers = ["embedded", "mini", "full"]
exclusive = [["embedded", "mini", "full"]]
[build.required]
"holger-embedded" = ["embedded"]
[[build.variant]]
name = "embedded"
package = "holger-server-cli"
bin = "holger-embedded"
features = ["embedded"]
no_default_features = true
profile = "release"
"#,
)
.unwrap()
}
/// The emitted document round-trips through the SAME `BuildConfig` shape the
/// TOML uses — one declaration type, two encodings, no second parser.
#[test]
fn an_emitted_spec_round_trips_into_the_same_declaration() {
let set = holgerish();
let text = describe_json("holger", "nornir-buildspec-holger", &set).unwrap();
let doc: SpecDocument = serde_json::from_str(&text).unwrap();
assert_eq!(doc.schema, SPEC_SCHEMA);
assert_eq!(doc.repo, "holger");
assert_eq!(doc.source_crate, "nornir-buildspec-holger");
assert_eq!(doc.build.names(), set.names());
assert_eq!(doc.build.offers, set.offers);
// …and the emitted text is a checked-in-able file: pretty, newline-terminated.
assert!(text.ends_with("}\n"), "diffs like a source file");
// The same bytes parse as the TOML's own config type too — one declaration
// type, two encodings, no second parser.
let cfg: crate::variant::BuildConfig = serde_json::from_str(&text).unwrap();
assert_eq!(cfg.build.names(), set.names());
}
/// The loader prefers what the APP said about itself, and says so.
#[test]
fn the_source_prefers_the_apps_own_emission_over_a_hand_written_toml() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join(".nornir")).unwrap();
// Only a legacy TOML: listed, but visibly NOT guaranteed.
std::fs::write(
root.join(LEGACY_FILE),
"[build]\n[[build.variant]]\nname = \"from-toml\"\n",
)
.unwrap();
let spec = JsonSpecSource.resolve("holger", root);
assert_eq!(spec.set.names(), vec!["from-toml"]);
assert_eq!(spec.origin.label(), "legacy-toml");
assert!(!spec.origin.is_guaranteed(), "a hand-written file guarantees nothing");
assert!(spec.origin.line().contains("nothing guarantees"));
// The app's own emission wins.
std::fs::write(
root.join(SPEC_FILE),
describe_json("holger", "nornir-buildspec-holger", &holgerish()).unwrap(),
)
.unwrap();
let spec = JsonSpecSource.resolve("holger", root);
assert_eq!(spec.set.names(), vec!["embedded"]);
assert_eq!(spec.origin.label(), "app-emitted");
assert!(spec.origin.is_guaranteed());
assert!(spec.origin.line().contains("nornir-buildspec-holger"));
}
/// A repo that declares nothing resolves to UNDECLARED — a finding to render,
/// never an error to abort on, and never an empty space pretending to be one.
#[test]
fn an_undeclared_repo_resolves_to_a_finding_not_an_error() {
let dir = tempfile::tempdir().unwrap();
let spec = JsonSpecSource.resolve("knut", dir.path());
assert_eq!(spec.origin, SpecOrigin::Undeclared);
assert!(!spec.origin.is_guaranteed());
assert!(spec.set.variants.is_empty());
assert!(
spec.origin.line().contains("not the same as empty"),
"{}",
spec.origin.line()
);
assert_eq!(spec.state_json()["guaranteed"], false);
}
/// The drift guard distinguishes "same declaration, different bytes" from "the
/// cache says something the crate does not" — the second is the dangerous one.
#[test]
fn the_drift_guard_names_which_kind_of_drift_it_found() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("build-spec.json");
let set = holgerish();
// Absent ⇒ nothing to check (an isolated/published build).
assert!(check_against_file("holger", "c", &set, &path).unwrap().is_none());
// In agreement ⇒ clean.
std::fs::write(&path, describe_json("holger", "c", &set).unwrap()).unwrap();
assert!(check_against_file("holger", "c", &set, &path).unwrap().is_none());
// Same declaration, different bytes.
let mut doc = SpecDocument::new("holger", "c", &set);
doc.generated_by = "something else".into();
std::fs::write(&path, doc.to_file_text().unwrap()).unwrap();
let msg = check_against_file("holger", "c", &set, &path).unwrap().unwrap();
assert!(msg.contains("declarations agree but the file bytes differ"), "{msg}");
// A cache claiming a variant the crate does not declare — the dangerous one.
let mut other = set.clone();
other.variants.push(crate::variant::BuildVariant {
name: "ghost".into(),
..Default::default()
});
std::fs::write(&path, describe_json("holger", "c", &other).unwrap()).unwrap();
let msg = check_against_file("holger", "c", &set, &path).unwrap().unwrap();
assert!(msg.contains("declares something the crate does NOT"), "{msg}");
}
#[test]
fn describe_prints_all_four_axes_per_variant() {
let out = describe("holger", "nornir-buildspec-holger", &holgerish());
assert!(out.contains("nornir-buildspec-holger"), "names the source of truth: {out}");
assert!(out.contains("embedded"));
assert!(out.contains("exe"), "kind axis");
assert!(out.contains("fat"), "shape axis");
assert!(out.contains("host"), "target axis");
assert!(out.contains("cargo build -p holger-server-cli"), "the materialized command");
}
}