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

nordisk/gunnar

crates/gunnar-build-thing/src/main.rs

//! `gunnar-build <variant>`: build one declared build point, and prove it landed.
//!
//! # Why this binary exists
//!
//! [`VARIANTS`] already declares every build point gunnar has — package, bin,
//! features, profile, target — migrated verbatim from
//! `.nornir/build-variants.toml` and held to it by `tests/toml_parity.rs`. What
//! did not exist was any way to *run* one. So every build was typed by hand,
//! and on 2026-08-08 two sweeps were lost to the two ways that goes wrong:
//!
//! 1. **The wrong binary.** `gunnar` and `gunnar-service` are different
//!    artifacts and a sweep needs both. Rebuilding one and re-running reports
//!    the other's arms still stale, which reads exactly like a code regression.
//! 2. **`required-features`, silently.** `gunnar-service` is
//!    `required-features = ["systemd"]`, so
//!    `cargo build --release -p gunnar-cli --bin gunnar-service` **builds
//!    nothing, prints a hint, and exits 0** — leaving the stale binary in place.
//!    That is the failure "measure, never assert" is written against: "a
//!    malformed command fails silently and its empty output is shaped exactly
//!    like a clean result."
//!
//! Neither is a mistake anyone stops making by being careful. Both are gone by
//! construction here: the feature set is a property of the variant, and success is
//! read off the **artifact on disk**, never off cargo's exit code.
//!
//! # It is not a second declaration
//!
//! One implementation. This binary owns no build knowledge. It looks a variant
//! up in [`VARIANTS`] and materializes the argv that variant already describes.
//! Adding a build point means editing `.nornir/build-variants.toml` and
//! [`VARIANTS`] — which `toml_parity` keeps in step — and never this file.
//!
//! # Why not `nornir build`
//!
//! The installed `nornir` CLI cannot parse gunnar's matrix: it rejects
//! `role = "solo"` (`.nornir/build-variants.toml:469`), accepting only
//! `server|client|library|tool`. gunnar's own loader parses it, so gunnar's
//! declaration is ahead of the upstream schema. Recorded rather than worked
//! around: when upstream learns `solo`, this binary becomes a thin shim over
//! `nornir build` and should be replaced by one.

use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
use std::time::SystemTime;

use gunnar_build_thing::{by_name, Variant, VARIANTS};

fn main() -> ExitCode {
    let args: Vec<String> = std::env::args().skip(1).collect();
    if args.is_empty() || args.iter().any(|a| a == "-h" || a == "--help") {
        usage();
        return ExitCode::FAILURE;
    }
    // ── The EMISSION doors (2026-08-30) ─────────────────────────────────────────
    //
    // `.nornir/build-variants.toml` is this crate's OUTPUT, not its input. It was
    // a hand-maintained mirror until this emitter, and the mirror had already
    // lost two whole groups to a loader that dropped what it could not parse (an
    // audit found them). `emit` is the only way the file is now written.
    if args[0] == "toml" {
        return match gunnar_build_thing::emitted_toml() {
            Ok(t) => {
                print!("{t}");
                ExitCode::SUCCESS
            }
            Err(e) => {
                eprintln!("gunnar-build-thing: {e:#}");
                ExitCode::FAILURE
            }
        };
    }
    if args[0] == "emit" {
        let root = args.get(1).map(PathBuf::from).unwrap_or_else(repo_root);
        return match gunnar_build_thing::write_emitted(&root) {
            Ok(p) => {
                println!("emitted {}", p.display());
                ExitCode::SUCCESS
            }
            Err(e) => {
                eprintln!("gunnar-build-thing: {e:#}");
                ExitCode::FAILURE
            }
        };
    }
    if args[0] == "--list" {
        for c in VARIANTS {
            let what = match (c.package, c.bin) {
                (Some(p), Some(b)) => format!("-p {p} --bin {b}"),
                (Some(p), None) => format!("-p {p}"),
                _ => "(composes other variants; runs no cargo)".to_string(),
            };
            println!("{:<24} {what}", c.name);
        }
        return ExitCode::SUCCESS;
    }

    let repo = repo_root();
    let newest = newest_source(&repo);
    let mut failures = Vec::new();

    for name in &args {
        let Some(variant) = by_name(name) else {
            eprintln!("gunnar-build: no build point named `{name}`");
            eprintln!("  try `gunnar-build --list`");
            return ExitCode::FAILURE;
        };
        if variant.package.is_none() {
            eprintln!("gunnar-build: `{name}` composes other variants and runs no cargo; skipping");
            continue;
        }
        eprintln!("gunnar-build: {name}");
        if let Err(e) = build_one(&repo, variant, newest) {
            failures.push(format!("{name}: {e}"));
        }
    }

    if failures.is_empty() {
        return ExitCode::SUCCESS;
    }
    eprintln!("\ngunnar-build: {} FAILED", failures.len());
    for f in &failures {
        eprintln!("  ✗ {f}");
    }
    ExitCode::FAILURE
}

