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

nordisk/edda

crates/nornir-release-core/src/cargo.rs

//! The READ-ONLY half of the cargo leg — the parts of the release doctor's cargo
//! expertise that only OBSERVE: which `cargo` binary to spawn, which crates a
//! commit range touched, and the reverse-dependency closure that must be
//! re-tested.
//!
//! ## Why only this half is a leaf
//!
//! The rest of the conductor's `cargo` module MUTATES — it strips
//! `[patch.crates-io]` blocks, rewrites version fields, yanks published versions,
//! mirrors tarballs. Constructive operations belong to the release conductor
//! (PRE-PROMPT §L5a) and stay there. What lives here is what a *reader* needs:
//! the warehouse owner's `release impacted-crates` verb is a pure READ over the
//! git diff + the dep-graph snapshot, and both halves need one honest answer to
//! "where is cargo?" rather than the string literal `"cargo"`.
//!
//! [`cargo_bin`] in particular is the primitive every spawner in the
//! constellation wants as-is — the ENOENT-under-systemd bug it fixes cost a whole
//! rehearsal cycle, and it must be fixed in exactly one place.

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

use anyhow::{anyhow, Context, Result};

// ─── cargo binary resolution (robust; not ambient-PATH-dependent) ───────
//
// `stage --execute` / `publish` / the drift check all spawn `cargo`. When the
// process runs WITHOUT `~/.cargo/bin` on PATH (a systemd unit, a bare login shell,
// a GUI-launched binary), `Command::new("cargo")` fails ENOENT and cascades as a
// cryptic per-crate "spawn cargo failed (os error 2)" that reads like a real
// dependency problem (it cost a whole rehearsal cycle). Resolve the binary
// explicitly instead, and offer a one-shot up-front check.

/// The resolved `cargo` binary. Resolution order: `$CARGO` (cargo sets it for its
/// own subcommands) → `rustup which cargo` (toolchain-correct) → `~/.cargo/bin/cargo`
/// → bare `"cargo"` (PATH fallback). Cached after first resolution. Use everywhere
/// instead of the string literal `"cargo"`.
pub fn cargo_bin() -> OsString {
    // Deliberately UNCACHED: resolution is cheap (an env read + at most one stat in
    // the common case) and staying uncached keeps it honest — a test can inject a
    // fake via `$CARGO`, and a mid-process toolchain-env change is still respected.
    if let Some(c) = std::env::var_os("CARGO").filter(|c| !c.is_empty()) {
        return c;
    }
    if let Some(home) = std::env::var_os("HOME") {
        let p = Path::new(&home).join(".cargo/bin/cargo");
        if p.exists() {
            return p.into_os_string();
        }
    }
    // Spawn rustup only as a LAST resort (both `$CARGO` and `~/.cargo/bin` absent —
    // a system-cargo-only host), so the per-call spawn never bites the common path.
    if let Ok(out) = Command::new("rustup").args(["which", "cargo"]).output() {
        if out.status.success() {
            let p = String::from_utf8_lossy(&out.stdout).trim().to_string();
            if !p.is_empty() && Path::new(&p).exists() {
                return OsString::from(p);
            }
        }
    }
    OsString::from("cargo")
}

/// Up-front preflight: verify the resolved `cargo` actually executes BEFORE the
/// cascade touches any repo — one clear error instead of N cryptic per-crate spawn
/// failures. Returns the resolved binary path on success.
pub fn ensure_cargo() -> Result<OsString> {
    let bin = cargo_bin();
    match Command::new(&bin).arg("--version").output() {
        Ok(out) if out.status.success() => Ok(bin),
        Ok(_) => Err(anyhow!(
            "resolved cargo `{}` did not run `cargo --version` cleanly — check the toolchain",
            bin.to_string_lossy()
        )),
        Err(e) => Err(anyhow!(
            "cargo not found — resolved `{}` but it failed to spawn ({e}). \
             Install rustup or `source ~/.cargo/env` so cargo is available.",
            bin.to_string_lossy()
        )),
    }
}


// ─── feature 11: impacted tests from dep-graph ──────────────────────────

/// Given a set of changed crates and the full dep-graph edges (any
/// snapshot — typically the latest), return every crate that is in the
/// transitive *reverse* closure (i.e. would need re-testing because it
/// directly or indirectly depends on a changed crate).
///
/// The result includes the seed `changed` set. Caller hands it to
/// `cargo test -p crate1 -p crate2 ...` for a minimal CI run.
pub fn impacted_crates(
    changed: &BTreeSet<String>,
    edges: &[(String, String)],
) -> BTreeSet<String> {
    // Build reverse adjacency: target -> {consumers that depend on target}.
    let mut rev: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
    for (from, to) in edges {
        rev.entry(to.as_str()).or_default().push(from.as_str());
    }
    let mut out: BTreeSet<String> = changed.clone();
    let mut frontier: Vec<String> = changed.iter().cloned().collect();
    while let Some(c) = frontier.pop() {
        if let Some(consumers) = rev.get(c.as_str()) {
            for consumer in consumers {
                if out.insert(consumer.to_string()) {
                    frontier.push(consumer.to_string());
                }
            }
        }
    }
    out
}

