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

nordisk/gunnar

xtask/src/both_ways.rs

//! **Gunnar builds BOTH ways, from crates.io alone** (owner order 2026-10-26, G-BOTH-1;
//! lane GUN-INSTALL; Holger's H1 `lone_clone` is the model).
//!
//! On 2026-10-26 `cargo install gunnar` (crates.io gunnar 1.5.0) did not build:
//! gunnar-recipe 0.1.0 names every field of nornir-build-thing's `InstallerBuild` in a
//! struct literal, and nornir-build-thing 0.2.6 added one (`payload_entries`) in a
//! semver-compatible release, so the install resolved 0.2.6 and stopped at E0063. Even
//! `--locked` did: the lock packaged into gunnar 1.5.0 was written in an estate checkout,
//! so its nornir-build-thing entry was a sibling PATH entry with no registry source,
//! and cargo resolved that one afresh. Nothing in this repository ran the install a
//! user runs, so nothing saw it. Two CI steps do now:
//!
//! * **`crates-io-install`** (needs the network): in an EMPTY directory with an EMPTY
//!   `CARGO_HOME`, exactly what the README tells a user, `cargo install gunnar
//!   --features client`, and the same `--locked`; each installed `gunnar --version`
//!   must run. It is red until crates.io holds a gunnar that builds.
//! * **`crates-io-install-staged`**, the pre-publish twin: every crate of the
//!   `gunnar --features client` graph that comes from this workspace is packaged from
//!   a clone of this commit ALONE (`cargo package`, which rewrites path dependencies to
//!   registry ones exactly as a publish does). A crate crates.io already serves at that
//!   version must be the SAME content there (else: bump it); the ones it does not serve
//!   are the publish rows, and they are patched in from their packaged form
//!   (`--config patch.crates-io.<name>.path=…`). Then the packaged gunnar is installed
//!   from its own directory with `--features client`, so a broken install is caught
//!   BEFORE the publish. The packaged gunnar's `Cargo.lock` must name no package
//!   without a registry source (the 1.5.0 defect).
//!
//! The lone clone (`lone-clone`, [`lone_steps`]): this commit cloned with no sibling
//! beside it, with an EMPTY `CARGO_HOME` ([`lone_home`]), runs `cargo build --locked` and
//! the two install lines [`LONE_INSTALLS`] (`cargo install --locked --path crates/gunnar-cli
//! --features client` and the same for `crates/gunnar`), runs each binary and boots the
//! server. The committed root lock is the crates.io lock (G-B7: the root takes no sibling
//! patch in any checkout), so the plain run takes it as it is; a lock that names a sibling
//! crate by path again is refused first, by name ([`lock_path_entries`] beside
//! [`in_repo_packages`]). `--stage <dir>` relocks once with the staged crates patched in
//! and then runs every step `--locked`.

use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::process::Command;

/// The crates.io package a user installs, and the feature the owner's order names.
pub const CRATES_IO_PACKAGE: &str = "gunnar";
/// `--features client`: the server with its client half (`gunnar clone`, `gunnar push`).
pub const CRATES_IO_FEATURES: &str = "client";

/// **The lone clone's install lines**: a name, the package directory, the features,
/// and the binary each leaves in `<root>/bin`.
pub const LONE_INSTALLS: &[(&str, &str, &str, &str)] = &[
    // gunnar-cli's binary is `gunnar-cli` since GUN-PRODUCTS (57542fb4).
    ("cli", "crates/gunnar-cli", "client", "gunnar-cli"),
    ("server", "crates/gunnar", "client", "gunnar"),
];

/// A crate packaged from this workspace for the stage: name, version, unpacked dir.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Staged {
    pub name: String,
    pub version: String,
    pub dir: PathBuf,
}

/// One cargo step: a name for the log, its argv (without `cargo`), and the installed
/// binary it must leave behind, if it installs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Step {
    pub name: String,
    pub argv: Vec<String>,
    pub leaves: Option<PathBuf>,
}

fn s(v: &[&str]) -> Vec<String> {
    v.iter().map(|x| (*x).to_string()).collect()
}

fn path_str(p: &Path) -> String {
    p.display().to_string()
}

/// The `--config patch.crates-io.<name>.path="<dir>"` pairs for staged crates.
pub fn patch_args(staged: &[Staged]) -> Vec<String> {
    let mut out = Vec::new();
    for c in staged {
        out.push("--config".to_string());
        out.push(format!(
            "patch.crates-io.{}.path={:?}",
            c.name,
            path_str(&c.dir)
        ));
    }
    out
}