/// Materialize this variant's cargo invocation, run it, and assert the artifact.
fn build_one(repo: &Path, variant: &Variant, newest: Option<SystemTime>) -> Result<(), String> {
    let mut cmd = Command::new(env!("CARGO"));
    cmd.current_dir(repo).args(cargo_args(variant));

    match cmd.status() {
        Ok(s) if s.success() => {}
        Ok(s) => return Err(format!("cargo exited {s}")),
        Err(e) => return Err(format!("could not run cargo: {e}")),
    }

    // ── the assertion cargo's exit code cannot make ─────────────────────────
    let Some(bin) = variant.bin else {
        return Ok(());
    };
    let out = artifact(repo, variant, bin);
    let meta = std::fs::metadata(&out).map_err(|_| {
        format!(
            "cargo exited 0 and {} does not exist.\n      \
             That is the `required-features` skip: cargo prints a hint and succeeds.",
            out.display()
        )
    })?;
    if let (Ok(built), Some(src)) = (meta.modified(), newest) {
        if built < src {
            let behind = src
                .duration_since(built)
                .map(|d| d.as_secs() / 60)
                .unwrap_or(0);
            return Err(format!(
                "{} is {behind} minute(s) OLDER than the newest source under crates/.\n      \
                 cargo exited 0 without relinking it, so this is a different binary from the \
                 one the source describes.",
                out.display()
            ));
        }
    }
    eprintln!("  ok  {}", out.display());
    Ok(())
}

/// The `cargo` arguments that build this variant: `build`, the profile unless it is the
/// default one, the package and bin, the feature switches and the target.
fn cargo_args(variant: &Variant) -> Vec<String> {
    let mut args = vec!["build".to_string()];
    if !variant.profile.is_empty() && variant.profile != "dev" {
        args.extend(["--profile".to_string(), variant.profile.to_string()]);
    }
    if let Some(p) = variant.package {
        args.extend(["-p".to_string(), p.to_string()]);
    }
    if let Some(b) = variant.bin {
        args.extend(["--bin".to_string(), b.to_string()]);
    }
    if variant.no_default_features {
        args.push("--no-default-features".to_string());
    }
    if !variant.features.is_empty() {
        args.extend(["--features".to_string(), variant.features.join(",")]);
    }
    if let Some(t) = variant.target {
        args.extend(["--target".to_string(), t.to_string()]);
    }
    args
}

/// Where cargo puts this variant's binary.
///
/// `CARGO_TARGET_DIR` is honoured because the forge bench arms look for gunnar
/// in the bench's own target directory and nowhere else — building into one
/// directory while an arm reads another is the failure this whole binary exists
/// to prevent.
fn artifact(repo: &Path, variant: &Variant, bin: &str) -> PathBuf {
    let root = std::env::var("CARGO_TARGET_DIR")
        .map(PathBuf::from)
        .unwrap_or_else(|_| repo.join("target"));
    let dir = match variant.profile {
        "" | "dev" => "debug",
        other => other,
    };
    match variant.target {
        Some(t) => root.join(t).join(dir).join(bin),
        None => root.join(dir).join(bin),
    }
}

fn repo_root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .and_then(Path::parent)
        .expect("crates/gunnar-build-thing has two ancestors")
        .to_path_buf()
}

/// The newest mtime under `crates/`, which is the rule
/// `bench/examples/nornir-bench.rs` refuses to measure against.
///
/// Deliberately the SAME rule rather than a looser one: a check here that
/// passed where the sweep's fails would be worse than none, because it would
/// send someone into a forty-minute run believing they were ready.
fn newest_source(repo: &Path) -> Option<SystemTime> {
    fn walk(dir: &Path, newest: &mut Option<SystemTime>) {
        let Ok(entries) = std::fs::read_dir(dir) else {
            return;
        };
        for entry in entries.flatten() {
            let path = entry.path();
            let Ok(meta) = entry.metadata() else { continue };
            if meta.is_dir() {
                if path.file_name().is_some_and(|n| n == "target") {
                    continue;
                }
                walk(&path, newest);
            } else if path.extension().is_some_and(|e| e == "rs" || e == "toml") {
                if let Ok(m) = meta.modified() {
                    if newest.is_none() || newest.is_some_and(|n| m > n) {
                        *newest = Some(m);
                    }
                }
            }
        }
    }
    let mut newest = None;
    walk(&repo.join("crates"), &mut newest);
    newest
}

