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