xtask/src/main.rs
//! `cargo xtask <task>`: gunnar-ui's repository tasks.
//!
//! ```text
//! cargo xtask build [--variant N] build what the recipe declares
//! cargo xtask publish [--push] build the PUBLIC branch and prove it compiles
//! ```
//!
//! `build` carries no build knowledge of its own: every package, feature,
//! profile and target it uses is a field of a variant `gunnar-ui-recipe`
//! declares, and `.nornir/build-variants.toml` is that declaration EMITTED for
//! the tools that discover this repository by that path.
//!
//! Nothing here is a build dependency of anything: repository tooling is
//! invoked as a tool, never as a dependency.
use std::process::ExitCode;
/// `cargo xtask publish [--push]`; see [`xtask::publish`].
///
/// Two steps, and the second is the one that matters: it writes branch `public`
/// from HEAD's tree minus the declared exclusion list, and then compiles that
/// branch in a throwaway worktree. Excluding a file a build reads produces a
/// branch that looks right and does not build, and nothing about writing the
/// list would have said so.
fn task_publish() -> ExitCode {
let mut push = false;
for a in std::env::args().skip(2) {
match a.as_str() {
"--push" => push = true,
other => {
eprintln!("xtask publish: unknown argument `{other}`");
eprintln!("usage: cargo xtask publish [--push]");
return ExitCode::FAILURE;
}
}
}
match xtask::publish::run(&xtask::workspace_root(), push) {
Ok(r) => {
println!("\npublic tree {}", r.tree);
println!("commit {}", r.commit);
println!("branch public");
println!("\n kept {} path(s)", r.kept);
println!(" excluded {} path(s):", r.excluded_total());
for (why, n) in &r.excluded_by_reason {
println!(" {n:>4} {why}");
}
println!(
"\n cargo metadata + check -p gunnar-ui-server + readme_version: {}",
if r.checked { "PASS" } else { "not run" }
);
if r.pushed {
println!(" pushed to github-public (main) and gunnar-rs (main)");
} else {
println!(" NOT pushed: `cargo xtask publish --push` does that");
}
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("xtask publish: {e}");
ExitCode::FAILURE
}
}
}
/// `cargo xtask build [--variant <name>] [--list]`; see [`xtask::build`].
///
/// The declaration is `gunnar-ui-recipe`'s. This function knows the name of no
/// package, no feature and no triple — it resolves rows through the
/// constellation's shared `suite_cli` parser and hands each one to the shared
/// builder. There is no second place a build point of this repository is
/// defined.
fn task_build() -> ExitCode {
let argv: Vec<String> = std::env::args().skip(2).collect();
match xtask::build::run(&xtask::workspace_root(), &argv) {
Ok(names) => {
println!("\n{} variant(s): {}", names.len(), names.join(" · "));
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("xtask build: {e}");
ExitCode::FAILURE
}
}
}
/// Every task's one-line usage, so a typo and a bare invocation print the same
/// list and neither can go stale on its own.
const USAGE: &str = "usage: cargo xtask build [--variant <name>] [--list]
cargo xtask publish [--push]
cargo xtask ci [--quick|--nightly] [--list] [--only <prefix>]
cargo xtask browser [<test filter>]
cargo xtask console";
/// `cargo xtask ci`: gunnar-ui's CI plan on the shared runner (nornir-xtask-support
/// `ci`): its locks, its tests, the wasm console checked for its target, and (full)
/// its declared rows. One JSON receipt per run.
fn task_ci() -> ExitCode {
let args: Vec<String> = std::env::args().skip(2).collect();
// the quick set's lean profile (QUICK_ENV) on every step: it fits the forge's 40 GB target
let plan = xtask::ci::for_mode(xtask::ci::plan(&xtask::workspace_root()), nornir_xtask_support::ci::Mode::from_args(&args));
ExitCode::from(nornir_xtask_support::ci::main(&plan, &args).clamp(0, 255) as u8)
}
/// **`cargo xtask console`** (lane GUN-PRODUCTS, 2026-10-30): the community console bundle
/// (`trunk build --release` in `crates/gunnar-ui-web`, the browser build this repository
/// names), copied into `crates/gunnar-ui/console`, where the product crate `gunnar-ui`
/// builds it in (its build.rs) and a published crate carries it. `$TRUNK` names trunk when
/// it is not on the PATH.
fn task_console() -> ExitCode {
let root = xtask::workspace_root();
let dist = std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| root.join("target")).join("console-dist");
let into = root.join("crates/gunnar-ui/console");
let trunk = std::env::var("TRUNK").unwrap_or_else(|_| "trunk".to_string());
let status = std::process::Command::new(&trunk)
.current_dir(root.join("crates/gunnar-ui-web"))
.args(["build", "--release", "--dist"])
.arg(&dist)
.status();
match status {
Ok(s) if s.success() => {}
Ok(s) => {
eprintln!("xtask console: {trunk} build exited {s}");
return ExitCode::FAILURE;
}
Err(e) => {
eprintln!("xtask console: could not start {trunk} ({e}): the console is a WebAssembly bundle and trunk builds it (`cargo install trunk --locked`)");
return ExitCode::FAILURE;
}
}
if !dist.join("index.html").is_file() {
eprintln!("xtask console: trunk wrote no index.html into {}", dist.display());
return ExitCode::FAILURE;
}
let _ = std::fs::remove_dir_all(&into);
if let Err(e) = copy_dir(&dist, &into) {
eprintln!("xtask console: copy {} to {}: {e}", dist.display(), into.display());
return ExitCode::FAILURE;
}
println!("the console bundle is in {} (`cargo install --path crates/gunnar-ui` builds it in)", into.display());
ExitCode::SUCCESS
}
fn copy_dir(from: &std::path::Path, to: &std::path::Path) -> std::io::Result<()> {
std::fs::create_dir_all(to)?;
for e in std::fs::read_dir(from)? {
let e = e?;
let p = e.path();
if p.is_dir() {
copy_dir(&p, &to.join(e.file_name()))?;
} else {
std::fs::copy(&p, to.join(e.file_name()))?;
}
}
Ok(())
}
/// **`cargo xtask browser`** (RA2-7): gunnar-ui's browser tests, the A29-20 first touch
/// (`gunnar-ui-server`'s `passkeys_at_gunnar::first_touch`, metered), in the browser image: the
/// toolchain image plus Debian's Chromium (`browser/Containerfile`), built once per content of
/// that file ON its base image and tagged by both (GHUNT-17). `GUNNAR_UI_NEEDS_BROWSER=1` makes a
/// missing Chromium red.
/// Inside a container (the CI runner's, or `NORNIR_CI_HOST=1`) the tests run right here and
/// `$CHROME` must name a Chromium.
fn task_browser(filter: Option<String>) -> ExitCode {
use std::process::Command;
let root = xtask::workspace_root();
let runs = xtask::ci::browser_runs(filter.as_deref());
let mut env = vec![("GUNNAR_UI_NEEDS_BROWSER".to_string(), "1".to_string())];
// GUN-GUEST: the console's built bundle for the deep-link test, the one named or the
// recipe's `dist-metered` where `cargo xtask build` left one (inside the workspace, so the
// image sees it too).
let bundle = std::env::var("GUNNAR_UI_BUNDLE").ok().filter(|b| !b.is_empty()).or_else(|| {
let built = root.join("crates/gunnar-ui-web/dist-metered");
built.join("index.html").is_file().then(|| built.display().to_string())
});
if let Some(bundle) = bundle {
env.push(("GUNNAR_UI_BUNDLE".to_string(), bundle));
}
let run = |mut c: Command, what: &str| -> ExitCode {
match c.status() {
Ok(s) if s.success() => ExitCode::SUCCESS,
Ok(s) => {
eprintln!("xtask browser: {what} exited {s}");
ExitCode::FAILURE
}
Err(e) => {
eprintln!("xtask browser: could not start {what}: {e}");
ExitCode::FAILURE
}
}
};
if nornir_xtask_support::ci::toolchain_is_here() {
for args in &runs {
let mut c = Command::new("cargo");
c.args(args).envs(env.iter().map(|(k, v)| (k, v))).current_dir(&root);
let ran = run(c, "the browser tests");
if ran != ExitCode::SUCCESS {
return ran;
}
}
return ExitCode::SUCCESS;
}
let mut image = nornir_xtask_support::ci::Container::from_env(&root);
let file = root.join("browser/Containerfile");
let recipe = match std::fs::read(&file) {
Ok(r) => r,
Err(e) => {
eprintln!("xtask browser: read {}: {e}", file.display());
return ExitCode::FAILURE;
}
};
// GHUNT-17: the tag names the base image's id too, so a moved base builds a new image.
let base = Command::new(&image.engine)
.args(["image", "inspect", "--format", "{{.Id}}", &image.image])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned())
.filter(|id| !id.is_empty())
.unwrap_or_else(|| image.image.clone());
let tag = format!("localhost/gunnar-ui-browser:{}", xtask::ci::image_tag(&recipe, &base));
let present = Command::new(&image.engine).args(["image", "exists", &tag]).status().is_ok_and(|s| s.success());
if !present {
let mut c = Command::new(&image.engine);
c.args(["build", "-t", &tag, "--build-arg", &format!("BASE={}", image.image), "-f"]).arg(&file).arg(root.join("browser"));
let built = run(c, "the browser image's build");
if built != ExitCode::SUCCESS {
return built;
}
}
image.image = tag;
if let Err(e) = std::fs::create_dir_all(&image.target) {
eprintln!("xtask browser: create {}: {e}", image.target.display());
return ExitCode::FAILURE;
}
for args in &runs {
let argv = image.argv(None, &env, "cargo", args);
let mut c = Command::new(&argv[0]);
c.args(&argv[1..]);
let ran = run(c, "the browser tests in their image");
if ran != ExitCode::SUCCESS {
return ran;
}
}
ExitCode::SUCCESS
}
fn main() -> ExitCode {
match std::env::args().nth(1).as_deref() {
Some("build") => task_build(),
Some("publish") => task_publish(),
Some("ci") => task_ci(),
Some("browser") => task_browser(std::env::args().nth(2)),
Some("console") => task_console(),
Some(other) => {
eprintln!("xtask: unknown task `{other}`");
eprintln!("{USAGE}");
ExitCode::FAILURE
}
None => {
eprintln!("{USAGE}");
ExitCode::FAILURE
}
}
}