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

nordisk/gunnar

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![])));
    }
}