xtask/src/twins.rs
//! **`cargo xtask twins`: every Build Thing cargo row resolves exactly what its `cargo
//! install` line resolves, and leaves the row's binary** (owner order 2026-10-26,
//! G-BOTH-2; a step of `cargo xtask ci`).
//!
//! `gunnar_recipe::twins` holds each row to its line by its parameters; this measures
//! it. For every [`gunnar_recipe::twins::TWINS`] entry:
//!
//! * the row's own argv, as the Build Thing materialises it
//! (`BuildVariant::cargo_args`, the forge's one writer), is turned into `cargo tree`
//! (the subcommand swapped, `--bin` and `--profile` dropped: neither changes a
//! resolution), and so is the line (`--path <dir>` is that package in that dir's
//! workspace, which is what `cargo install --path` builds);
//! * both listings must name the SAME crates (name and version), so the same count;
//! * the line must leave exactly the row's binary: the bins of the package whose
//! `required-features` the build has (from `cargo metadata`), or the one `--bin`.
use std::collections::BTreeSet;
use std::path::Path;
use std::process::Command;
use gunnar_recipe::twins::{
point_of_install, point_of_row, twin_differences, Manifest, Point, TWINS,
};
use gunnar_recipe::variants::by_name;
use crate::json::Json;
/// A Build Thing argv (`build -p … --bin … --features … --profile … --target …
/// [--manifest-path …]`) as the `cargo tree` that resolves the same thing.
pub fn tree_args_of_build(build: &[String]) -> Vec<String> {
let mut out = vec!["tree".to_string()];
let mut it = build.iter().skip(1);
while let Some(a) = it.next() {
match a.as_str() {
"--bin" | "--profile" | "--example" => {
it.next();
}
"--lib" => {}
_ => out.push(a.clone()),
}
}
out.extend(TREE_TAIL.iter().map(|s| (*s).to_string()));
out
}
/// The tail every measured `cargo tree` carries: the committed lock (`--locked`, as the twin
/// line installs), and what a build compiles, once per crate.
pub const TREE_TAIL: &[&str] = &[
"--locked",
"-e",
"normal,build",
"--prefix",
"none",
"--format",
"{p}",
];
/// A line's [`Point`] as the `cargo tree` that resolves what `cargo install --path`
/// builds: that package, in the workspace its directory belongs to.
pub fn tree_args_of_line(p: &Point, package: &str) -> Vec<String> {
let mut out = vec![
"tree".to_string(),
"--manifest-path".to_string(),
format!("{}/Cargo.toml", p.path.trim_end_matches('/')),
"-p".to_string(),
package.to_string(),
];
if !p.features.is_empty() {
out.push("--features".to_string());
out.push(p.features.iter().cloned().collect::<Vec<_>>().join(","));
}
if p.no_default_features {
out.push("--no-default-features".to_string());
}
if let Some(t) = &p.target {
out.push("--target".to_string());
out.push(t.clone());
}
out.extend(TREE_TAIL.iter().map(|s| (*s).to_string()));
out
}
/// The crates of a `--format {p}` listing as `name version`, once each (the `(*)`
/// repeats, `(proc-macro)` marks and paths aside).
pub fn crates_of(listing: &str) -> BTreeSet<String> {
listing
.lines()
.filter_map(|l| {
let mut w = l.split_whitespace();
let name = w.next()?;
let version = w.next()?;
version
.starts_with('v')
.then(|| format!("{name} {version}"))
})
.collect()
}
/// A package's [`Manifest`] facts from `cargo metadata --no-deps` JSON.
pub fn manifest_of(metadata: &Json, package: &str) -> Option<Manifest> {
let p = metadata
.get("packages")?
.arr()
.iter()
.find(|p| p.get("name").and_then(Json::str) == Some(package))?;
let strs = |v: Option<&Json>| -> Vec<String> {
v.map(|a| {
a.arr()
.iter()
.filter_map(|x| x.str().map(str::to_string))
.collect()
})
.unwrap_or_default()
};
let mut m = Manifest::default();
if let Some(features) = p.get("features").and_then(Json::obj) {
for (k, v) in features {
let local: Vec<String> = strs(Some(v))
.into_iter()
.filter(|f| !f.contains(':') && !f.contains('/'))
.collect();
if k == "default" {
m.default_features = local.into_iter().collect();
} else {
m.features.push((k.clone(), local));
}
}
}
for t in p.get("targets").map(Json::arr).unwrap_or(&[]) {
if strs(t.get("kind")).iter().any(|k| k == "bin") {
let name = t
.get("name")
.and_then(Json::str)
.unwrap_or_default()
.to_string();
m.bins.push((name, strs(t.get("required-features"))));
}
}
Some(m)
}
fn cargo_out(root: &Path, cargo: &str, args: &[String]) -> Result<String, String> {
let out = Command::new(cargo)
.current_dir(root)
.args(args)
.output()
.map_err(|e| format!("{cargo}: {e}"))?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
} else {
Err(format!(
"cargo {}: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr)
.lines()
.filter(|l| l.starts_with("error"))
.take(4)
.collect::<Vec<_>>()
.join(" / ")
))
}
}
/// One measured twin.
#[derive(Debug, Clone)]
pub struct Measured {
pub row: &'static str,
pub crates: usize,
pub bin: String,
}
/// Measure every twin. `Err` lists every row that is not its line.
pub fn measure(root: &Path, cargo: &str) -> Result<Vec<Measured>, Vec<String>> {
let mut bad = Vec::new();
let mut good = Vec::new();
for t in TWINS {
let Some(v) = by_name(t.row) else {
bad.push(format!("{}: no such row", t.row));
continue;
};
let (row, line) = match (point_of_row(v), point_of_install(t.install)) {
(Ok(r), Ok(l)) => (r, l),
(Err(e), _) | (_, Err(e)) => {
bad.push(format!("{}: {e}", t.row));
continue;
}
};
let package = v.package.unwrap_or_default();
let meta = cargo_out(
root,
cargo,
&[
"metadata".into(),
"--no-deps".into(),
"--format-version".into(),
"1".into(),
"--manifest-path".into(),
format!("{}/Cargo.toml", line.path),
],
)
.and_then(|s| Json::parse(&s));
let Some(manifest) = meta.as_ref().ok().and_then(|m| manifest_of(m, package)) else {
bad.push(format!(
"{}: cannot read {package}'s manifest ({})",
t.row,
meta.err().unwrap_or_default()
));
continue;
};
for d in twin_differences(&row, &line, &manifest) {
bad.push(format!("{} (`{}`): {d}", t.row, t.install));
}
let by_row = cargo_out(
root,
cargo,
&tree_args_of_build(&v.to_variant().cargo_args()),
);
let by_line = cargo_out(root, cargo, &tree_args_of_line(&line, package));
match (by_row, by_line) {
(Ok(a), Ok(b)) => {
let (a, b) = (crates_of(&a), crates_of(&b));
if a != b {
let only_row: Vec<&String> = a.difference(&b).take(6).collect();
let only_line: Vec<&String> = b.difference(&a).take(6).collect();
bad.push(format!("{}: the row resolves {} crates and the line {} (only the row: {only_row:?}; only the line: {only_line:?})", t.row, a.len(), b.len()));
} else {
good.push(Measured {
row: t.row,
crates: a.len(),
bin: row.bin.clone().unwrap_or_default(),
});
}
}
(Err(e), _) | (_, Err(e)) => bad.push(format!("{}: {e}", t.row)),
}
}
if bad.is_empty() {
Ok(good)
} else {
Err(bad)
}
}
/// The step: print each twin's crate count and binary, or every difference.
pub fn run(root: &Path, cargo: &str) -> bool {
match measure(root, cargo) {
Ok(rows) => {
for m in &rows {
println!(
"twins: {:<30} {:>4} crates both ways, leaves `{}`",
m.row, m.crates, m.bin
);
}
println!(
"twins: {} rows, each the same crates and binary as its `cargo install` line",
rows.len()
);
true
}
Err(bad) => {
for b in &bad {
eprintln!("twins: ✗ {b}");
}
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn s(v: &[&str]) -> Vec<String> {
v.iter().map(|x| (*x).to_string()).collect()
}
#[test]
fn a_build_argv_becomes_the_tree_of_the_same_resolution() {
let build = s(&[
"build",
"-p",
"gunnar-cli",
"--bin",
"gunnar",
"--features",
"client",
"--no-default-features",
"--profile",
"release",
"--target",
"t",
"--manifest-path",
"m/Cargo.toml",
]);
let mut want = s(&[
"tree",
"-p",
"gunnar-cli",
"--features",
"client",
"--no-default-features",
"--target",
"t",
"--manifest-path",
"m/Cargo.toml",
]);
want.extend(s(TREE_TAIL));
assert_eq!(tree_args_of_build(&build), want);
}
/// Both measured trees read the COMMITTED lock (OX-VARIANTS 2026-10-28): the twin is
/// `cargo install --locked`, so the measurement is `--locked` too. Without it `cargo tree`
/// re-resolves and rewrites `Cargo.lock` (measured on oden: `cargo xtask twins` left the
/// root lock modified in a clean checkout of master), and the crates compared are a fresh
/// resolution's, not the ones the documented line builds.
#[test]
fn every_measured_tree_reads_the_committed_lock() {
let build = s(&["build", "-p", "gunnar", "--bin", "gunnar"]);
assert!(
tree_args_of_build(&build).iter().any(|a| a == "--locked"),
"{:?}",
tree_args_of_build(&build)
);
let p = point_of_install("cargo install --locked --path crates/gunnar").unwrap();
let line = tree_args_of_line(&p, "gunnar");
assert!(line.iter().any(|a| a == "--locked"), "{line:?}");
}
#[test]
fn a_line_becomes_the_tree_of_its_package_in_its_directory() {
let p = point_of_install("cargo install --locked --path crates/gunnar-cli --features client,control --bin gunnar-cli --target t").unwrap();
let mut want = s(&[
"tree",
"--manifest-path",
"crates/gunnar-cli/Cargo.toml",
"-p",
"gunnar-cli",
"--features",
"client,control",
"--target",
"t",
]);
want.extend(s(TREE_TAIL));
assert_eq!(tree_args_of_line(&p, "gunnar-cli"), want);
}
#[test]
fn a_listing_counts_each_crate_once() {
let l = "gunnar v1.5.0 (/w/gunnar/crates/gunnar)\nserde v1.0.0\nserde_derive v1.0.0 (proc-macro)\nserde v1.0.0\nwarning: x\n";
let c = crates_of(l);
assert_eq!(c.len(), 3, "{c:?}");
assert!(c.contains("serde_derive v1.0.0"));
}
#[test]
fn the_manifest_facts_come_from_cargo_metadata() {
let j = Json::parse(r#"{"packages":[{"name":"gunnar-cli","features":{"default":["client"],"client":["dep:x","y/z"],"solo":["client"]},"targets":[{"kind":["bin"],"name":"gunnar","required-features":["client"]},{"kind":["lib"],"name":"gunnar_cli"}]}]}"#).unwrap();
let m = manifest_of(&j, "gunnar-cli").unwrap();
assert_eq!(m.default_features, ["client".to_string()].into());
assert_eq!(
m.bins,
vec![("gunnar".to_string(), vec!["client".to_string()])]
);
assert!(m
.features
.contains(&("solo".to_string(), vec!["client".to_string()])));
assert!(m.features.contains(&("client".to_string(), vec![])));
}
}