fn usage() {
    eprintln!(
        "usage: gunnar-build <variant>...   build declared build points and prove they landed
       gunnar-build --list       every build point gunnar declares
       gunnar-build toml         the emitted .nornir/build-variants.toml, on stdout
       gunnar-build emit [root]  REGENERATE .nornir/build-variants.toml from the code

The declaration is `GunnarBuildThing::variants` in this crate; the toml is
EMITTED from it (code → toml, one-directional) and is never read back as
authority. This binary adds no build knowledge of its own (one implementation)."
    );
}

#[cfg(test)]
mod tests {
    use super::*;

    fn target_root(repo: &Path) -> PathBuf {
        std::env::var("CARGO_TARGET_DIR")
            .map(PathBuf::from)
            .unwrap_or_else(|_| repo.join("target"))
    }

    /// The artifact is looked for where cargo writes it: the `dev` profile (and the
    /// unnamed one) under `debug`, never under a directory called `dev`; a named
    /// profile under its own name; a target triple between the root and the profile.
    #[test]
    fn the_artifact_is_looked_for_where_cargo_writes_it() {
        let repo = Path::new("/work/gunnar");
        let root = target_root(repo);
        let dev = Variant {
            profile: "dev",
            target: None,
            ..VARIANTS[0]
        };
        assert_eq!(
            artifact(repo, &dev, "gunnar"),
            root.join("debug").join("gunnar")
        );
        let unnamed = Variant {
            profile: "",
            target: None,
            ..VARIANTS[0]
        };
        assert_eq!(
            artifact(repo, &unnamed, "gunnar"),
            root.join("debug").join("gunnar")
        );
        let musl = Variant {
            profile: "dev-media",
            target: Some("x86_64-unknown-linux-musl"),
            ..VARIANTS[0]
        };
        assert_eq!(
            artifact(repo, &musl, "gunnar-server"),
            root.join("x86_64-unknown-linux-musl")
                .join("dev-media")
                .join("gunnar-server")
        );
    }

    /// The argv is the variant's and nothing else: the default profile is not named
    /// (cargo's `dev` is spelled by omission), every feature travels in one
    /// `--features` list, and `--no-default-features` and the target are passed when the
    /// variant says so.
    #[test]
    fn the_cargo_line_is_the_variants_own() {
        let full = Variant {
            profile: "release",
            package: Some("gunnar-server"),
            bin: Some("gunnar-server"),
            no_default_features: true,
            features: &["engine-znippy", "http"],
            target: Some("x86_64-unknown-linux-musl"),
            ..VARIANTS[0]
        };
        assert_eq!(
            cargo_args(&full).join(" "),
            "build --profile release -p gunnar-server --bin gunnar-server --no-default-features \
             --features engine-znippy,http --target x86_64-unknown-linux-musl"
        );
        let plain = Variant {
            profile: "dev",
            package: Some("gunnar"),
            bin: None,
            no_default_features: false,
            features: &[],
            target: None,
            ..VARIANTS[0]
        };
        assert_eq!(cargo_args(&plain), ["build", "-p", "gunnar"]);
    }

    /// The staleness rule reads `.rs` and `.toml` files under `crates/` and skips every
    /// `target` directory: a build's own output is newer than every source and would make
    /// every binary look stale.
    #[test]
    fn the_newest_source_skips_targets_and_other_files() {
        let dir = std::env::temp_dir().join(format!("gunnar-build-newest-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        let at = |rel: &str, secs: u64| {
            let path = dir.join(rel);
            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
            let file = std::fs::File::create(&path).unwrap();
            file.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs))
                .unwrap();
        };
        assert_eq!(newest_source(&dir), None, "no crates/ at all");
        at("crates/a/src/lib.rs", 1_000);
        at("crates/a/Cargo.toml", 2_000);
        at("crates/a/target/debug/build.rs", 9_000);
        at("crates/a/README.md", 8_000);
        let newest = newest_source(&dir).expect("two sources");
        assert_eq!(
            newest,
            std::time::UNIX_EPOCH + std::time::Duration::from_secs(2_000)
        );
        let _ = std::fs::remove_dir_all(&dir);
    }
}