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

nordisk/gunnar

xtask/tests/the_three_products.rs

//! **Gunnar's three products, one crate each** (lane GUN-PRODUCTS, owner 2026-10-30:
//! Gunnar gets the product shape Holger got).
//!
//! * `gunnar` is the server, `gunnar-ui` the console (in the gunnar-ui repository),
//!   `gunnar-cli` the command-line tool. Each `cargo install <crate>` installs exactly one
//!   program of the crate's own name, and no two products install the same file: the
//!   client's old `gunnar` bin collided with the server's.
//! * The named extras are features on every product (`public-site`, `mcp`, `engine-gix`,
//!   `online`, `federation`, and `full` = all of them); the default is AIRGAP and holds none.
//! * The Build Thing's one suite is MVP: exactly the three products and the estate's builds
//!   (appliance, metered, front, media); `cargo xtask build --suite MVP --list` prints it.
//! * `.nornir/README.md` carries the guide "Gunnar by hand", and `cargo xtask products`
//!   (a step of the full `cargo xtask ci`, not of `--quick`) runs exactly its commands.

use std::collections::BTreeSet;
use std::path::PathBuf;
use std::process::Command;

use xtask::json::Json;

fn root() -> PathBuf {
    std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .expect("xtask has a parent")
        .to_path_buf()
}

fn text(rel: &str) -> String {
    std::fs::read_to_string(root().join(rel)).unwrap_or_else(|e| panic!("{rel}: {e}"))
}

/// The products, in the order the docs name them.
const PRODUCTS: [&str; 3] = ["gunnar", "gunnar-ui", "gunnar-cli"];

/// The named extras every product carries.
const EXTRAS: [&str; 5] = ["public-site", "mcp", "engine-gix", "online", "federation"];

/// **The MVP suite**, in declaration order: the products, then the estate's builds.
const MVP: &[&str] = &[
    // the products, each its `cargo install` line
    "gunnar",
    "gunnar-ui",
    "gunnar-cli",
    // the appliance (main and twin boot one medium)
    "gunnar-server",
    "gunnar-server-narrate",
    "gunnar-server-thin",
    // the metered (sold) appliance
    "gunnar-server-metered",
    "gunnar-server-metered-narrate",
    "gunnar-server-metered-thin",
    // the front: vm1's program and the command-line tool it carries and serves
    "gunnar-cli-musl",
    "gunnar-cli-musl-metered",
    "gunnar-front",
    "gunnar-front-narrate",
    "gunnar-front-metered",
    "gunnar-front-metered-narrate",
    // the media
    "gunnar-image",
    "gunnar-appliance-iso",
    "gunnar-installer",
    "gunnar-installer-open",
    "gunnar-front-installer-open",
    "gunnar-front-installer-metered",
    "gunnar-installer-gce",
    "gunnar-installer-aws",
    "gunnar-installer-azure",
    "gunnar-installer-metered",
    "gunnar-installer-metered-aws",
    "gunnar-installer-metered-azure",
];

/// One workspace package as `cargo metadata` resolves it.
struct Package {
    publish: bool,
    /// `(bin name, required-features)`.
    bins: Vec<(String, Vec<String>)>,
    /// `feature -> what it turns on`.
    features: std::collections::BTreeMap<String, Vec<String>>,
}

