crates/gunnar-cli/tests/plain_cargo.rs
//! **The two plain-cargo builds, guarded from the resolved graph.**
//!
//! Owner order 2026-10-12: the `gunnar` client and the core `gunnar-server`
//! must each build with an ordinary `cargo build` / `cargo install`, as well as
//! be cells of the Build Thing (`.nornir/build-variants.toml`). The cells pass
//! exactly these cargo arguments, so guarding the cargo graph guards both:
//!
//! ```text
//! cargo build --release -p gunnar-cli --no-default-features --features client → gunnar
//! cargo build --release -p gunnar-server → gunnar-server
//! ```
//!
//! Three things are held here, each from `cargo tree -e normal` (what a shipped
//! binary links; dev- and build-dependencies never reach it):
//!
//! 1. the client stays SMALL — a crate-count ceiling just above what it measured
//! after the 2026-10-12 cut (see `crates/gunnar-cli/README.md`, "Size");
//! 2. the client links NO server crate (store, server, wire, ssh server, gRPC,
//! monetize, UI);
//! 3. core `gunnar-server` (its default features) links no monetize, no UI and
//! no shop.
//!
//! Every "absent" assertion has a non-vacuity partner: the graph must contain
//! the crate that makes it a real graph, because an empty `cargo tree` output
//! contains no forbidden crate either.
use std::collections::BTreeSet;
use std::process::Command;
/// Ceiling on the `gunnar` client's distinct normal-dependency packages
/// (name + version), counted as [`packages`] counts them.
///
/// MEASURED 2026-10-12: 442 before the cut, 389 after (nornir-git asked for gix
/// with its default features and the reqwest/rustls https transport the client
/// never reaches). Raise it only together with the README's "Size" table, and
/// say there what came in and why.
const CLIENT_CEILING: usize = 395;
fn tree(package: &str, extra: &[&str]) -> String {
let manifest = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml");
let out = Command::new(env!("CARGO"))
.args(["tree", "--manifest-path", manifest, "-p", package])
.args(extra)
.args(["-e", "normal", "--prefix", "none"])
.output()
.expect("cargo tree runs");
assert!(
out.status.success(),
"cargo tree failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let text = String::from_utf8_lossy(&out.stdout).into_owned();
assert!(
text.lines().any(|l| l.starts_with(&format!("{package} v"))),
"cargo tree printed no {package} at all, so every 'not found' below would be vacuous:\n{text}"
);
text
}
/// Distinct `name version` pairs in a `--prefix none` tree.
fn packages(tree: &str) -> BTreeSet<String> {
tree.lines()
.filter_map(|l| {
let mut it = l.split_whitespace();
Some(format!("{} {}", it.next()?, it.next()?))
})
.collect()
}
fn names(tree: &str) -> BTreeSet<String> {
packages(tree)
.into_iter()
.map(|p| p.split(' ').next().unwrap().to_string())
.collect()
}
fn client() -> String {
tree(
"gunnar-cli",
&["--no-default-features", "--features", "client"],
)
}
#[test]
fn the_client_stays_under_its_crate_ceiling() {
let all = packages(&client());
assert!(
all.len() > 200,
"only {} packages: this is not the client graph, the count below would mean nothing",
all.len()
);
assert!(
all.len() <= CLIENT_CEILING,
"the `gunnar` client links {} packages, past its ceiling of {CLIENT_CEILING}. Something \
came in; find it with `cargo tree -p gunnar-cli --no-default-features --features client \
-e normal -i <crate>`, and either cut it or raise the ceiling with the README's Size \
table. Packages:\n{}",
all.len(),
all.into_iter().collect::<Vec<_>>().join("\n")
);
}
#[test]
fn the_client_links_no_server_crate() {
let names = names(&client());
// Non-vacuity: this IS the client — git, the transport, the crypto.
for needed in [
"gunnar-client",
"gunnar-crypt",
"nornir-git",
"russh",
"gix",
"age",
] {
assert!(
names.contains(needed),
"the client graph lacks {needed}: {names:?}"
);
}
let forbidden: Vec<&String> = names
.iter()
.filter(|n| {
matches!(
n.as_str(),
"gunnar-server"
| "gunnar-store"
| "gunnar-core"
| "gunnar-wire"
| "gunnar-ssh"
| "gunnar-grpc"
| "gunnar-http"
| "gunnar-front"
| "gunnar-policy"
| "gunnar-pairing-door"
| "gunnar-monetize-guest"
| "tonic"
// the https transport nornir-git used to drag in unasked
| "reqwest"
| "hyper"
| "rustls"
) || n.starts_with("monetize")
|| n.starts_with("gunnar-ui")
|| n.starts_with("znippy")
|| n.starts_with("storage-git")
})
.collect();
assert!(
forbidden.is_empty(),
"the `gunnar` client links server-side or unneeded crates: {forbidden:?}"
);
}
#[test]
fn core_gunnar_server_links_no_monetize_ui_or_shop() {
let names = names(&tree("gunnar-server", &[]));
// Non-vacuity: a git server with its storage engine and SSH door.
for needed in ["gunnar-store", "gunnar-ssh", "gunnar-wire"] {
assert!(
names.contains(needed),
"core gunnar-server lacks {needed}: {names:?}"
);
}
let forbidden: Vec<&String> = names
.iter()
.filter(|n| {
n.starts_with("monetize")
|| n.starts_with("gunnar-ui")
|| n.contains("shop")
|| matches!(
n.as_str(),
"gunnar-front" | "gunnar-monetize-guest" | "plugin-door-wire"
)
})
.collect();
assert!(
forbidden.is_empty(),
"`cargo build -p gunnar-server` (default features) links money, UI or shop crates: \
{forbidden:?}. Those belong to gunnar-front / the `metered` feature, never to the core \
server."
);
}