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)
}