nornir 0.4.51

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! `nornir release stage` — the brave-but-safe tier: rehearse the whole release
//! cascade against the embedded holger `/sparring` registry before crates.io.
//!
//! This module owns the **plan** (pure, testable): given the cross-repo graph, the
//! topo publish order and, for each repo, the direct dependents to build-verify
//! from `/sparring` once it's published. Execution (cut a release branch →
//! `cargo publish --registry sparring` in order → `cargo build` each dependent
//! against `/sparring` → `seal`) drives this plan; `--dry-run` just prints it.
//!
//! Promote (`release promote`) replays the same order against crates.io once the
//! sparring run is green.

use serde::Serialize;

use crate::release::doctor::{publish_order, RepoGraph};

#[derive(Debug, Clone, Serialize)]
pub struct StageStep {
    /// Repo to package + publish into `/sparring` at this position.
    pub repo: String,
    /// Direct dependents to `cargo build` against `/sparring` after publishing
    /// `repo` — proves the just-published crates actually resolve from a registry.
    pub verify: Vec<String>,
}

#[derive(Debug, Clone, Serialize)]
pub struct StagePlan {
    /// Sparse registry the cascade publishes into, e.g.
    /// `sparse+http://127.0.0.1:18464/sparring/index/`.
    pub registry: String,
    /// Ephemeral release branch the version bumps + cascade live on.
    pub branch: String,
    /// Publish steps in dependency order (dependencies first).
    pub order: Vec<StageStep>,
    /// Repos left unordered by a dependency cycle (must be empty to execute).
    pub cycle: Vec<String>,
}

impl StagePlan {
    /// A plan is executable only when the graph is a clean DAG.
    pub fn is_executable(&self) -> bool {
        self.cycle.is_empty() && !self.order.is_empty()
    }
}

/// Repos that directly depend on `repo` (one hop): X depends on `repo` when X
/// declares a dependency on a crate `repo` produces.
fn direct_dependents(graphs: &[RepoGraph], repo: &str) -> Vec<String> {
    let Some(target) = graphs.iter().find(|g| g.repo == repo) else {
        return vec![];
    };
    let mut out: Vec<String> = graphs
        .iter()
        .filter(|g| g.repo != repo && g.deps.iter().any(|d| target.produces.contains(d)))
        .map(|g| g.repo.clone())
        .collect();
    out.sort();
    out
}

/// Build the stage plan: topo publish order + per-repo verify (direct dependents).
pub fn plan_stage(graphs: &[RepoGraph], registry: &str, branch: &str) -> StagePlan {
    let topo = publish_order(graphs);
    let order = topo
        .order
        .iter()
        .map(|repo| StageStep { repo: repo.clone(), verify: direct_dependents(graphs, repo) })
        .collect();
    StagePlan { registry: registry.to_string(), branch: branch.to_string(), order, cycle: topo.cycle }
}

/// Human-readable plan (the dry-run output).
pub fn format_plan(p: &StagePlan) -> String {
    let mut s = String::new();
    s.push_str("nornir release stage — plan (dry-run)\n\n");
    s.push_str(&format!("  registry: {}\n", p.registry));
    s.push_str(&format!("  branch:   {}\n\n", p.branch));
    if !p.cycle.is_empty() {
        s.push_str(&format!(
            "  ⚠ dependency cycle — NOT executable until resolved: {}\n\n",
            p.cycle.join(", ")
        ));
    }
    s.push_str("  Publish order (dependencies first):\n");
    for (i, step) in p.order.iter().enumerate() {
        s.push_str(&format!("    {}. cargo publish {} → /sparring\n", i + 1, step.repo));
        if !step.verify.is_empty() {
            s.push_str(&format!("       verify (build from /sparring): {}\n", step.verify.join(", ")));
        }
    }
    if p.is_executable() {
        s.push_str("\n  → green plan; run with --execute to publish into /sparring, then promote.\n");
    }
    s
}

