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