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