fn package(name: &str) -> Package {
    let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
    let out = Command::new(cargo)
        .args(["metadata", "--no-deps", "--format-version", "1"])
        .current_dir(root())
        .output()
        .expect("run `cargo metadata`");
    assert!(
        out.status.success(),
        "cargo metadata failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let json = Json::parse(&String::from_utf8(out.stdout).expect("utf-8")).expect("json");
    let p = json
        .get("packages")
        .expect("packages")
        .arr()
        .iter()
        .find(|p| p.get("name").and_then(Json::str) == Some(name))
        .unwrap_or_else(|| panic!("no workspace package `{name}`"))
        .clone();
    let strings = |j: &Json| {
        j.arr()
            .iter()
            .filter_map(|s| s.str().map(str::to_string))
            .collect::<Vec<_>>()
    };
    let bins = p
        .get("targets")
        .expect("targets")
        .arr()
        .iter()
        .filter(|t| {
            t.get("kind")
                .map(|k| strings(k).iter().any(|k| k == "bin"))
                .unwrap_or(false)
        })
        .map(|t| {
            (
                t.get("name")
                    .and_then(Json::str)
                    .unwrap_or_default()
                    .to_string(),
                t.get("required-features").map(&strings).unwrap_or_default(),
            )
        })
        .collect();
    let features = p
        .get("features")
        .and_then(Json::obj)
        .map(|o| o.iter().map(|(k, v)| (k.clone(), strings(v))).collect())
        .unwrap_or_default();
    let publish = matches!(p.get("publish"), None | Some(Json::Null));
    Package {
        publish,
        bins,
        features,
    }
}

/// The features `of` turns on within its own package, transitively.
fn closure(p: &Package, of: &[String]) -> BTreeSet<String> {
    let mut seen = BTreeSet::new();
    let mut todo: Vec<String> = of.to_vec();
    while let Some(f) = todo.pop() {
        if p.features.contains_key(&f) && seen.insert(f.clone()) {
            todo.extend(p.features[&f].iter().cloned());
        }
    }
    seen
}

/// The programs `cargo install <crate>` (the defaults) leaves in `~/.cargo/bin`.
fn installed(p: &Package) -> Vec<String> {
    let on = closure(p, &["default".to_string()]);
    p.bins
        .iter()
        .filter(|(_, req)| req.iter().all(|f| on.contains(f)))
        .map(|(b, _)| b.clone())
        .collect()
}

#[test]
fn each_product_installs_one_program_of_its_own_name() {
    for name in ["gunnar", "gunnar-cli"] {
        let p = package(name);
        assert!(
            p.publish,
            "`{name}` may not be published, and it is a product on crates.io"
        );
        assert_eq!(
            installed(&p),
            [name],
            "`cargo install {name}` must install exactly `{name}`"
        );
    }
    let cli = package("gunnar-cli");
    let bins: Vec<&str> = cli.bins.iter().map(|(b, _)| b.as_str()).collect();
    assert!(
        !bins.contains(&"gunnar"),
        "gunnar-cli has a `gunnar` bin: it lands on the server's `gunnar` ({bins:?})"
    );
    for retired in ["gunnar-solo", "gunnar-service"] {
        assert!(!bins.contains(&retired), "the retired bin `{retired}` is still in gunnar-cli: it is `gunnar-cli` with features now");
    }
}

#[test]
fn each_product_carries_the_named_extras_over_an_airgap_default() {
    for name in ["gunnar", "gunnar-cli"] {
        let p = package(name);
        let default = closure(&p, &["default".to_string()]);
        for extra in EXTRAS.iter().chain(&["full"]) {
            assert!(
                p.features.contains_key(*extra),
                "`{name}` has no named extra `{extra}`"
            );
            assert!(
                !default.contains(*extra),
                "`{name}`'s default turns on `{extra}`: the default is AIRGAP"
            );
        }
        let full = closure(&p, &["full".to_string()]);
        for extra in EXTRAS {
            assert!(
                full.contains(extra),
                "`{name}`'s `full` does not turn on `{extra}`"
            );
        }
        assert!(
            !full.contains("metered"),
            "`{name}`'s `full` turns on the paid half: that is the estate's, never an extra"
        );
    }
}

#[test]
fn the_mvp_suite_is_the_products_and_the_estates_builds() {
    let listed = xtask::build::run(
        &root(),
        &[
            "--suite".to_string(),
            "MVP".to_string(),
            "--list".to_string(),
        ],
    )
    .expect("`cargo xtask build --suite MVP --list` resolves");
    assert_eq!(listed, MVP, "`cargo xtask build --suite MVP --list`");
    let all =
        xtask::build::run(&root(), &["--list".to_string()]).expect("`cargo xtask build --list`");
    assert_eq!(
        all, MVP,
        "the MVP suite is every row: the leftover rows are retired"
    );
    for (i, product) in PRODUCTS.iter().enumerate() {
        assert_eq!(
            listed[i], *product,
            "the products come first, in the order the docs name them"
        );
    }
}

/// The guide's markers in `.nornir/README.md`.
const BEGIN: &str = "<!-- gunnar-by-hand:begin -->";
const END: &str = "<!-- gunnar-by-hand:end -->";

#[test]
fn the_readme_carries_the_guide_gunnar_by_hand() {
    for rel in [".nornir/README.md", "README.md"] {
        let readme = text(rel);
        assert!(
            readme.contains("## Gunnar by hand"),
            "{rel} has no guide \"Gunnar by hand\""
        );
        let start = readme
            .find(BEGIN)
            .unwrap_or_else(|| panic!("{rel}: no {BEGIN}"));
        let end = readme[start..]
            .find(END)
            .unwrap_or_else(|| panic!("{rel}: no {END}"))
            + start;
        let guide = &readme[start..end];
        let installs: BTreeSet<&str> = guide
            .lines()
            .filter_map(|l| l.trim().strip_prefix("cargo install "))
            .flat_map(|rest| rest.split_whitespace().take_while(|w| !w.starts_with('-')))
            .collect();
        assert_eq!(
            installs,
            BTreeSet::from(["gunnar", "gunnar-ui", "gunnar-cli"]),
            "{rel}: the guide installs the three products and nothing else"
        );
        for step in ["passkey", "git push", "crypt init", "replication"] {
            assert!(
                guide.contains(step),
                "{rel}: the guide has no `{step}` step"
            );
        }
    }
}

#[test]
fn the_guide_runs_in_the_full_ci_set_and_not_in_quick() {
    let main = text("xtask/src/main.rs");
    assert!(
        main.contains(r#"ci_step(&mut ran, "products")"#),
        "`cargo xtask ci` has no `products` step"
    );
    assert!(
        main.contains(r#"Some("products") =>"#),
        "there is no `cargo xtask products` verb"
    );
    assert!(
        !xtask::ci_quick::ROWS.contains(&"products"),
        "`--quick` must not run the guide (it builds three products and starts servers)"
    );
}