xtask/src/products.rs
//! **`cargo xtask products`**: the by-hand guide "Gunnar by hand" (`.nornir/README.md`), run
//! as written (lane GUN-PRODUCTS, owner 2026-10-30; Holger's `cargo xtask products` is the
//! model).
//!
//! The guide is the test: its `bash` blocks between `<!-- gunnar-by-hand:begin -->` and
//! `<!-- gunnar-by-hand:end -->` run, in order, in one shell. Three things differ from a
//! person at a keyboard, and only these:
//!
//! * `cargo install <crate> [--features f]` installs the product built from THIS checkout
//! (gunnar's own crates, the console's from the gunnar-ui checkout beside it), with the
//! features the line names, instead of crates.io. Every build runs in the pinned toolchain
//! container, one `cargo build -p <crate>` each, so no feature of one product unifies into
//! another.
//! * `xdg-open <first-login link>` is a headless Chromium with a virtual authenticator
//! (gunnar-ui's `gunnar-uitest --first-login`): it opens the link, registers a passkey,
//! signs in with it and asks the console who it is (the administrator `alice`).
//! * The places, the ports and `HOME` come from the environment (`G` and `HOME` fresh
//! directories, free ports): the guide's first block takes them from there, and makes
//! the SSH key in that `HOME`.
//!
//! Green when the script ends with `GUIDE-DONE`. A host step of the full CI set (it builds
//! in the toolchain container, starts three servers and a browser); never in `--quick`. It
//! writes `target/products/guide.log`. `--no-console` skips the console bundle (trunk): the
//! sign-in pages are the console server's own, so the guide still runs whole.
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Instant;
/// The guide's markers in `.nornir/README.md`.
pub const BEGIN: &str = "<!-- gunnar-by-hand:begin -->";
pub const END: &str = "<!-- gunnar-by-hand:end -->";
/// The three products and the workspace each crate builds in, from gunnar's root (the
/// console is the gunnar-ui checkout's).
pub const PRODUCTS: [(&str, &str); 3] = [
("gunnar", "."),
("gunnar-cli", "."),
("gunnar-ui", "../gunnar-ui"),
];
/// The browser the guide's `xdg-open` drives: gunnar-ui's UI test, a workspace of its own.
pub const UITEST: (&str, &str) = ("gunnar-uitest", "../gunnar-ui/uitest");
/// **The guide's commands**: every ```bash block between the markers, in order.
pub fn guide_blocks(readme: &str) -> Result<Vec<String>, String> {
let start = readme
.find(BEGIN)
.ok_or("the README has no guide \"Gunnar by hand\" (its begin marker)")?;
let end = readme[start..]
.find(END)
.map(|e| start + e)
.ok_or("the guide has no end marker")?;
let mut blocks = Vec::new();
let mut cur: Option<String> = None;
for line in readme[start..end].lines() {
match (&mut cur, line.trim_end()) {
(None, "```bash") => cur = Some(String::new()),
(Some(_), "```") => blocks.push(cur.take().unwrap_or_default()),
(Some(b), l) => {
b.push_str(l);
b.push('\n');
}
(None, _) => {}
}
}
if cur.is_some() {
return Err("the guide's last bash block is not closed".into());
}
if blocks.is_empty() {
return Err("the guide has no bash block".into());
}
Ok(blocks)
}
/// **The builds the guide installs**: `(crate, features)` of every `cargo install` line, the
/// features sorted and comma-joined (empty: the defaults).
pub fn installs(blocks: &[String]) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = Vec::new();
for line in blocks.iter().flat_map(|b| b.lines()) {
let words: Vec<&str> = line.split_whitespace().collect();
if words.len() < 3 || words[0] != "cargo" || words[1] != "install" {
continue;
}
let (mut crates, mut features) = (Vec::new(), Vec::new());
let mut it = words[2..].iter();
while let Some(w) = it.next() {
match *w {
"--features" | "-F" => features.extend(
it.next()
.map(|f| f.split(',').map(String::from).collect::<Vec<_>>())
.unwrap_or_default(),
),
"--root" | "--target" | "--path" | "--git" => {
it.next();
}
w if w.starts_with('-') => {}
w => crates.push(w.to_string()),
}
}
features.sort();
for c in crates {
let e = (c, features.join(","));
if !out.contains(&e) {
out.push(e);
}
}
}
out
}
/// The file a build is kept under in the test's bin directory: `<crate>` for the defaults,
/// `<crate>+<features>` otherwise.
pub fn bin_name(krate: &str, features: &str) -> String {
if features.is_empty() {
krate.to_string()
} else {
format!("{krate}+{features}")
}
}
/// The shell the guide runs in: strict, the three differences above, then the blocks.
pub fn script(blocks: &[String]) -> String {
let mut s = String::from(PRELUDE);
for (i, b) in blocks.iter().enumerate() {
s.push_str(&format!("echo '== guide block {}'\n", i + 1));
s.push_str(b);
s.push('\n');
}
s.push_str("echo GUIDE-DONE\njobs -p | xargs -r kill 2>/dev/null || true\n");
s
}
const PRELUDE: &str = r#"set -Eeuo pipefail
# Only the guide's own shell gives up (a command substitution's subshell inherits the trap,
# and must not stop the servers the guide started).
trap 'if [ "$BASHPID" = "$$" ]; then echo "GUIDE-FAILED at line $LINENO: $BASH_COMMAND" >&2; jobs -p | xargs -r kill 2>/dev/null; exit 1; fi' ERR
# However the shell ends (a stop from outside included), the servers it started stop with it.
trap 'jobs -p | xargs -r kill 2>/dev/null || true' EXIT
trap 'exit 143' TERM INT
# `cargo install <crate> [--features f]`: the build of this checkout's crate the test made
# ($GUIDE_BIN/<crate>[+<features>]), installed into --root/bin or the PATH's first directory.
cargo() {
if [ "$1" = install ]; then
shift; local root="" crates=() feats=""
while [ $# -gt 0 ]; do
case "$1" in --root) root=$2; shift 2;; --features|-F) feats=$(printf '%s' "$2" | tr ',' '\n' | sort | paste -sd, -); shift 2;; --target|--path|--git) shift 2;; -*) shift;; *) crates+=("$1"); shift;; esac
done
for c in "${crates[@]}"; do
local built="$GUIDE_BIN/$c${feats:++$feats}" into="${root:+$root/bin}"
[ -x "$built" ] || { echo "the test built no $built" >&2; return 1; }
mkdir -p "${into:-$GUIDE_PATH}" && cp "$built" "${into:-$GUIDE_PATH}/$c"
echo "(the test) cargo install $c${feats:+ --features $feats}: the crate built from this checkout"
done
return 0
fi
command cargo "$@"
}
# `xdg-open <link>`: the person's browser is a headless Chromium with a virtual authenticator.
xdg-open() {
printf '%s\n' "$1" > "$G/.first-login-link"
echo "(the test) xdg-open: a headless browser registers a passkey and signs in"
"$GUIDE_UITEST" --first-login "$G/.first-login-link" --chrome "$GUIDE_CHROME"
}
"#;
fn target_dir(root: &Path) -> PathBuf {
std::env::var_os("CARGO_TARGET_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| root.join("target"))
}
fn run(cmd: &mut Command) -> Result<(), String> {
let shown = format!("{cmd:?}");
let st = cmd.status().map_err(|e| format!("starting {shown}: {e}"))?;
if !st.success() {
return Err(format!("{shown} exited {st}"));
}
Ok(())
}
/// One `cargo build --release --locked` of `krate` in the toolchain container, in the
/// workspace `dir` (or by `manifest` when the crate is a workspace of its own), into
/// `target`; the built program's path.
fn build_one(
parent: &Path,
dir: &Path,
target: &Path,
krate: &str,
features: &str,
own_workspace: bool,
) -> Result<PathBuf, String> {
let home = PathBuf::from(std::env::var_os("HOME").unwrap_or_default());
let registry = std::env::var_os("CARGO_REGISTRY_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| home.join(".cargo/registry"));
let git = home.join(".cargo/git");
let toolchain = std::env::var("TOOLCHAIN_IMAGE")
.unwrap_or_else(|_| "localhost/nordisk-cargo-toolchain:live".into());
let mut c = Command::new(std::env::var("GUIDE_PODMAN").unwrap_or_else(|_| "podman".into()));
c.args(["run", "--rm", "--init", "--userns=keep-id"])
.arg(format!("--volume={0}:{0}", parent.display()))
.arg(format!("--volume={0}:{0}", target.display()))
.arg(format!(
"--volume={}:/usr/local/cargo/registry",
registry.display()
));
if git.is_dir() {
c.arg(format!("--volume={}:/usr/local/cargo/git", git.display()));
}
c.arg(format!("--env=CARGO_TARGET_DIR={}", target.display()))
.arg("--env=HOME=/tmp");
// The console bundle a build of `gunnar-ui` carries (its build.rs), when one is named.
if let Some(console) = std::env::var_os("GUNNAR_UI_CONSOLE").filter(|v| !v.is_empty()) {
c.arg(format!(
"--env=GUNNAR_UI_CONSOLE={}",
PathBuf::from(console).display()
));
}
if let Ok(jobs) = std::env::var("CARGO_BUILD_JOBS") {
c.arg(format!("--env=CARGO_BUILD_JOBS={jobs}"));
}
c.arg(format!("--workdir={}", dir.display()))
.arg(&toolchain)
.args(["cargo", "build", "--release", "--locked"]);
if own_workspace {
c.args(["--manifest-path", "Cargo.toml"]);
} else {
c.args(["-p", krate]);
}
if !features.is_empty() {
c.args(["--features", features]);
}
run(&mut c)?;
let out = target.join("release").join(krate);
if !out.is_file() {
return Err(format!("{} was not built", out.display()));
}
Ok(out)
}
/// The Chromium the browser step drives: `$CHROME`, else the first on the PATH.
fn chrome() -> Result<String, String> {
if let Ok(c) = std::env::var("CHROME") {
if !c.is_empty() {
return Ok(c);
}
}
for c in ["chromium", "chromium-browser", "google-chrome"] {
if Command::new("sh")
.args(["-c", &format!("command -v {c} > /dev/null")])
.status()
.is_ok_and(|s| s.success())
{
return Ok(c.to_string());
}
}
Err("no Chromium for the guide's passkey step: set CHROME, or install chromium".into())
}
/// A free loopback port.
fn free_port() -> Result<u16, String> {
std::net::TcpListener::bind("127.0.0.1:0")
.and_then(|l| l.local_addr())
.map(|a| a.port())
.map_err(|e| e.to_string())
}
/// The verb. True when the guide ran to its end.
pub fn main(root: &Path, args: &[String]) -> Result<(), String> {
let started = Instant::now();
let ui_root = root.join("../gunnar-ui").canonicalize().map_err(|e| format!("gunnar-ui is not checked out beside gunnar (../gunnar-ui): the console is one of the three products ({e})"))?;
let parent = root.parent().unwrap_or(root).to_path_buf();
let target = target_dir(root).join("products");
let bin = target.join("bin");
std::fs::create_dir_all(&bin).map_err(|e| e.to_string())?;
let readme =
std::fs::read_to_string(root.join(".nornir/README.md")).map_err(|e| e.to_string())?;
let blocks = guide_blocks(&readme)?;
let chrome = chrome()?;
// The console's bundle (trunk, gunnar-ui's `cargo xtask console`, on the host) into the
// crate, so `cargo install gunnar-ui` carries it as a published crate does.
if !args.iter().any(|a| a == "--no-console") && std::env::var_os("GUNNAR_UI_CONSOLE").is_none()
{
run(
Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
.current_dir(&ui_root)
.args(["xtask", "console"]),
)?;
}
// Each build the guide installs on its own, so no feature of one unifies into another.
for (krate, features) in installs(&blocks) {
let at = PRODUCTS
.iter()
.find(|(p, _)| *p == krate)
.map(|(_, d)| *d)
.ok_or_else(|| {
format!("the guide installs `{krate}`, which is not one of the three products")
})?;
let in_ui = at.starts_with("../");
let dir = if in_ui {
ui_root.clone()
} else {
root.join(at)
};
let t = if in_ui {
target.join("ui")
} else {
target.clone()
};
std::fs::create_dir_all(&t).map_err(|e| e.to_string())?;
let built = build_one(&parent, &dir, &t, &krate, &features, false)?;
std::fs::copy(&built, bin.join(bin_name(&krate, &features))).map_err(|e| e.to_string())?;
}
// The browser step's driver.
let uitest_dir = root
.join(UITEST.1)
.canonicalize()
.map_err(|e| format!("{}: {e}", UITEST.1))?;
let t = target.join("uitest");
std::fs::create_dir_all(&t).map_err(|e| e.to_string())?;
let uitest = build_one(&parent, &uitest_dir, &t, UITEST.0, "", true)?;
let work = target.join(format!("work-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&work);
let (g, home, path_bin) = (work.join("guide"), work.join("home"), work.join("path-bin"));
for d in [&home, &path_bin] {
std::fs::create_dir_all(d).map_err(|e| e.to_string())?;
}
let script_path = work.join("guide.sh");
std::fs::write(&script_path, script(&blocks)).map_err(|e| e.to_string())?;
let log = target.join("guide.log");
let log_file = std::fs::File::create(&log).map_err(|e| e.to_string())?;
let mut c = Command::new("bash");
c.arg(&script_path)
.current_dir(&work)
.env("G", &g)
.env("HOME", &home)
.env("GUIDE_BIN", &bin)
.env("GUIDE_PATH", &path_bin)
.env("GUIDE_UITEST", &uitest)
.env("GUIDE_CHROME", &chrome)
// Nothing of Gunnar's is on the PATH until the guide's `cargo install` puts it there.
.env(
"PATH",
format!(
"{}:{}",
path_bin.display(),
std::env::var("PATH").unwrap_or_default()
),
)
.env_remove("SSH_AUTH_SOCK")
.env_remove("GUNNAR_CONTROL_ADDR")
.env_remove("XDG_CONFIG_HOME")
.env("GIT_CONFIG_NOSYSTEM", "1")
.stdout(log_file.try_clone().map_err(|e| e.to_string())?)
.stderr(log_file);
for var in ["SSH", "HTTP", "CTRL", "TWIN_SSH", "TWIN_HTTP", "TWIN_CTRL"] {
c.env(var, format!("127.0.0.1:{}", free_port()?));
}
c.env("UI", free_port()?.to_string());
let st = c.status().map_err(|e| format!("run the guide: {e}"))?;
let text = std::fs::read_to_string(&log).unwrap_or_default();
let done = st.success() && text.lines().any(|l| l.trim() == "GUIDE-DONE");
let secs = started.elapsed().as_secs();
if !done {
let tail: Vec<&str> = text
.lines()
.rev()
.take(40)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
return Err(format!(
"the guide \"Gunnar by hand\" went red after {secs} s ({}); its last lines:\n{}",
log.display(),
tail.join("\n")
));
}
let _ = std::fs::remove_dir_all(&work);
eprintln!(
"products: the guide \"Gunnar by hand\" ran green in {secs} s ({})",
log.display()
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn readme() -> String {
std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("../.nornir/README.md"))
.expect(".nornir/README.md")
}
#[test]
fn the_guide_parses_into_its_blocks_and_installs_the_three_products() {
let blocks = guide_blocks(&readme()).expect("the guide has blocks");
assert!(blocks.len() >= 8, "{} blocks", blocks.len());
let crates: Vec<String> = installs(&blocks).into_iter().map(|(c, _)| c).collect();
for p in PRODUCTS {
assert!(
crates.iter().any(|c| c == p.0),
"the guide does not install `{}`: {crates:?}",
p.0
);
}
let s = script(&blocks);
assert!(
s.starts_with("set -Eeuo pipefail")
&& s.contains("xdg-open()")
&& s.trim_end().ends_with("|| true")
);
}
#[test]
fn an_install_line_names_its_crates_and_its_sorted_features() {
let b = vec!["cargo install gunnar gunnar-cli\ncargo install --root x gunnar --features online,engine-gix\n".to_string()];
assert_eq!(
installs(&b),
[
("gunnar".to_string(), String::new()),
("gunnar-cli".to_string(), String::new()),
("gunnar".to_string(), "engine-gix,online".to_string())
]
);
assert_eq!(bin_name("gunnar", ""), "gunnar");
assert_eq!(
bin_name("gunnar", "engine-gix,online"),
"gunnar+engine-gix,online"
);
}
#[test]
fn an_unclosed_or_missing_guide_is_refused() {
assert!(guide_blocks("no guide here").is_err());
assert!(guide_blocks(&format!("{BEGIN}\n```bash\necho\n{END}")).is_err());
assert!(guide_blocks(&format!("{BEGIN}\nprose only\n{END}")).is_err());
}
}