/// **The lone clone's steps**: `cargo build --locked`, then every [`LONE_INSTALLS`] line
/// `--locked` into its own root (two lines leave a `gunnar`, so one root each). With
/// staged crates, a relock (`cargo metadata`) comes first, with the patches, so the
/// lock names the staged crates; every later step keeps the patches and `--locked`.
pub fn lone_steps(target: &Path, install_root: &Path, staged: &[Staged]) -> Vec<Step> {
    let t = path_str(target);
    let patches = patch_args(staged);
    let mut out = Vec::new();
    if !staged.is_empty() {
        let mut argv = s(&["metadata", "--format-version", "1"]);
        argv.extend(patches.iter().cloned());
        out.push(Step {
            name: "relock with the staged crates".into(),
            argv,
            leaves: None,
        });
    }
    let mut build = s(&["build", "--locked", "--target-dir"]);
    build.push(t.clone());
    build.extend(patches.iter().cloned());
    out.push(Step {
        name: "cargo build --locked".into(),
        argv: build,
        leaves: None,
    });
    for (name, dir, features, bin) in LONE_INSTALLS {
        let root = install_root.join(name);
        let mut argv = s(&[
            "install",
            "--locked",
            "--path",
            dir,
            "--features",
            features,
            "--root",
        ]);
        argv.push(path_str(&root));
        argv.push("--target-dir".into());
        argv.push(t.clone());
        argv.extend(patches.iter().cloned());
        out.push(Step {
            name: format!("cargo install --locked --path {dir} --features {features}"),
            argv,
            leaves: Some(root.join("bin").join(bin)),
        });
    }
    out
}

/// **The crates.io steps**, run in an empty directory with an empty `CARGO_HOME`:
/// without a stage, `cargo install gunnar --features client` as a user types it and
/// the same `--locked`; with the packaged gunnar of a stage, that package installed
/// from its directory with every OTHER staged crate patched in (no `--locked`: the
/// patches rewrite the resolution, as the publish will).
pub fn crates_io_steps(
    target: &Path,
    install_root: &Path,
    stage: Option<(&Staged, &[Staged])>,
) -> Vec<Step> {
    let t = path_str(target);
    let line = |root: &Path, extra: &[&str]| {
        let mut argv = s(&[
            "install",
            CRATES_IO_PACKAGE,
            "--features",
            CRATES_IO_FEATURES,
        ]);
        argv.extend(s(extra));
        argv.extend([
            "--root".to_string(),
            path_str(root),
            "--target-dir".to_string(),
            t.clone(),
        ]);
        argv
    };
    match stage {
        None => {
            let plain = install_root.join("plain");
            let locked = install_root.join("locked");
            vec![
                Step {
                    name: "cargo install gunnar --features client".into(),
                    argv: line(&plain, &[]),
                    leaves: Some(plain.join("bin/gunnar")),
                },
                Step {
                    name: "cargo install gunnar --features client --locked".into(),
                    argv: line(&locked, &["--locked"]),
                    leaves: Some(locked.join("bin/gunnar")),
                },
            ]
        }
        Some((gunnar, others)) => {
            let root = install_root.join("staged");
            let mut argv = s(&["install", "--path"]);
            argv.push(path_str(&gunnar.dir));
            argv.extend(s(&["--features", CRATES_IO_FEATURES, "--root"]));
            argv.push(path_str(&root));
            argv.extend(["--target-dir".to_string(), t]);
            argv.extend(patch_args(others));
            vec![Step {
                name: format!(
                    "cargo install --path <packaged gunnar {}> --features client",
                    gunnar.version
                ),
                argv,
                leaves: Some(root.join("bin/gunnar")),
            }]
        }
    }
}

/// The packages of a `Cargo.lock` that carry no `source` (a path package), other than
/// the ones named in `own` (the package itself, or the workspace's members). In a
/// packaged crate's lock every one of them is a defect: `cargo install --locked`
/// cannot use the entry and resolves that crate afresh.
pub fn lock_path_entries(lock: &str, own: &BTreeSet<String>) -> Vec<String> {
    let mut out = Vec::new();
    for block in lock.split("[[package]]").skip(1) {
        let field = |k: &str| {
            block.lines().map(str::trim).find_map(|l| {
                l.strip_prefix(&format!("{k} = \""))
                    .and_then(|r| r.strip_suffix('"'))
                    .map(str::to_string)
            })
        };
        let (Some(name), Some(version)) = (field("name"), field("version")) else {
            continue;
        };
        if field("source").is_none() && !own.contains(&name) {
            out.push(format!("{name} {version}"));
        }
    }
    out.sort();
    out.dedup();
    out
}

/// The files that differ between our packaged crate and the published one (the
/// generated `Cargo.toml`, `Cargo.lock`, `.cargo_vcs_info.json` and the registry's
/// `.cargo-ok` aside: the manifest
/// as written is `Cargo.toml.orig`, so a changed requirement still shows).
pub fn differing_files(
    ours: &BTreeMap<String, Vec<u8>>,
    theirs: &BTreeMap<String, Vec<u8>>,
) -> Vec<String> {
    const GENERATED: &[&str] = &[
        "Cargo.toml",
        "Cargo.lock",
        ".cargo_vcs_info.json",
        ".cargo-ok",
    ];
    let keys: BTreeSet<&String> = ours
        .keys()
        .chain(theirs.keys())
        .filter(|k| !GENERATED.contains(&k.as_str()))
        .collect();
    keys.into_iter()
        .filter(|k| ours.get(*k) != theirs.get(*k))
        .cloned()
        .collect()
}