// ─────────────────────── execution (steps 1–3) ───────────────────────

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

use anyhow::{Context, Result};

/// Cargo config the cascade runs under: defines the `sparring` registry to publish
/// INTO, and source-replaces crates.io with it so a dependent's build resolves the
/// just-published workspace crates from `/sparring` (everything else falls through
/// `/sparring`'s crates.io upstream).
pub fn cargo_config_toml(http_addr: &str) -> String {
    format!(
        r#"[registries.sparring]
index = "sparse+http://{http_addr}/sparring/index/"

[source.crates-io]
replace-with = "sparring-mirror"

[source.sparring-mirror]
registry = "sparse+http://{http_addr}/sparring/index/"

[net]
retry = 2
"#
    )
}

/// The embedded holger subprocess — killed on drop (the rehearsal registry is
/// ephemeral; a crash/early-return never leaks a server).
struct HolgerProc(Child);
impl Drop for HolgerProc {
    fn drop(&mut self) {
        let _ = self.0.kill();
        let _ = self.0.wait();
    }
}

fn spawn_holger(holger_bin: &Path, data_dir: &Path, grpc: &str, http: &str) -> Result<HolgerProc> {
    let child = Command::new(holger_bin)
        .arg("dev-pair")
        .arg("--data")
        .arg(data_dir)
        .args(["--grpc", grpc, "--http", http])
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .spawn()
        .with_context(|| format!("spawn holger dev-pair via {}", holger_bin.display()))?;
    Ok(HolgerProc(child))
}

/// Poll the HTTP gateway until it serves (or give up after ~15s).
fn wait_ready(http_addr: &str) -> Result<()> {
    let url = format!("http://{http_addr}/cache/index/config.json");
    for _ in 0..60 {
        if ureq::get(&url)
            .timeout(std::time::Duration::from_millis(500))
            .call()
            .is_ok()
        {
            return Ok(());
        }
        std::thread::sleep(std::time::Duration::from_millis(250));
    }
    anyhow::bail!("holger dev-pair never became ready at {url}")
}

#[derive(Debug, Default)]
pub struct StageOutcome {
    /// Repos whose crates published into /sparring.
    pub published: Vec<String>,
    /// Verify targets that built against /sparring.
    pub verified: Vec<String>,
    /// Non-fatal per-step failures (repo: reason).
    pub errors: Vec<String>,
}