/// `release impacted-crates` rendered as the uniform
/// [`nornir_cli_outcome::CommandOutcome`] — the shared face for the (fat-only)
/// CLI path. SHAPES the already-computed `changed` + `impacted` sets (caller
/// fetched the git diff + dep-graph edges); it does NOT fetch (CommandOutcome is
/// presentation, never transport). `ok ⟺ ≥1 impacted crate` — no changes since
/// `base` ⇒ nothing to re-test ⇒ a blank pane ⇒ RED (RAGNARÖK: empty is RED,
/// never silently green; the caller's old "ok: no changed crates" was a green
/// no-op). AUT9 — see `.nornir/cli-command-contract.md`.
pub fn impacted_crates_outcome(
    base: &str,
    changed: &BTreeSet<String>,
    impacted: &BTreeSet<String>,
) -> nornir_cli_outcome::CommandOutcome {
    use nornir_cli_outcome::CommandOutcome;
    if impacted.is_empty() {
        return CommandOutcome::fail(
            "release impacted-crates",
            format!("no changed crates since {base} (nothing impacted to re-test)"),
        );
    }
    let changed_v: Vec<&String> = changed.iter().collect();
    let impacted_v: Vec<&String> = impacted.iter().collect();
    // The human face mirrors the old CLI: each impacted crate on its own line
    // (ready to feed `cargo test -p ...`), then a one-line summary.
    let mut human = impacted_v.iter().map(|c| c.as_str()).collect::<Vec<_>>().join("\n");
    human.push_str(&format!(
        "\n# {} changed → {} impacted (since {base})",
        changed.len(),
        impacted.len()
    ));
    let data = serde_json::json!({
        "base": base,
        "changed": changed_v,
        "impacted": impacted_v,
    });
    CommandOutcome::ok("release impacted-crates", data, human)
}

/// Convenience: diff `<base>..HEAD` and infer which crate each touched
/// path belongs to by walking up to the nearest Cargo.toml. Returns the
/// set of crate package names. Pure-Rust via `gix` (no `git` shellout).
pub fn changed_crates_since(repo_root: &Path, base: &str) -> Result<BTreeSet<String>> {
    use gix::bstr::ByteSlice;
    use gix::object::tree::diff::ChangeDetached as Change;

    let repo = gix::open(repo_root)
        .with_context(|| format!("gix::open {}", repo_root.display()))?;
    let base_tree = repo
        .rev_parse_single(base)
        .with_context(|| format!("resolve rev `{base}`"))?
        .object()
        .context("read base object")?
        .peel_to_commit()
        .with_context(|| format!("peel `{base}` to commit"))?
        .tree()
        .context("base tree")?;
    let head_tree = repo
        .head_commit()
        .context("resolve HEAD commit")?
        .tree()
        .context("HEAD tree")?;

    // `None` options => gix fills track_path from config, so locations
    // are populated (unlike Options::default()).
    let changes = repo
        .diff_tree_to_tree(Some(&base_tree), Some(&head_tree), None::<gix::diff::Options>)
        .context("diff base..HEAD")?;

    let mut paths: Vec<String> = Vec::new();
    for ch in changes {
        match ch {
            Change::Addition { location, .. }
            | Change::Deletion { location, .. }
            | Change::Modification { location, .. } => {
                paths.push(location.to_str_lossy().into_owned());
            }
            Change::Rewrite { source_location, location, .. } => {
                paths.push(source_location.to_str_lossy().into_owned());
                paths.push(location.to_str_lossy().into_owned());
            }
        }
    }

    let mut crates = BTreeSet::new();
    for rel in paths {
        let mut cur = repo_root.join(&rel);
        cur.pop();
        loop {
            let candidate = cur.join("Cargo.toml");
            if candidate.exists() {
                if let Ok(text) = std::fs::read_to_string(&candidate) {
                    if let Ok(v) = text.parse::<toml::Table>().map(toml::Value::Table) {
                        if let Some(name) = v
                            .get("package")
                            .and_then(|p| p.get("name"))
                            .and_then(|n| n.as_str())
                        {
                            crates.insert(name.to_string());
                            break;
                        }
                    }
                }
            }
            if !cur.pop() || !cur.starts_with(repo_root) {
                break;
            }
        }
    }
    Ok(crates)
}


/// `release impacted-crates <repo> --base <ref>` rendered as the uniform
/// [`nornir_cli_outcome::CommandOutcome`] — the CLI's `state_json` for the
/// re-test set. Pure SHAPER over the already-computed `changed` set (from
/// [`changed_crates_since`]) and the `impacted` closure (from [`impacted_crates`]);
/// it never reads git or the warehouse.
///
/// READ ok-semantics: `ok ⟺ at least one changed crate` (there is a real re-test
/// set to feed `cargo test -p …`). NOTHING changed since the base ⇒ empty ⇒ RED
/// (RAGNARÖK: an empty pane is never silently green — a "nothing to do" verdict is
/// distinct from a sannr READ result). AUT9.
pub fn impacted_outcome(
    repo: &str,
    base: &str,
    changed: &std::collections::BTreeSet<String>,
    impacted: &std::collections::BTreeSet<String>,
) -> nornir_cli_outcome::CommandOutcome {
    use nornir_cli_outcome::CommandOutcome;
    let cmd = "release impacted-crates";
    if changed.is_empty() {
        return CommandOutcome::fail(
            cmd,
            format!("release impacted-crates: no changed crates in `{repo}` since `{base}`"),
        );
    }
    let changed_v: Vec<&String> = changed.iter().collect();
    let impacted_v: Vec<&String> = impacted.iter().collect();
    let human = format!(
        "{} changed → {} impacted (since {base})\n{}",
        changed.len(),
        impacted.len(),
        impacted_v.iter().map(|c| c.as_str()).collect::<Vec<_>>().join("\n"),
    );
    let data = serde_json::json!({
        "repo": repo,
        "base": base,
        "changed_count": changed.len(),
        "impacted_count": impacted.len(),
        "changed": changed_v,
        "impacted": impacted_v,
    });
    CommandOutcome::ok(cmd, data, human)
}