/// The dependency requirements of a NORMALIZED (packaged) `Cargo.toml`: each
/// `[…dependencies.<name>]` table's `version`, keyed by the table header. A crate whose
/// `Cargo.toml.orig` says `{ workspace = true }` has the same `.orig` text when the
/// workspace raises the requirement, so the requirement is read here instead.
pub fn dependency_requirements(normalized: &str) -> BTreeMap<String, String> {
    let mut out = BTreeMap::new();
    let mut table: Option<String> = None;
    for line in normalized.lines().map(str::trim) {
        if line.starts_with('[') {
            let h = line.trim_matches(|c| c == '[' || c == ']').to_string();
            table = h.contains("dependencies.").then_some(h);
            continue;
        }
        if let (Some(t), Some(v)) = (
            &table,
            line.strip_prefix("version = \"")
                .and_then(|r| r.strip_suffix('"')),
        ) {
            out.insert(t.clone(), v.to_string());
        }
    }
    out
}

/// The workspace crates in a `cargo tree --prefix none --format "{p}"` listing: the
/// lines whose path is inside `root`, as (name, version), once each.
pub fn workspace_crates_in_tree(listing: &str, root: &Path) -> Vec<(String, String)> {
    let root = path_str(root);
    let mut out: Vec<(String, String)> = listing
        .lines()
        .filter_map(|l| {
            let open = l.find(" (")?;
            let path = l[open + 2..].split(')').next()?;
            if !(path == root || path.starts_with(&format!("{root}/"))) {
                return None;
            }
            let mut w = l[..open].split_whitespace();
            let name = w.next()?.to_string();
            let version = w.next()?.trim_start_matches('v').to_string();
            Some((name, version))
        })
        .collect();
    out.sort();
    out.dedup();
    out
}

/// The crates.io sparse index path of a crate.
pub fn index_path(name: &str) -> String {
    let n = name.to_lowercase();
    match n.len() {
        1 => format!("1/{n}"),
        2 => format!("2/{n}"),
        3 => format!("3/{}/{n}", &n[..1]),
        _ => format!("{}/{}/{n}", &n[..2], &n[2..4]),
    }
}

/// **The published copy of `name version`**, through cargo alone (no curl: the
/// toolchain image has none): `cargo info name@version`, run in an empty directory with
/// the step's own `CARGO_HOME`, downloads and unpacks the `.crate` into that home's
/// registry when crates.io serves the version. `Ok(None)`: crates.io does not serve it.
fn published_copy(
    cargo: &str,
    name: &str,
    version: &str,
    home: &Path,
    empty: &Path,
) -> Result<Option<PathBuf>, String> {
    let out = Command::new(cargo)
        .current_dir(empty)
        .env("CARGO_HOME", home)
        .env_remove("CARGO_TARGET_DIR")
        .args(["info", &format!("{name}@{version}")])
        .output()
        .map_err(|e| format!("{cargo} info: {e}"))?;
    if !out.status.success() {
        let err = String::from_utf8_lossy(&out.stderr);
        if err.contains("could not find") {
            return Ok(None);
        }
        return Err(format!(
            "cargo info {name}@{version}: {}",
            err.lines()
                .filter(|l| l.starts_with("error"))
                .collect::<Vec<_>>()
                .join(" / ")
        ));
    }
    let src = home.join("registry").join("src");
    let found = std::fs::read_dir(&src)
        .map_err(|e| format!("{}: {e}", src.display()))?
        .flatten()
        .map(|e| e.path().join(format!("{name}-{version}")))
        .find(|p| p.join("Cargo.toml").is_file());
    found.map(Some).ok_or_else(|| {
        format!(
            "cargo info {name}@{version} left no unpacked copy under {}",
            src.display()
        )
    })
}

/// The files of an unpacked crate directory, by path relative to it.
fn files_of(dir: &Path) -> BTreeMap<String, Vec<u8>> {
    fn walk(base: &Path, d: &Path, out: &mut BTreeMap<String, Vec<u8>>) {
        let Ok(rd) = std::fs::read_dir(d) else { return };
        for e in rd.flatten() {
            let p = e.path();
            if p.is_dir() {
                walk(base, &p, out);
            } else if let (Ok(rel), Ok(bytes)) = (p.strip_prefix(base), std::fs::read(&p)) {
                out.insert(rel.to_string_lossy().into_owned(), bytes);
            }
        }
    }
    let mut out = BTreeMap::new();
    walk(dir, dir, &mut out);
    out
}

fn unpack(file: &Path, into: &Path) -> Result<(), String> {
    let ok = Command::new("tar")
        .arg("xzf")
        .arg(file)
        .arg("-C")
        .arg(into)
        .status()
        .map(|s| s.success())
        .unwrap_or(false);
    if ok {
        Ok(())
    } else {
        Err(format!("tar xzf {}: failed", file.display()))
    }
}

fn clone_alone(root: &Path, dir: &Path) -> Result<(), String> {
    let _ = std::fs::remove_dir_all(dir);
    let ok = Command::new("git")
        .env("GIT_LFS_SKIP_SMUDGE", "1")
        .args(["clone", "--quiet", "--no-local"])
        .arg(root)
        .arg(dir)
        .status()
        .map(|s| s.success())
        .unwrap_or(false);
    if ok {
        Ok(())
    } else {
        Err(format!(
            "git clone {} {}: failed",
            root.display(),
            dir.display()
        ))
    }
}