/// Steps 1–3: spawn the embedded holger, cut the release branch per repo, then in
/// plan order `cargo publish --workspace` each repo into `/sparring`, then
/// `cargo build` each verify target against `/sparring`. Stops before seal/promote.
/// holger is torn down on return. Per-repo failures are collected, not fatal, so
/// the outcome shows how far the rehearsal got.
pub fn execute(
    plan: &StagePlan,
    repo_paths: &BTreeMap<String, PathBuf>,
    holger_bin: &Path,
    data_dir: &Path,
    grpc: &str,
    http: &str,
) -> Result<StageOutcome> {
    if !plan.is_executable() {
        anyhow::bail!("stage plan not executable (dependency cycle: {})", plan.cycle.join(", "));
    }
    std::fs::create_dir_all(data_dir)?;
    let cfg_path = data_dir.join("cargo-config.toml");
    std::fs::write(&cfg_path, cargo_config_toml(http))?;

    let _holger = spawn_holger(holger_bin, &data_dir.join("registry"), grpc, http)?;
    wait_ready(http)?;
    eprintln!("stage: holger /cache + /sparring ready on http://{http}");

    let mut out = StageOutcome::default();

    // 1 + 2 — cut branch, publish each repo's crates into /sparring in plan order.
    for step in &plan.order {
        let Some(path) = repo_paths.get(&step.repo) else { continue };
        let _ = Command::new("git")
            .arg("-C")
            .arg(path)
            .args(["checkout", "-B", &plan.branch])
            .status(); // best-effort branch isolation
        let status = Command::new("cargo")
            .current_dir(path)
            .args(["publish", "--workspace", "--no-verify", "--allow-dirty", "--registry", "sparring"])
            .arg("--config")
            .arg(&cfg_path)
            .env("CARGO_REGISTRIES_SPARRING_TOKEN", "stage-rehearsal")
            .status()
            .with_context(|| format!("cargo publish {}", step.repo))?;
        if status.success() {
            out.published.push(step.repo.clone());
            eprintln!("stage: published {} → /sparring", step.repo);
        } else {
            out.errors.push(format!("{}: cargo publish failed ({status})", step.repo));
        }
    }

    // 3 — verify each dependent builds against /sparring (unique targets).
    let mut seen = std::collections::BTreeSet::new();
    for step in &plan.order {
        for dep in &step.verify {
            if !seen.insert(dep.clone()) {
                continue;
            }
            let Some(path) = repo_paths.get(dep) else { continue };
            let status = Command::new("cargo")
                .current_dir(path)
                .args(["build", "--quiet"])
                .arg("--config")
                .arg(&cfg_path)
                .status()
                .with_context(|| format!("cargo build {dep}"))?;
            if status.success() {
                out.verified.push(dep.clone());
                eprintln!("stage: verified {} builds from /sparring", dep);
            } else {
                out.errors.push(format!("{dep}: verify build failed ({status})"));
            }
        }
    }

    Ok(out)
}

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

    fn graph(repo: &str, produces: &[&str], deps: &[&str]) -> RepoGraph {
        RepoGraph {
            repo: repo.to_string(),
            produces: produces.iter().map(|s| s.to_string()).collect::<BTreeSet<_>>(),
            deps: deps.iter().map(|s| s.to_string()).collect::<BTreeSet<_>>(),
        }
    }

    /// The plan publishes in dependency order, and each base repo lists the
    /// dependents to verify: znippy & skade before nornir; both list nornir.
    #[test]
    fn plan_orders_topologically_with_verify_targets() {
        let graphs = [
            graph("znippy", &["znippy-common"], &["serde"]),
            graph("skade", &["skade-katalog"], &["arrow"]),
            graph("nornir", &["nornir"], &["znippy-common", "skade-katalog"]),
        ];
        let plan = plan_stage(&graphs, "sparse+http://h/sparring/index/", "release/arrow-58");
        assert!(plan.is_executable(), "clean DAG must be executable");
        assert_eq!(plan.branch, "release/arrow-58");

        let order: Vec<&str> = plan.order.iter().map(|s| s.repo.as_str()).collect();
        let pos = |r: &str| order.iter().position(|x| *x == r).unwrap();
        assert!(pos("znippy") < pos("nornir") && pos("skade") < pos("nornir"));

        let znippy = plan.order.iter().find(|s| s.repo == "znippy").unwrap();
        assert_eq!(znippy.verify, vec!["nornir"], "publishing znippy must verify nornir");
        let nornir = plan.order.iter().find(|s| s.repo == "nornir").unwrap();
        assert!(nornir.verify.is_empty(), "nothing depends on nornir here");
    }

    #[test]
    fn cargo_config_targets_sparring_and_replaces_crates_io() {
        let c = cargo_config_toml("127.0.0.1:18464");
        assert!(c.contains("[registries.sparring]"), "defines the publish registry");
        assert!(c.contains("sparse+http://127.0.0.1:18464/sparring/index/"));
        assert!(c.contains(r#"replace-with = "sparring-mirror""#), "resolves deps via /sparring");
    }

    #[test]
    fn plan_with_cycle_is_not_executable() {
        let graphs = [
            graph("a", &["a-crate"], &["b-crate"]),
            graph("b", &["b-crate"], &["a-crate"]),
        ];
        let plan = plan_stage(&graphs, "reg", "rel");
        assert!(!plan.is_executable(), "a cycle must block execution");
        assert_eq!(plan.cycle.len(), 2);
    }
}