use serde::Serialize;
use crate::release::doctor::{publish_order, RepoGraph};
#[derive(Debug, Clone, Serialize)]
pub struct StageStep {
pub repo: String,
pub verify: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct StagePlan {
pub registry: String,
pub branch: String,
pub order: Vec<StageStep>,
pub cycle: Vec<String>,
}
impl StagePlan {
pub fn is_executable(&self) -> bool {
self.cycle.is_empty() && !self.order.is_empty()
}
}
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
}
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 }
}
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
}
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use anyhow::{Context, Result};
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
"#
)
}
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))
}
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 {
pub published: Vec<String>,
pub verified: Vec<String>,
pub errors: Vec<String>,
}
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();
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(); 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));
}
}
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<_>>(),
}
}
#[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);
}
}