fn run_cargo(
    cargo: &str,
    cwd: &Path,
    argv: &[String],
    cargo_home: Option<&Path>,
) -> Result<(), String> {
    let mut c = Command::new(cargo);
    c.current_dir(cwd).env_remove("CARGO_TARGET_DIR").args(argv);
    if let Some(h) = cargo_home {
        c.env("CARGO_HOME", h);
    }
    eprintln!("both-ways: cargo {}", argv.join(" "));
    let out = c.output().map_err(|e| format!("{cargo}: {e}"))?;
    if out.status.success() {
        return Ok(());
    }
    let err = String::from_utf8_lossy(&out.stderr);
    // The compiler's verdict, not cargo's progress lines: each `error…` line, the
    // `-->` place it names, and a `Caused by` chain.
    let lines: Vec<&str> = err
        .lines()
        .filter(|l| {
            l.starts_with("error")
                || l.trim_start().starts_with("-->")
                || l.starts_with("Caused by")
        })
        .take(14)
        .collect();
    Err(lines.join("\n"))
}

/// Run a binary once (`--version`) to show it starts.
fn runs(bin: &Path) -> Result<String, String> {
    let out = Command::new(bin)
        .arg("--version")
        .output()
        .map_err(|e| format!("start {}: {e}", bin.display()))?;
    if out.status.success() {
        Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
    } else {
        Err(format!(
            "{} --version: {}",
            bin.display(),
            String::from_utf8_lossy(&out.stderr).trim()
        ))
    }
}

/// What the stage holds: the packaged gunnar, the other publish rows, and the crates
/// that were already on crates.io with the same content.
#[derive(Debug, Clone, Default)]
pub struct Stage {
    pub gunnar: Option<Staged>,
    pub rows: Vec<Staged>,
    pub same: Vec<(String, String)>,
}

/// **Build the stage** in `work`: clone this commit alone, find the workspace crates of
/// the `gunnar --features client` graph, `cargo package` them in the clone, and judge
/// each against crates.io. A crate crates.io serves at that version with OTHER content
/// is a red (bump its version); the packaged gunnar's lock must name no path package.
pub fn make_stage(root: &Path, cargo: &str, work: &Path) -> Result<Stage, Vec<String>> {
    let clone = work.join("clone");
    clone_alone(root, &clone).map_err(|e| vec![e])?;
    let tree = Command::new(cargo)
        .current_dir(&clone)
        .args([
            "tree",
            "-p",
            CRATES_IO_PACKAGE,
            "--features",
            CRATES_IO_FEATURES,
            "-e",
            "normal,build",
            "--prefix",
            "none",
            "--format",
            "{p}",
        ])
        .output()
        .map_err(|e| vec![format!("cargo tree: {e}")])?;
    if !tree.status.success() {
        return Err(vec![format!(
            "cargo tree -p gunnar in the lone clone: {}",
            String::from_utf8_lossy(&tree.stderr).trim()
        )]);
    }
    // Members only: an in-repo `[patch]` (the vendored gix-parallel) is a path package
    // under the root too, but a crates.io user never sees the patch, so it is no row.
    let members = member_names(&clone, cargo);
    let mut crates: Vec<(String, String)> =
        workspace_crates_in_tree(&String::from_utf8_lossy(&tree.stdout), &clone)
            .into_iter()
            .filter(|(n, _)| members.contains(n))
            .collect();
    // An OPTIONAL in-repo dependency of a row is a publish row too: `cargo package` resolves
    // every dependency of a crate, the off-by-default ones included, and a crates.io user
    // may turn the feature on (gunnar-server's `heimdall` names gunnar-heimdall, which the
    // `client` tree above never reaches: the staged steps' red of 2026-11-03).
    for extra in optional_member_deps(&clone, cargo, &crates) {
        crates.push(extra);
    }
    crates.sort();
    crates.dedup();
    if !crates.iter().any(|(n, _)| n == CRATES_IO_PACKAGE) {
        return Err(vec!["the gunnar graph names no gunnar package".into()]);
    }
    let pkg_target = work.join("package-target");
    let mut argv = s(&["package", "--no-verify", "--allow-dirty", "--target-dir"]);
    argv.push(path_str(&pkg_target));
    for (n, _) in &crates {
        argv.push("-p".into());
        argv.push(n.clone());
    }
    run_cargo(cargo, &clone, &argv, None).map_err(|e| vec![format!("cargo package: {e}")])?;
    let stage_dir = work.join("stage");
    // The published copies land in a home of their own, read from an empty directory
    // (no workspace, no `.cargo/config.toml` of this repository on the way up).
    let home = work.join("published-home");
    let empty = work.join("published-empty");
    for d in [&stage_dir, &home, &empty] {
        std::fs::create_dir_all(d).map_err(|e| vec![format!("{}: {e}", d.display())])?;
    }
    let mut stage = Stage::default();
    let mut red = Vec::new();
    for (name, version) in &crates {
        let file = pkg_target
            .join("package")
            .join(format!("{name}-{version}.crate"));
        if let Err(e) = unpack(&file, &stage_dir) {
            red.push(e);
            continue;
        }
        let dir = stage_dir.join(format!("{name}-{version}"));
        let staged = Staged {
            name: name.clone(),
            version: version.clone(),
            dir: dir.clone(),
        };
        let served = match published_copy(cargo, name, version, &home, &empty) {
            Ok(p) => p,
            Err(e) => {
                red.push(format!("{name}: {e}"));
                continue;
            }
        };
        if let Some(theirs) = served {
            let mut diff = differing_files(&files_of(&dir), &files_of(&theirs));
            let reqs = |d: &Path| {
                dependency_requirements(
                    &std::fs::read_to_string(d.join("Cargo.toml")).unwrap_or_default(),
                )
            };
            let (ours, published) = (reqs(&dir), reqs(&theirs));
            for (t, v) in &ours {
                if published.get(t) != Some(v) {
                    diff.push(format!(
                        "Cargo.toml [{t}] version {v} (crates.io: {})",
                        published.get(t).map(String::as_str).unwrap_or("none")
                    ));
                }
            }
            if diff.is_empty() {
                stage.same.push((name.clone(), version.clone()));
            } else {
                red.push(format!(
                    "{name} {version} is on crates.io with OTHER content ({}{}): bump its version, it is a publish row",
                    diff.iter().take(6).cloned().collect::<Vec<_>>().join(", "),
                    if diff.len() > 6 { ", …" } else { "" }
                ));
            }
            if name == CRATES_IO_PACKAGE {
                stage.gunnar = Some(staged);
            }
        } else if name == CRATES_IO_PACKAGE {
            stage.gunnar = Some(staged);
        } else {
            stage.rows.push(staged);
        }
    }
    if let Some(g) = &stage.gunnar {
        let own: BTreeSet<String> = [CRATES_IO_PACKAGE.to_string()].into();
        match std::fs::read_to_string(g.dir.join("Cargo.lock")) {
            Ok(lock) => {
                let bad = lock_path_entries(&lock, &own);
                if !bad.is_empty() {
                    red.push(format!("the packaged gunnar's Cargo.lock names path packages (no registry source), which `cargo install --locked` re-resolves: {}", bad.join(", ")));
                }
            }
            Err(e) => red.push(format!("the packaged gunnar has no Cargo.lock: {e}")),
        }
    }
    if red.is_empty() {
        Ok(stage)
    } else {
        Err(red)
    }
}

