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

nordisk/gunnar-ui

xtask/src/build.rs

//! `cargo xtask build` — **build what the recipe declares, and nothing else.**
//!
//! # Why this exists
//!
//! Until 2026-09-17 this repository's build knowledge lived in
//! `deploy/deploy.sh`: the package, the feature, the triple and the two
//! commands, in shell, beside a CI workflow that spelled some of the same
//! things again. Neither could be read by any tool, and nothing held them to
//! each other.
//!
//! The declaration is now [`gunnar_ui_recipe`], and this module OWNS NO BUILD
//! KNOWLEDGE. It carries no package name, no feature string, no triple and no
//! list of rows. Every one of those is a field of a [`BuildVariant`] the recipe
//! declares, and the argv is materialized by the ONE writer that materializes
//! it for the dwarves forge as well — `nornir_build_thing::build_variant`.
//!
//! # The two roads, and why there are two
//!
//! A native row (`kind = "service"`) is a cargo build, and it goes through the
//! shared loop, which content-hashes what cargo reports and captures an
//! unsealed provenance record.
//!
//! A `kind = "wasm"` row is NOT just its cargo build. `trunk build --release`
//! runs that cargo build and then wasm-bindgen at the version `Trunk.toml`
//! pins, `wasm-opt -Oz`, and the `index.html` asset pipeline — and it writes
//! content-hashed names. Handing a wasm row to the cargo loop would produce
//! `gunnar_ui_web.wasm`, a file no browser loads and no door ships, and it
//! would report success. So the wasm rows are driven through trunk, with the
//! ROW's own `--features` — which is the whole point of declaring
//! `gunnar-ui-web-metered` at all: `cargo xtask build gunnar-ui-web-metered`
//! is the paid console's browser half, and no other door in this repository
//! can currently build it.
//!
//! The model has no word for trunk yet (`[[build.wasm]]`'s `BindgenStage`
//! derives its output names from the package name, which trunk does not use),
//! so the seam is here and named rather than faked in the declaration. See
//! `crates/gunnar-ui-recipe/src/variants.rs`.

use std::path::Path;
use std::process::Command;

use gunnar_ui_recipe::nornir_build_thing as nbt;
use nbt::suite_cli;
use nbt::variant::BuildVariant;

/// `cargo xtask build [--variant <name>] [--list]`.
///
/// The flags are the constellation's shared ones: `suite_cli::parse` is the
/// same parser dwarves, nornir, holger, korp and facett's xtasks use, so a
/// person who has typed `cargo xtask build --variant …` in any of them has
/// typed it here.
pub fn run(root: &Path, argv: &[String]) -> Result<Vec<String>, String> {
    // **The committed description must be the code's emission before anything
    // builds.** Not because this door reads it — it does not, it asks the code
    // — but because everything else that discovers this repository does read
    // it, and a build that runs while the file next to it says something else
    // is how the two drift apart in the first place.
    gunnar_ui_recipe::require_committed_is_emitted(root).map_err(|e| format!("{e:#}"))?;

    let args = suite_cli::parse(argv).map_err(|e| format!("{e:#}"))?;
    let set = gunnar_ui_recipe::declared();
    let (_suite, chosen) = suite_cli::resolve(&set, &args).map_err(|e| format!("{e:#}"))?;

    if args.list {
        for v in &chosen {
            println!("{:<26} {}", v.name, describe(v));
        }
        return Ok(chosen.iter().map(|v| v.name.clone()).collect());
    }

    let mut built = Vec::new();
    for v in &chosen {
        eprintln!("\n=== {} ===\n  {}", v.name, describe(v));
        if is_browser_bundle(v) {
            trunk(root, v)?;
        } else {
            // The shared loop: `set.require`, `set.validate`, then ONE
            // `cargo build --message-format=json` over the row's own
            // `cargo_args()`, content-hashing the executables it reports.
            let out = nbt::build_variant(&set, &v.name, root).map_err(|e| format!("{e:#}"))?;
            for a in &out.artifacts {
                eprintln!("  artifact {}", a.display());
            }
        }
        built.push(v.name.clone());
    }
    Ok(built)
}

/// A row whose target is wasm32 is built by trunk, not by cargo alone.
fn is_browser_bundle(v: &BuildVariant) -> bool {
    v.target.as_deref().is_some_and(|t| t.starts_with("wasm32"))
}

