xtask/src/main.rs
//! **nornir xtask** — dependency-free build automation.
//!
//! Verbs:
//! - `cargo xtask build [--suite <s>] [--atom <a>] [--list]` — **the ONE build
//! verb.** Its entire body is `nornir_build_thing::suite_cli::run(root, &args)`:
//! it reads `.nornir/build-variants.toml` and contains no variant name, no
//! feature string and no cargo argv of its own. Same command shape in korp,
//! dwarves, nornir and modgunn (`--suite ORIGINAL`) and in holger (`--suite MVP`).
//! - `cargo xtask demo-wasm` — build the `nornir-demo` release wasm bundle into
//! `dist/` (`index.html` + `nornir-demo.js` + `nornir-demo_bg.wasm` + Pages meta),
//! a self-contained static site.
//! - `cargo xtask deploy-pages` — run `demo-wasm`, then force-push `dist/` to the
//! `pages` branch, which Codeberg serves at <https://nordisk.codeberg.page/nornir>.
//! - `cargo xtask bench [--heavy|--light]` — run the Mímir bench suite. Two TIERS:
//! **heavy** = the full ~105-arm authoritative run (needs a FAT box like oden);
//! **light** = a fast representative smoke (a few working-set arms) that a normal
//! laptop can run without churning. With no flag the tier is AUTO-selected from the
//! machine: fat (≥24 cores AND ≥64 GiB) ⇒ heavy, else ⇒ light. So a normal machine
//! never accidentally kicks off the heavy run. `NORNIR_MACHINE` is stamped from the
//! hostname so oden's rows never overwrite another host's.
//!
//! - `cargo xtask feature-matrix [--atom <a>] [--list]` — ★ **THE FEATURE-COMBO
//! GATE** (SK290). `cargo check`s every combination `.nornir/build-variants.toml`
//! DECLARES, one `cargo check` per distinct
//! `(--no-default-features, --features …, --bin …)` triple, and exits non-zero
//! naming the atoms that failed. It reads the SAME declaration `xtask build` reads,
//! so the matrix cannot drift from the atom list — there is no second list of
//! combos anywhere.
//!
//! Why it exists: on 2026-08-01 the leanest DECLARED atom — `nornir-ui`,
//! `--no-default-features --features viz` (ATOM 4/7) — did not compile, and had
//! not for an unknown length of time, because nothing ever built it. `cargo
//! build`/`cargo test` only ever build `default`, and `default` contains every
//! feature, so a symbol that should be `#[cfg]`-gated and is not is INVISIBLE
//! there. Measured red:
//!
//! ```text
//! error[E0433]: cannot find `DocFormat` in `docs` --> src/bin/nornir.rs:13130:24
//! error[E0425]: cannot find function `client_workspace` in this scope
//! --> src/bin/nornir.rs:9689:21
//! error: could not compile `nornir` (bin "nornir") due to 18 previous errors
//! ```
//!
//! `cargo check` (not `build`), a dedicated `--target-dir`
//! (`$NORNIR_FEATURE_MATRIX_TARGET_DIR`, default `target/feature-matrix`) so the
//! per-combo feature churn never invalidates the normal `target/`, and
//! `--all-targets` OFF on purpose: the point is the shipped artifacts.
//!
//! The gh-pages FLOW itself lives ONCE in the edda leaf `nornir-pages` (LAW #5);
//! this crate is a thin dispatcher that builds nornir's [`PagesConfig`] knobs and
//! calls `nornir_pages::{demo_wasm, deploy_pages}` with nornir's DECLARATION and the
//! demo row's name. Since 2026-09-30 those build the row IN THE PINNED IMAGE
//! (`nornir_build_thing::container_road`: cargo + its wasm-bindgen stage) — never host
//! cargo or host wasm-bindgen (THE LAW: a build thing builds in a container). Only the
//! network `git push` shells. No YAML CI, no shell scripts.
//!
//! ⚠ nornir declares NO `nornir-demo-wasm` row today: the declared set is pinned to
//! the five matrix atoms (`tests/build_variants.rs`, "EXACTLY THE MATRIX — no more, no
//! less"). So `demo-wasm`/`deploy-pages` are REFUSED BY NAME until the owner decides
//! how the Pages demo row enters the declaration — there is no host fallback.
use std::path::Path;
use nornir_pages::PagesConfig;
use nornir_xtask_support::workspace_root_of;
/// The declared row the Pages demo is built from (`nornir-demo`, wasm32, with its
/// `[[build.wasm]]` bindgen cell). Named here because the verb needs ONE row; what
/// the row builds is the declaration's.
const DEMO_VARIANT: &str = "nornir-demo-wasm";
/// nornir's declaration, validated — the same load `cargo xtask build` does.
fn declared(root: &Path) -> Result<nornir_build_thing::BuildVariantSet, String> {
nornir_build_thing::BuildVariantSet::discover_validated(root)
.map_err(|e| format!("{e:#}"))?
.ok_or_else(|| format!("{} declares no build-variant set", root.display()))
}
// ─────────────────────────────────────────────────────────────────────────────
// ★ THE FEATURE-COMBO GATE (SK290)
// ─────────────────────────────────────────────────────────────────────────────
/// One combination the declaration asks for. Atoms whose cargo flags are
/// byte-identical share a `Combo` (checked ONCE, every sharing atom named), so
/// the gate never pays twice for one compile.
struct Combo {
/// Every declared atom this combination belongs to, in declaration order.
atoms: Vec<String>,
/// The cargo argv, taken from [`BuildVariant::cargo_args`] with `build`
/// swapped for `check` — NOT re-derived here. See [`check_args`].
args: Vec<String>,
/// The atom's `cfg = ` passthrough, appended to `RUSTFLAGS`. `None` for all
/// nornir atoms today; carried so a future one is not silently checked
/// without the `--cfg` it ships with.
rustflags_cfg: Option<String>,
}
/// The atom's own cargo argv, as a `check` instead of a `build`.
///
/// ⚠ **THIS USED TO BE HAND-ROLLED, AND THE COPY WAS ALREADY WRONG** (LAW 5).
/// Until 2026-08-03 this file walked `v.package` / `v.bin` /
/// `v.no_default_features` / `v.features` and assembled the argv itself — a
/// second materializer beside `nornir_build_thing::variant::BuildVariant::cargo_args`,
/// which every other reader of this declaration goes through (`suite_cli::run`
/// presses it, `--list` prints it, `no_two_atoms_materialize_the_same_cargo_line`
/// compares it).
///
/// The twin had already drifted in three ways, all of them silent:
///
/// * **`--lib` was missing.** `cargo_args` emits it for `kind = "lib"`; the copy
/// did not, so a lib atom would be checked as a bin that does not exist.
/// * **`--target` was missing.** A wasm atom (`target = "wasm32-*"`) would have
/// been checked for the HOST, i.e. the one combination it is not shipped as —
/// green here, broken on release.
/// * **`--profile` was missing**, so atoms declaring a custom profile were checked
/// under a different `cfg`/opt configuration than they ship with.
///
/// None of those bite nornir's five atoms TODAY, which is exactly the problem: a
/// twin that agrees on the current inputs is invisible until the day it does not,
/// and the gate reporting green is what would hide it. Deriving from the shared
/// materializer means adding a lib/wasm/profile atom needs no change here.
///
/// The swap itself is deliberate and is the ONLY difference this verb is entitled
/// to: `cargo_args()[0]` is the subcommand, and asserting that is what
/// [`check_args`] does before replacing it — if the shared materializer ever stops
/// leading with a subcommand, this fails loudly instead of producing
/// `cargo -p nornir check …`.
fn check_args(v: &nornir_build_thing::variant::BuildVariant) -> Result<Vec<String>, String> {
let mut args = v.cargo_args();
match args.first().map(String::as_str) {
Some("build") => {
args[0] = "check".to_string();
Ok(args)
}
other => Err(format!(
"atom '{}': BuildVariant::cargo_args() no longer starts with the `build` \
subcommand (got {other:?}) — this gate swaps that word for `check` and \
cannot do so blindly. Fix the swap rather than the assertion.",
v.name
)),
}
}
impl Combo {
/// The human line the report prints — the exact command a reader can paste.
fn command_line(&self) -> String {
format!("cargo {}", self.args.join(" "))
}
}
/// `cargo check` every feature combination `.nornir/build-variants.toml` declares.
///
/// The combos are DERIVED from the declaration through the production loader, so
/// this verb holds no feature string and no atom name of its own: adding an atom
/// to the TOML adds it to the gate, and there is nothing to keep in sync.
///
/// Fails (`Err`) when any combination does not compile, when the declaration is
/// missing, or when the filter selected NOTHING — an empty matrix that reported
/// "ok" would be exactly the vacuous green this gate exists to close.
fn feature_matrix(root: &Path, argv: &[String]) -> Result<(), String> {
let set = nornir_build_thing::variant::BuildVariantSet::discover(root)
.map_err(|e| format!("read .nornir/build-variants.toml: {e:#}"))?
.ok_or_else(|| {
format!(
"{} declares no .nornir/build-variants.toml, so there are NO declared \
feature combinations to check — this gate has nothing to stand on and \
refuses to report a pass",
root.display()
)
})?;
let only = argv
.iter()
.position(|a| a == "--atom")
.and_then(|i| argv.get(i + 1))
.map(String::as_str);
let list_only = argv.iter().any(|a| a == "--list");
let target_dir = std::env::var("NORNIR_FEATURE_MATRIX_TARGET_DIR").unwrap_or_else(|_| {
root.join("target")
.join("feature-matrix")
.display()
.to_string()
});
let mut combos: Vec<Combo> = Vec::new();
for v in &set.variants {
if let Some(want) = only {
if v.name != want {
continue;
}
}
// ⚠ NOT re-derived here — the SHARED materializer, `build` → `check`.
let args = check_args(v)?;
// The same flags twice is one compile, not two.
if let Some(hit) = combos
.iter_mut()
.find(|c| c.args == args && c.rustflags_cfg == v.cfg)
{
hit.atoms.push(v.name.clone());
continue;
}
combos.push(Combo {
atoms: vec![v.name.clone()],
args,
rustflags_cfg: v.cfg.clone(),
});
}
if combos.is_empty() {
return Err(match only {
Some(a) => format!(
"--atom {a} matched no declared atom; declared: {}",
set.names().join(", ")
),
None => "the declaration contains no atoms, so the matrix is EMPTY — an \
empty matrix cannot report red and must not report green"
.to_string(),
});
}
println!(
"nornir xtask feature-matrix — {} declared atom(s) ⇒ {} distinct feature combination(s)",
set.variants.len(),
combos.len()
);
println!(" target-dir: {target_dir}\n");
for c in &combos {
println!(" · {} — {}", c.atoms.join(" + "), c.command_line());
}
if list_only {
println!("\n(--list: nothing compiled)");
return Ok(());
}
println!();
let mut failed: Vec<(&Combo, String)> = Vec::new();
for (i, c) in combos.iter().enumerate() {
println!("[{}/{}] {} …", i + 1, combos.len(), c.atoms.join(" + "));
let mut cmd = std::process::Command::new(env!("CARGO"));
cmd.current_dir(root)
.args(&c.args)
.args(["--target-dir", &target_dir])
// `cargo check` writes diagnostics to stderr; merge nothing, capture
// both, and judge the OUTPUT, not just the status — a cargo that fails
// to launch its own resolution prints and exits, and an empty output
// read as clean is the trap this file is guarding against.
.stdin(std::process::Stdio::null());
if let Some(cfg) = &c.rustflags_cfg {
let inherited = std::env::var("RUSTFLAGS").unwrap_or_default();
cmd.env(
"RUSTFLAGS",
format!("{inherited} --cfg {cfg}").trim().to_string(),
);
}
let out = match cmd.output() {
Ok(o) => o,
Err(e) => {
failed.push((c, format!("could not launch cargo: {e}")));
println!(" ✗ could not launch cargo: {e}");
continue;
}
};
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
// ⚠ The grep that missed a whole broken build once: `: error` does NOT
// match a line that STARTS with `error:` / `error[E0433]:`, which is the
// shape of every rustc diagnostic AND of a cargo feature-resolution
// failure. Both forms are matched here.
let errors: Vec<&str> = text
.lines()
.filter(|l| {
let t = l.trim_start();
t.starts_with("error:") || t.starts_with("error[") || l.contains(": error")
})
.collect();
if out.status.success() && errors.is_empty() {
println!(" ✓ compiles");
continue;
}
// Status and output must AGREE. A non-zero exit with no error line means
// the failure is somewhere this filter cannot see, and saying "0 errors"
// there is the false-green shape.
let detail = if errors.is_empty() {
format!(
"cargo exited {} but printed no `error:` line — the last 20 lines of its \
output follow:\n{}",
out.status,
text.lines()
.rev()
.take(20)
.collect::<Vec<_>>()
.into_iter()
.rev()
.map(|l| format!(" {l}"))
.collect::<Vec<_>>()
.join("\n")
)
} else {
errors
.iter()
.take(12)
.map(|l| format!(" {l}"))
.collect::<Vec<_>>()
.join("\n")
};
println!(" ✗ {} error line(s)", errors.len());
failed.push((c, detail));
}
if failed.is_empty() {
println!(
"\n✔ every declared feature combination compiles ({}/{})",
combos.len(),
combos.len()
);
return Ok(());
}
let mut msg = format!(
"{}/{} DECLARED feature combination(s) do not compile:\n",
failed.len(),
combos.len()
);
for (c, detail) in &failed {
msg.push_str(&format!(
"\n ✗ atom(s) {} — {}\n{}\n",
c.atoms.join(" + "),
c.command_line(),
detail
));
}
Err(msg)
}
/// nornir's per-repo knobs — the entirety of the divergence from the other three
/// xtasks. Everything else (build → wasm-bindgen → index.html → orphan-worktree
/// force-push) is the shared `nornir-pages` flow.
fn cfg() -> PagesConfig {
PagesConfig {
// The cargo crate that owns the wasm binary.
demo_crate: "nornir-demo",
// The eframe canvas id the wasm entry (`nornir-demo/src/main.rs::start` →
// `WebDemo::new`) and the `index.html` loader agree on.
canvas_id: "demo_canvas",
// The exported JS state-hook name (`nornir-demo/src/main.rs::nornir_state`).
state_export: "__nornir_state",
title: "nornir — live web demo",
description: "nornir: the release dependency-DAG / publish graph, in your browser — the live in-browser demo (facett-rendered, sample fixture).",
// The live URL Codeberg Pages serves it at (printed banner).
pages_url: "https://nordisk.codeberg.page/nornir",
// The host pinned in `.domains`.
pages_host: "nordisk.codeberg.page",
// The branch Codeberg Pages serves from.
pages_branch: "pages",
// The env var naming the push remote; unset → `origin`
// (`ssh://git@git.gunnar.rs:2222/nordisk/nornir.git`, SSH-only per the nordisk law).
remote_env: "NORNIR_PAGES_REMOTE",
commit_label: "nornir live web demo",
// Read by nothing since edda's host road left (2026-09-30): the row's
// features are the declaration's.
extra_build_args: &[],
// The workspace root = the parent of this `xtask` crate dir.
workspace_root: workspace_root_of(env!("CARGO_MANIFEST_DIR").as_ref()),
}
}
/// A machine is "fat" (⇒ auto-heavy bench) at or above BOTH thresholds. oden is
/// 32 cores / 499 GiB, so it clears them; a normal laptop does not and gets light.
const FAT_CORES: usize = 24;
const FAT_MEM_GIB: u64 = 64;
/// The LIGHT-tier arm filter (`NORNIR_BENCH_ONLY`, comma-separated prefixes): the
/// SQL point-search arms + the batched-embed populate arm. A fast, representative
/// smoke — the harness fails RED if these ids vanish. (The working-set dup arms it
/// named before went with the `symbol_facts` read, Mímir v2.)
const LIGHT_BENCH_ARMS: &str =
"nornir.mimir.search.sql_point,nornir.mimir.populate.embed_members_batched";
/// Logical cores, best-effort (never panics; 1 on failure).
fn cpu_cores() -> usize {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
}
/// Total RAM in GiB from `/proc/meminfo` (`MemTotal` is in kB); 0 if unreadable
/// (⇒ treated as not-fat, i.e. light — the safe default for an unknown box).
fn mem_total_gib() -> u64 {
std::fs::read_to_string("/proc/meminfo")
.ok()
.and_then(|s| {
s.lines()
.find(|l| l.starts_with("MemTotal:"))
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|kb| kb.parse::<u64>().ok())
})
.map(|kb| kb / (1024 * 1024))
.unwrap_or(0)
}
/// The host name stamped into `NORNIR_MACHINE` so per-host bench rows never mix
/// (oden's numbers must never overwrite another box's). `/etc/hostname` → `$HOSTNAME`
/// → "unknown".
fn hostname() -> String {
std::fs::read_to_string("/etc/hostname")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.or_else(|| std::env::var("HOSTNAME").ok())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "unknown".to_string())
}
/// nornir's CI plan: its locks, its workspace tests, (full) its ORIGINAL rows and
/// (nightly) its bench.
fn ci_plan() -> nornir_xtask_support::ci::Plan {
use nornir_xtask_support::ci::{Mode, Plan, Step};
let root = workspace_root_of(env!("CARGO_MANIFEST_DIR").as_ref());
Plan {
repo: "nornir".into(),
root: root.clone(),
steps: vec![
nornir_xtask_support::lockcheck::step(&root),
Step::cargo("test", &["test", "--workspace", "--locked"]),
Step::cargo("rows:ORIGINAL", &["xtask", "build", "--suite", "ORIGINAL"]).from(Mode::Full),
Step::cargo("bench", &["xtask", "bench", "--light"]).from(Mode::Nightly),
],
}
}
fn main() {
let verb = std::env::args().nth(1);
match verb.as_deref() {
// `cargo xtask ci [--quick|--nightly] [--list] [--only <prefix>]`: nornir's CI
// plan on the shared runner (nornir-xtask-support `ci`), one JSON receipt.
Some("ci") => {
let rest: Vec<String> = std::env::args().skip(2).collect();
std::process::exit(nornir_xtask_support::ci::main(&ci_plan(), &rest));
}
// ── THE ONE BUILD VERB ───────────────────────────────────────────────
// Three lines on purpose. Everything that decides WHAT gets built — the
// atoms, their features, their bins, their profiles, which delivery they
// belong to and which one is the default — lives in
// `.nornir/build-variants.toml` and is read by `suite_cli`. If this arm
// ever grows a variant name or a `--features` string, the declaration has
// acquired a second reader and they will drift; that is the exact defect
// this verb replaced (`nornir-buildspec-holger` re-declared holger's
// variants in Rust: 8 in code vs 13 in the TOML).
Some("build") => {
let rest: Vec<String> = std::env::args().skip(2).collect();
let root = workspace_root_of(env!("CARGO_MANIFEST_DIR").as_ref());
let res = nornir_build_thing::suite_cli::parse(&rest).and_then(|args| {
// `--list` resolves and prints without compiling, so the banner must
// not claim a build. "resolved" and "built" reading the same is the
// silent-green shape this whole pass is about.
let listed = args.list;
nornir_build_thing::suite_cli::run(&root, &args).map(|names| (listed, names))
});
match res {
Ok((true, _)) => {}
Ok((false, built)) => {
println!("\n✔ built {} atom(s): {}", built.len(), built.join(", "))
}
Err(e) => {
eprintln!("\n✗ {e:#}");
std::process::exit(1);
}
}
}
// ── ★ THE FEATURE-COMBO GATE (SK290) ────────────────────────────────
// No combo list of its own: every combination checked here is read off
// `.nornir/build-variants.toml` through the production loader, the same
// one `xtask build` and `tests/build_variants.rs` use.
Some("feature-matrix") => {
let rest: Vec<String> = std::env::args().skip(2).collect();
let root = workspace_root_of(env!("CARGO_MANIFEST_DIR").as_ref());
match feature_matrix(&root, &rest) {
Ok(()) => {}
Err(e) => {
eprintln!("\n✗ {e}");
std::process::exit(1);
}
}
}
Some("demo-wasm") => match declared(&cfg().workspace_root)
.and_then(|set| nornir_pages::demo_wasm(&cfg(), &set, DEMO_VARIANT).map_err(|e| e.to_string()))
{
Ok(dist) => {
println!("\n✔ nornir-demo wasm bundle → {}", dist.display());
}
Err(e) => {
eprintln!("\n✗ demo-wasm failed: {e}");
std::process::exit(1);
}
},
Some("deploy-pages") => match declared(&cfg().workspace_root)
.and_then(|set| nornir_pages::deploy_pages(&cfg(), &set, DEMO_VARIANT).map_err(|e| e.to_string()))
{
Ok(sha) => {
println!("\n✔ nornir-demo published to Codeberg Pages (pages commit {sha})");
}
Err(e) => {
eprintln!("\n✗ deploy-pages failed: {e}");
std::process::exit(1);
}
},
Some("bench") => {
let rest: Vec<String> = std::env::args().skip(2).collect();
let force_heavy = rest.iter().any(|a| a == "--heavy");
let force_light = rest.iter().any(|a| a == "--light");
let (cores, mem_gib) = (cpu_cores(), mem_total_gib());
let fat = cores >= FAT_CORES && mem_gib >= FAT_MEM_GIB;
let heavy = if force_heavy {
true
} else if force_light {
false
} else {
fat
};
let why = if force_heavy || force_light {
"forced"
} else if fat {
"fat machine → auto-heavy"
} else {
"normal machine → auto-light"
};
let host = hostname();
println!(
"nornir xtask bench: {host} — {cores} cores, {mem_gib} GiB RAM ⇒ {} tier ({why})",
if heavy { "HEAVY" } else { "LIGHT" }
);
let mut cmd = std::process::Command::new(env!("CARGO"));
cmd.current_dir(workspace_root_of(env!("CARGO_MANIFEST_DIR").as_ref()))
.args(["run", "--release", "--example", "nornir-bench"])
// Stamp the host so oden's rows never overwrite another machine's
// (bench tables are per-host; mixing hosts makes numbers meaningless).
.env("NORNIR_MACHINE", &host);
if !heavy {
// LIGHT tier: a fast, representative smoke — the working-set search arms
// (the ones the Tier-0 cache work moves) + the batched-embed populate arm.
// The harness fails RED if these ids ever vanish, so a stale filter can
// never silently pass as a green zero-arm run.
cmd.env("NORNIR_BENCH_ONLY", LIGHT_BENCH_ARMS);
println!(" light arms: {LIGHT_BENCH_ARMS}");
} else {
println!(" heavy: the full linked bench suite (all ~105 arms)");
}
match cmd.status() {
Ok(s) if s.success() => {
println!(
"\n✔ bench ({} tier) complete",
if heavy { "heavy" } else { "light" }
);
}
Ok(s) => {
eprintln!("\n✗ bench exited: {s}");
std::process::exit(s.code().unwrap_or(1));
}
Err(e) => {
eprintln!("\n✗ bench failed to launch cargo: {e}");
std::process::exit(1);
}
}
}
other => {
eprintln!(
"nornir xtask — usage:\n \
cargo xtask build build the declared atoms (--suite/--atom/--list)\n \
cargo xtask demo-wasm build the nornir-demo wasm bundle into dist/\n \
cargo xtask deploy-pages build + force-push dist/ to the `pages` branch\n \
cargo xtask bench run the Mímir bench suite (auto heavy on fat boxes, light on normal)\n \
cargo xtask bench --heavy force the full ~105-arm authoritative run\n \
cargo xtask bench --light force the fast representative smoke\n \
cargo xtask feature-matrix cargo-check EVERY declared atom's feature combo (--atom <a>, --list)\n"
);
if other.is_some() {
eprintln!("unknown verb: {}", other.unwrap());
}
std::process::exit(2);
}
}
}