/// The verdict of a both-ways step.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Verdict {
    Passed(String),
    Failed(String),
}

fn work_dir(tag: &str) -> PathBuf {
    std::env::var("GUNNAR_BOTH_WAYS_WORK")
        .ok()
        .filter(|v| !v.is_empty())
        .map(PathBuf::from)
        .unwrap_or_else(std::env::temp_dir)
        .join(format!("gunnar-{tag}-{}", std::process::id()))
}

fn run_steps(cargo: &str, cwd: &Path, steps: &[Step], cargo_home: Option<&Path>) -> Verdict {
    let mut said = Vec::new();
    for st in steps {
        if let Err(e) = run_cargo(cargo, cwd, &st.argv, cargo_home) {
            return Verdict::Failed(format!("`{}` failed:\n{e}", st.name));
        }
        if let Some(bin) = &st.leaves {
            match runs(bin) {
                Ok(v) => said.push(format!(
                    "{} -> {} ({v})",
                    st.name,
                    bin.file_name()
                        .map(|n| n.to_string_lossy().into_owned())
                        .unwrap_or_default()
                )),
                Err(e) => {
                    return Verdict::Failed(format!("`{}` left no working binary: {e}", st.name))
                }
            }
        }
    }
    Verdict::Passed(said.join("; "))
}

/// **`crates-io-install`** (no stage) or **`crates-io-install-staged`**: see the module
/// docs. Needs the network. The work directory is removed afterwards.
pub fn crates_io_install(root: &Path, cargo: &str, staged: bool) -> Verdict {
    let work = work_dir(if staged {
        "crates-io-staged"
    } else {
        "crates-io"
    });
    let _ = std::fs::remove_dir_all(&work);
    let empty = work.join("empty");
    let home = work.join("cargo-home");
    for d in [&empty, &home] {
        if let Err(e) = std::fs::create_dir_all(d) {
            return Verdict::Failed(format!("{}: {e}", d.display()));
        }
    }
    let target = work.join("target");
    let installs = work.join("installs");
    let verdict = if staged {
        match make_stage(root, cargo, &work) {
            Err(red) => Verdict::Failed(red.join("\n")),
            Ok(stage) => match &stage.gunnar {
                None => Verdict::Failed("the stage has no packaged gunnar".into()),
                Some(g) => {
                    eprintln!(
                        "both-ways: stage: gunnar {}; publish rows {}; on crates.io already {}",
                        g.version,
                        stage
                            .rows
                            .iter()
                            .map(|r| format!("{} {}", r.name, r.version))
                            .collect::<Vec<_>>()
                            .join(", "),
                        stage
                            .same
                            .iter()
                            .map(|(n, v)| format!("{n} {v}"))
                            .collect::<Vec<_>>()
                            .join(", ")
                    );
                    run_steps(
                        cargo,
                        &empty,
                        &crates_io_steps(&target, &installs, Some((g, &stage.rows))),
                        Some(&home),
                    )
                }
            },
        }
    } else {
        run_steps(
            cargo,
            &empty,
            &crates_io_steps(&target, &installs, None),
            Some(&home),
        )
    };
    let _ = std::fs::remove_dir_all(&work);
    verdict
}