/// What this row builds, in one line — the row's own answer, never a guess.
fn describe(v: &BuildVariant) -> String {
    if is_browser_bundle(v) {
        format!("trunk {}", trunk_args(v).join(" "))
    } else {
        v.command_line()
    }
}

/// The trunk argv for a wasm row, materialized from the ROW. `--release`
/// because the row's profile says so; `--features` only when the row decided
/// one, so the community bundle's command line stays the one CI runs.
fn trunk_args(v: &BuildVariant) -> Vec<String> {
    trunk_args_into(v, std::env::var(BUNDLE_DIST_ENV).ok().as_deref())
}

/// **Where the caller wants the bundle** (`GUNNAR_UI_BUNDLE_DIST`, an absolute directory):
/// gunnar's fast verb builds the console into ITS run's own directory, so two runs of one
/// checkout never write one bundle. Unset: the recipe's directory, in this tree.
pub const BUNDLE_DIST_ENV: &str = "GUNNAR_UI_BUNDLE_DIST";

/// The bundle directory of row `v`: the caller's (`over`), else the recipe's.
fn bundle_dir(v: &BuildVariant, over: Option<&str>) -> Option<String> {
    let recipe = gunnar_ui_recipe::variants::bundle_dist(&v.name)?;
    Some(over.map(str::trim).filter(|d| !d.is_empty()).map(str::to_string).unwrap_or_else(|| recipe.to_string()))
}

/// [`trunk_args`] with the caller's bundle directory, if any.
fn trunk_args_into(v: &BuildVariant, over: Option<&str>) -> Vec<String> {
    let mut a = vec!["build".to_string()];
    if v.profile == nbt::variant::Profile::Release {
        a.push("--release".to_string());
    }
    if !v.features.is_empty() {
        a.push("--features".to_string());
        a.push(v.features.join(","));
    }
    // Where the bundle lands is the RECIPE's answer (`dist` / `dist-metered`),
    // so the paid console never overwrites the community one and gunnar's
    // front finds it where it reads it.
    if let Some(dist) = bundle_dir(v, over) {
        a.push("--dist".to_string());
        a.push(dist);
    }
    a
}

/// Run trunk in the row's own package directory — where its `Trunk.toml` is,
/// because that file is what pins wasm-bindgen and wasm-opt and declares the
/// dist directory, and trunk reads it relative to the working directory.
fn trunk(root: &Path, v: &BuildVariant) -> Result<(), String> {
    let package = v
        .package
        .as_deref()
        .ok_or_else(|| format!("variant '{}': a wasm row with no package", v.name))?;
    let dir = root.join("crates").join(package);
    if !dir.join("Trunk.toml").is_file() {
        return Err(format!(
            "variant '{}': no Trunk.toml in {} — the bundle's tool pins live there, and \
             running trunk without them builds a bundle CI's ratchet never measured",
            v.name,
            dir.display()
        ));
    }
    let status = Command::new("trunk")
        .current_dir(&dir)
        .args(trunk_args(v))
        .status()
        .map_err(|e| {
            format!(
                "variant '{}': running trunk in {}: {e}. The console is a WebAssembly \
                 bundle and trunk is how it is built (`cargo install trunk --locked \
                 --version 0.21.14`, the version CI pins).",
                v.name,
                dir.display()
            )
        })?;
    if !status.success() {
        return Err(format!("variant '{}': trunk exited {status}", v.name));
    }
    Ok(())
}

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

    /// ★ **Two gunnar fast runs of one checkout never write one bundle** (FAST-REL, 2026-10-25:
    /// two runs that started together collided in `dist-metered`, "Text file busy" in trunk's
    /// asset pipeline): the caller names its own directory; without it the recipe's stays.
    #[test]
    fn the_caller_names_where_the_bundle_lands() {
        let set = gunnar_ui_recipe::declared();
        let v = set.variants.iter().find(|v| v.name == "gunnar-ui-web-metered").expect("the paid console row");
        let at = |a: Vec<String>| a.iter().position(|x| x == "--dist").map(|i| a[i + 1].clone());
        assert_eq!(at(trunk_args_into(v, None)).as_deref(), gunnar_ui_recipe::variants::bundle_dist(&v.name), "the recipe's, in this tree");
        assert_eq!(at(trunk_args_into(v, Some("/run/a/console/dist-metered"))).as_deref(), Some("/run/a/console/dist-metered"), "the run's own");
        assert_eq!(at(trunk_args_into(v, Some(""))).as_deref(), gunnar_ui_recipe::variants::bundle_dist(&v.name), "an empty value is unset");
    }
}