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

nordisk/gunnar

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