/// **The lone clone**, plain or staged (`stage`: the stage is built from this commit
/// by [`make_stage`]): see the module docs. Returns the clone's directory with the
/// verdict so the caller can boot the installed server; the caller removes `work`.
pub fn lone_clone(
    root: &Path,
    cargo: &str,
    staged: bool,
    work: &Path,
) -> (Verdict, Option<PathBuf>) {
    let clone = work.join("lone");
    if let Err(e) = clone_alone(root, &clone) {
        return (Verdict::Failed(e), None);
    }
    let rows = if staged {
        match make_stage(root, cargo, &work.join("stage-work")) {
            Ok(stage) => stage.rows,
            Err(red) => return (Verdict::Failed(red.join("\n")), None),
        }
    } else {
        let lock = std::fs::read_to_string(clone.join("Cargo.lock")).unwrap_or_default();
        let bad = lock_path_entries(&lock, &in_repo_packages(&clone, cargo));
        if !bad.is_empty() {
            return (
                Verdict::Failed(format!(
                    "the committed Cargo.lock is not the crates.io lock: it names {} crate(s) by path that are neither members nor an in-repo patch ({}{}), so a lone clone's `cargo build --locked` refuses it. The root takes no sibling patch (G-B7): relock it with none (`cargo metadata` at the root) and commit that lock",
                    bad.len(),
                    bad.iter().take(8).cloned().collect::<Vec<_>>().join(", "),
                    if bad.len() > 8 { ", …" } else { "" }
                )),
                None,
            );
        }
        Vec::new()
    };
    let steps = lone_steps(
        &work.join("lone-target"),
        &work.join("lone-installs"),
        &rows,
    );
    // The plain run is a user's first build: nothing in CARGO_HOME (G-BOTH-1).
    let home = if staged { None } else { Some(lone_home(work)) };
    if let Some(h) = &home {
        let _ = std::fs::remove_dir_all(h);
        if let Err(e) = std::fs::create_dir_all(h) {
            return (Verdict::Failed(format!("{}: {e}", h.display())), None);
        }
    }
    let verdict = run_steps(cargo, &clone, &steps, home.as_deref());
    let server = work
        .join("lone-installs")
        .join("server")
        .join("bin")
        .join("gunnar");
    (verdict, Some(server))
}

/// The plain lone clone's `CARGO_HOME`: a fresh, empty directory in its work directory, so
/// the run downloads every crate from crates.io as a user's first build does.
pub fn lone_home(work: &Path) -> PathBuf {
    work.join("lone-cargo-home")
}

/// The path packages a crates.io root lock may hold: the root workspace's members and the
/// crates its own `[patch]` supplies from inside the repository (`vendor/gix-parallel`;
/// `xtask/tests/lone_clone.rs` holds that table inside the repo).
pub fn in_repo_packages(root: &Path, cargo: &str) -> BTreeSet<String> {
    let mut own = member_names(root, cargo);
    if let Ok(text) = std::fs::read_to_string(root.join("Cargo.toml")) {
        own.extend(crate::siblings::patch_names(&text));
    }
    own
}

/// The workspace members that `rows` depend on (normal or build, optional ones included),
/// to a fixed point, minus the rows themselves: name and version each. From `cargo metadata
/// --no-deps`, whose `dependencies` carry a `path` for an in-repo dependency.
pub fn optional_member_deps(
    root: &Path,
    cargo: &str,
    rows: &[(String, String)],
) -> Vec<(String, String)> {
    let out = Command::new(cargo)
        .current_dir(root)
        .args(["metadata", "--no-deps", "--format-version", "1"])
        .output();
    let Ok(out) = out else { return Vec::new() };
    let Ok(json) = crate::json::Json::parse(&String::from_utf8_lossy(&out.stdout)) else {
        return Vec::new();
    };
    let mut version: BTreeMap<String, String> = BTreeMap::new();
    let mut deps: BTreeMap<String, Vec<String>> = BTreeMap::new();
    for p in json.get("packages").map(|p| p.arr()).unwrap_or(&[]) {
        let Some(name) = p.get("name").and_then(crate::json::Json::str) else {
            continue;
        };
        if let Some(v) = p.get("version").and_then(crate::json::Json::str) {
            version.insert(name.to_string(), v.to_string());
        }
        let list = p
            .get("dependencies")
            .map(|d| d.arr())
            .unwrap_or(&[])
            .iter()
            .filter(|d| {
                d.get("path").is_some()
                    && d.get("kind").and_then(crate::json::Json::str) != Some("dev")
            })
            .filter_map(|d| d.get("name").and_then(crate::json::Json::str))
            .map(str::to_string)
            .collect();
        deps.insert(name.to_string(), list);
    }
    let mut have: BTreeSet<String> = rows.iter().map(|(n, _)| n.clone()).collect();
    let mut todo: Vec<String> = have.iter().cloned().collect();
    let mut added = Vec::new();
    while let Some(n) = todo.pop() {
        for d in deps.get(&n).cloned().unwrap_or_default() {
            if let Some(v) = version.get(&d) {
                if have.insert(d.clone()) {
                    added.push((d.clone(), v.clone()));
                    todo.push(d);
                }
            }
        }
    }
    added
}

/// The root workspace's member names (from `cargo metadata --no-deps`, which needs no
/// resolution); empty if cargo cannot read the manifests.
pub fn member_names(root: &Path, cargo: &str) -> BTreeSet<String> {
    let out = Command::new(cargo)
        .current_dir(root)
        .args(["metadata", "--no-deps", "--format-version", "1"])
        .output();
    let Ok(out) = out else { return BTreeSet::new() };
    let Ok(json) = crate::json::Json::parse(&String::from_utf8_lossy(&out.stdout)) else {
        return BTreeSet::new();
    };
    json.get("packages")
        .map(|p| {
            p.arr()
                .iter()
                .filter_map(|x| {
                    x.get("name")
                        .and_then(crate::json::Json::str)
                        .map(str::to_string)
                })
                .collect()
        })
        .unwrap_or_default()
}

#[cfg(test)]
mod tests {
    use super::*;

    fn staged(name: &str, v: &str) -> Staged {
        Staged {
            name: name.into(),
            version: v.into(),
            dir: PathBuf::from(format!("/s/{name}-{v}")),
        }
    }

    #[test]
    fn the_plain_lone_clone_has_its_own_empty_cargo_home_in_its_work_dir() {
        let work = Path::new("/w/gunnar-lone-clone-1");
        assert_eq!(
            lone_home(work),
            PathBuf::from("/w/gunnar-lone-clone-1/lone-cargo-home")
        );
        assert!(
            lone_home(work).starts_with(work),
            "removed with the work directory"
        );
    }

    #[test]
    fn an_in_repo_patch_is_not_a_sibling_path_entry() {
        // The vendored gix-features is a path package in the crates.io lock; a sibling is not.
        let lock = "version = 4\n\n[[package]]\nname = \"gix-features\"\nversion = \"0.50.0\"\n\n[[package]]\nname = \"nornir-git\"\nversion = \"0.2.0\"\n";
        let mut own: BTreeSet<String> = ["gunnar".to_string()].into();
        own.extend(crate::siblings::patch_names(
            "[patch.crates-io]\ngix-features = { path = \"vendor/gix-features\" }\n",
        ));
        assert_eq!(lock_path_entries(lock, &own), ["nornir-git 0.2.0"]);
    }

    #[test]
    fn a_lock_path_entry_is_named_and_a_member_or_registry_entry_is_not() {
        let lock = "version = 4\n\n[[package]]\nname = \"gunnar\"\nversion = \"1.5.0\"\n\n[[package]]\nname = \"nornir-build-thing\"\nversion = \"0.2.5\"\ndependencies = [\n \"serde\",\n]\n\n[[package]]\nname = \"serde\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n";
        let own: BTreeSet<String> = ["gunnar".to_string()].into();
        assert_eq!(lock_path_entries(lock, &own), ["nornir-build-thing 0.2.5"]);
        assert!(lock_path_entries("version = 4\n", &own).is_empty());
    }

    #[test]
    fn the_generated_files_are_not_a_difference_and_every_other_file_is() {
        let m = |kv: &[(&str, &str)]| {
            kv.iter()
                .map(|(k, v)| (k.to_string(), v.as_bytes().to_vec()))
                .collect::<BTreeMap<_, _>>()
        };
        let ours = m(&[
            ("Cargo.toml", "a"),
            ("Cargo.toml.orig", "x"),
            ("src/lib.rs", "1"),
            ("src/new.rs", "n"),
        ]);
        let theirs = m(&[
            ("Cargo.toml", "b"),
            ("Cargo.toml.orig", "x"),
            ("src/lib.rs", "2"),
            ("Cargo.lock", "l"),
        ]);
        assert_eq!(
            differing_files(&ours, &theirs),
            ["src/lib.rs", "src/new.rs"]
        );
        assert!(differing_files(&ours, &ours).is_empty());
    }

    #[test]
    fn a_raised_requirement_shows_in_the_normalized_manifest() {
        let a = "[package]\nname = \"gunnar-http\"\nversion = \"1.5.0\"\n\n[dependencies.gunnar-ssh]\nversion = \"1.1.0\"\n\n[dev-dependencies.tempfile]\nversion = \"3\"\n";
        let r = dependency_requirements(a);
        assert_eq!(
            r.get("dependencies.gunnar-ssh").map(String::as_str),
            Some("1.1.0")
        );
        assert_eq!(
            r.get("dev-dependencies.tempfile").map(String::as_str),
            Some("3")
        );
        assert!(
            !r.values().any(|v| v == "1.5.0"),
            "the package's own version is not a requirement"
        );
    }

    #[test]
    fn the_workspace_crates_of_a_tree_are_the_ones_under_the_root() {
        let listing = "gunnar v1.5.1 (/c/gunnar/crates/gunnar)\ngunnar-server v1.2.0 (/c/gunnar/crates/gunnar-server)\nserde v1.0.0\nnornir-git v0.2.1 (/c/edda/crates/nornir-git)\ngunnar-server v1.2.0 (/c/gunnar/crates/gunnar-server)\ngunnar-x v1.0.0 (/c/gunnar-other/x)\n";
        assert_eq!(
            workspace_crates_in_tree(listing, Path::new("/c/gunnar")),
            [
                ("gunnar".to_string(), "1.5.1".to_string()),
                ("gunnar-server".to_string(), "1.2.0".to_string())
            ]
        );
    }

    #[test]
    fn the_index_path_follows_the_crates_io_layout() {
        assert_eq!(index_path("gunnar-recipe"), "gu/nn/gunnar-recipe");
        assert_eq!(index_path("syn"), "3/s/syn");
        assert_eq!(index_path("cc"), "2/cc");
        assert_eq!(index_path("a"), "1/a");
    }

    #[test]
    fn the_plain_lone_clone_builds_locked_and_installs_both_lines_locked() {
        let st = lone_steps(Path::new("/t"), Path::new("/r"), &[]);
        let argv: Vec<Vec<String>> = st.iter().map(|x| x.argv.clone()).collect();
        assert_eq!(argv[0], s(&["build", "--locked", "--target-dir", "/t"]));
        assert_eq!(
            argv[1],
            s(&[
                "install",
                "--locked",
                "--path",
                "crates/gunnar-cli",
                "--features",
                "client",
                "--root",
                "/r/cli",
                "--target-dir",
                "/t"
            ])
        );
        assert_eq!(
            argv[2],
            s(&[
                "install",
                "--locked",
                "--path",
                "crates/gunnar",
                "--features",
                "client",
                "--root",
                "/r/server",
                "--target-dir",
                "/t"
            ])
        );
        assert_eq!(st.len(), 3);
        assert_eq!(
            st[1].leaves.as_deref(),
            Some(Path::new("/r/cli/bin/gunnar-cli"))
        );
        assert_eq!(
            st[2].leaves.as_deref(),
            Some(Path::new("/r/server/bin/gunnar"))
        );
    }

    #[test]
    fn a_staged_lone_clone_relocks_first_and_keeps_the_patches_and_locked() {
        let st = lone_steps(
            Path::new("/t"),
            Path::new("/r"),
            &[staged("gunnar-recipe", "0.1.1")],
        );
        assert_eq!(st[0].argv[0], "metadata");
        assert!(
            st[0].argv.iter().all(|a| a != "--locked"),
            "the relock writes the lock"
        );
        for x in &st[1..] {
            assert!(x.argv.contains(&"--locked".to_string()), "{:?}", x.argv);
            assert!(
                x.argv.contains(
                    &"patch.crates-io.gunnar-recipe.path=\"/s/gunnar-recipe-0.1.1\"".to_string()
                ),
                "{:?}",
                x.argv
            );
        }
    }

    #[test]
    fn the_crates_io_install_is_the_readme_line_plain_and_locked() {
        let st = crates_io_steps(Path::new("/t"), Path::new("/r"), None);
        assert_eq!(
            st[0].argv,
            s(&[
                "install",
                "gunnar",
                "--features",
                "client",
                "--root",
                "/r/plain",
                "--target-dir",
                "/t"
            ])
        );
        assert_eq!(
            st[1].argv,
            s(&[
                "install",
                "gunnar",
                "--features",
                "client",
                "--locked",
                "--root",
                "/r/locked",
                "--target-dir",
                "/t"
            ])
        );
        assert!(st
            .iter()
            .all(|x| x.leaves.as_ref().is_some_and(|b| b.ends_with("bin/gunnar"))));
    }

    #[test]
    fn the_staged_install_installs_the_packaged_gunnar_with_the_other_rows_patched_in() {
        let g = staged("gunnar", "1.5.1");
        let rows = [
            staged("gunnar-recipe", "0.1.1"),
            staged("gunnar-server", "1.2.0"),
        ];
        let st = crates_io_steps(Path::new("/t"), Path::new("/r"), Some((&g, &rows)));
        assert_eq!(st.len(), 1);
        let a = &st[0].argv;
        assert_eq!(&a[..3], &s(&["install", "--path", "/s/gunnar-1.5.1"])[..]);
        assert!(a.contains(&"client".to_string()));
        assert!(
            !a.contains(&"--locked".to_string()),
            "the patches rewrite the resolution"
        );
        assert_eq!(
            a.iter()
                .filter(|x| x.starts_with("patch.crates-io."))
                .count(),
            2
        );
        assert!(
            !a.iter().any(|x| x.starts_with("patch.crates-io.gunnar.")),
            "gunnar itself is installed by --path"
        );
    }
}