use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result};
use crate::release::cargo::{
apply_bump_plan, apply_skew_bumps, AppliedBump, VersionBumpPlan,
};
use crate::warehouse::iceberg::IcebergWarehouse;
use crate::warehouse::release_changes::{
self, ChangeKind, ChangeRecorder, ReleaseChange,
};
pub fn record_bump(
wh: &IcebergWarehouse,
rec: &ChangeRecorder,
repo: &str,
plan: &VersionBumpPlan,
) -> Result<usize> {
let mut files: BTreeMap<PathBuf, String> = BTreeMap::new();
for e in &plan.edits {
if let std::collections::btree_map::Entry::Vacant(slot) =
files.entry(e.cargo_toml.clone())
{
let text = std::fs::read_to_string(&e.cargo_toml)
.with_context(|| format!("read before-state {}", e.cargo_toml.display()))?;
slot.insert(text);
}
}
let n = apply_bump_plan(plan)?;
for (path, before) in &files {
let edit = plan.edits.iter().find(|e| &e.cargo_toml == path);
let (crate_name, old_v, new_v) = edit
.map(|e| (e.pkg.clone(), e.old_version.clone(), e.new_version.clone()))
.unwrap_or_default();
rec.record(
wh,
repo,
ChangeKind::Bump {
crate_name,
old_version: old_v,
new_version: new_v,
file: path.to_string_lossy().into_owned(),
old_file_text: before.clone(),
},
)?;
}
Ok(n)
}
pub fn record_skew_bumps(
wh: &IcebergWarehouse,
rec: &ChangeRecorder,
skew: &[crate::release::doctor::CrateSkew],
repo_paths: &BTreeMap<String, PathBuf>,
) -> Result<Vec<AppliedBump>> {
let mut applied = Vec::new();
for c in skew {
for repo in c.bump_repos() {
let Some(root) = repo_paths.get(repo) else { continue };
let plan = crate::release::cargo::plan_version_bump(root, &c.crate_name, &c.target, false)
.with_context(|| format!("plan bump {} → {} in {repo}", c.crate_name, c.target))?;
if plan.edits.is_empty() {
continue;
}
let files = record_bump(wh, rec, repo, &plan)
.with_context(|| format!("apply+record bump {} → {} in {repo}", c.crate_name, c.target))?;
applied.push(AppliedBump {
repo: repo.to_string(),
crate_name: c.crate_name.clone(),
target: c.target.clone(),
files,
});
}
}
Ok(applied)
}
pub fn record_applied_bump(
wh: &IcebergWarehouse,
rec: &ChangeRecorder,
repo: &str,
crate_name: &str,
old_version: &str,
new_version: &str,
edits: &[(PathBuf, String)],
) -> Result<()> {
for (path, before) in edits {
rec.record(
wh,
repo,
ChangeKind::Bump {
crate_name: crate_name.to_string(),
old_version: old_version.to_string(),
new_version: new_version.to_string(),
file: path.to_string_lossy().into_owned(),
old_file_text: before.clone(),
},
)?;
}
Ok(())
}
pub fn record_patch_strip(
wh: &IcebergWarehouse,
rec: &ChangeRecorder,
repo: &str,
cargo_toml: &Path,
) -> Result<usize> {
let before = std::fs::read_to_string(cargo_toml)
.with_context(|| format!("read before-state {}", cargo_toml.display()))?;
let n = crate::release::cargo::strip_patch_crates_io(cargo_toml)?;
if n == 0 {
return Ok(0);
}
let after = std::fs::read_to_string(cargo_toml).unwrap_or_default();
let _ = after;
rec.record(
wh,
repo,
ChangeKind::PatchStrip {
file: cargo_toml.to_string_lossy().into_owned(),
removed_block: before,
},
)?;
Ok(n)
}
pub fn record_branch(
wh: &IcebergWarehouse,
rec: &ChangeRecorder,
repo: &str,
branch: &str,
prev_head: &str,
) -> Result<()> {
rec.record(
wh,
repo,
ChangeKind::Branch {
repo: repo.to_string(),
branch: branch.to_string(),
prev_head: prev_head.to_string(),
},
)
}
pub fn record_publish(
wh: &IcebergWarehouse,
rec: &ChangeRecorder,
repo: &str,
crate_name: &str,
version: &str,
registry: &str,
immutable: bool,
) -> Result<()> {
rec.record(
wh,
repo,
ChangeKind::Publish {
crate_name: crate_name.to_string(),
version: version.to_string(),
registry: registry.to_string(),
immutable,
},
)
}
#[derive(Debug, Clone)]
pub struct UndoStep {
pub seq: i64,
pub kind: String,
pub action: String,
pub partial: bool,
}
pub fn undo_release(
wh: &IcebergWarehouse,
release_id: Option<&str>,
repo_paths: &BTreeMap<String, PathBuf>,
dry_run: bool,
yank: bool,
) -> Result<Vec<UndoStep>> {
let id = match release_id {
Some(id) => id.to_string(),
None => wh
.block_on(release_changes::latest_release_id(wh))?
.context("release undo: the release_changes ledger is empty (nothing to undo)")?,
};
let mut records: Vec<ReleaseChange> =
wh.block_on(release_changes::query_release_changes(wh, Some(&id)))?;
if records.is_empty() {
anyhow::bail!("release undo: no records for release id `{id}`");
}
records.sort_by_key(|r| std::cmp::Reverse(r.seq));
let mut steps = Vec::new();
for r in &records {
let step = reverse_one(r, repo_paths, dry_run, yank)?;
steps.push(step);
}
Ok(steps)
}
fn reverse_one(
r: &ReleaseChange,
repo_paths: &BTreeMap<String, PathBuf>,
dry_run: bool,
yank: bool,
) -> Result<UndoStep> {
match &r.change {
ChangeKind::Bump { file, old_file_text, crate_name, new_version, old_version } => {
let action = format!(
"un-bump {crate_name} {new_version}→{old_version} (restore {file})"
);
if !dry_run {
std::fs::write(file, old_file_text)
.with_context(|| format!("restore {file}"))?;
}
Ok(UndoStep { seq: r.seq, kind: "bump".into(), action, partial: false })
}
ChangeKind::PatchStrip { file, removed_block } => {
let action = format!("restore [patch.crates-io] in {file}");
if !dry_run {
std::fs::write(file, removed_block)
.with_context(|| format!("restore patch in {file}"))?;
}
Ok(UndoStep { seq: r.seq, kind: "patch_strip".into(), action, partial: false })
}
ChangeKind::Branch { repo, branch, prev_head } => {
let path = repo_paths.get(repo);
let action = if prev_head.is_empty() {
format!("delete branch {branch} in {repo} (was freshly created)")
} else {
format!("reset branch {branch} in {repo} → {prev_head}")
};
if !dry_run {
if let Some(p) = path {
if prev_head.is_empty() {
let _ = Command::new("git").arg("-C").arg(p)
.args(["branch", "-D", branch]).status();
} else {
let _ = Command::new("git").arg("-C").arg(p)
.args(["update-ref", &format!("refs/heads/{branch}"), prev_head])
.status();
}
}
}
Ok(UndoStep { seq: r.seq, kind: "branch".into(), action, partial: path.is_none() })
}
ChangeKind::Publish { crate_name, version, registry, immutable } => {
if *immutable {
let action = format!(
"YANK {crate_name}@{version} from {registry} (immutable — cannot un-publish)"
);
if !dry_run && yank {
let out = Command::new("cargo")
.args(["yank", "--version", version, crate_name])
.output()
.context("spawn cargo yank")?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
if !stderr.contains("already yanked") {
anyhow::bail!("cargo yank {crate_name}@{version} failed: {stderr}");
}
}
}
Ok(UndoStep { seq: r.seq, kind: "publish".into(), action, partial: true })
} else {
Ok(UndoStep {
seq: r.seq,
kind: "publish".into(),
action: format!("{crate_name}@{version} was a {registry} rehearsal (no-op)"),
partial: false,
})
}
}
}
}
pub fn format_undo(release_id: &str, steps: &[UndoStep], dry_run: bool) -> String {
let mut s = String::new();
let mode = if dry_run { " (dry-run)" } else { "" };
s.push_str(&format!("nornir release undo{mode} — release {release_id}\n\n"));
if steps.is_empty() {
s.push_str(" (nothing to reverse)\n");
return s;
}
for st in steps {
let mark = if st.partial { "⚠" } else { "↩" };
s.push_str(&format!(" {mark} [{:>3}] {}: {}\n", st.seq, st.kind, st.action));
}
s
}
#[cfg(test)]
mod tests {
use super::*;
use crate::release::cargo::plan_version_bump;
#[test]
fn bump_record_undo_restores_file_byte_for_byte() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let manifest = root.join("Cargo.toml");
let original = "[package]\nname = \"x\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\narrow = \"57\"\n";
std::fs::write(&manifest, original).unwrap();
let wh = IcebergWarehouse::open(&root.join("wh")).unwrap();
let rec = ChangeRecorder::new("run-1");
let plan = plan_version_bump(root, "arrow", "58.3.0", false).unwrap();
assert!(!plan.edits.is_empty());
record_bump(&wh, &rec, "x", &plan).unwrap();
let after = std::fs::read_to_string(&manifest).unwrap();
assert!(after.contains("arrow = \"58.3.0\""), "bump applied: {after}");
assert_ne!(after, original);
let paths: BTreeMap<String, PathBuf> = [("x".to_string(), root.to_path_buf())].into();
let steps = undo_release(&wh, Some("run-1"), &paths, false, false).unwrap();
assert!(steps.iter().any(|s| s.kind == "bump"));
let restored = std::fs::read_to_string(&manifest).unwrap();
assert_eq!(restored, original, "undo restored the manifest byte-for-byte");
}
#[test]
fn patch_strip_record_undo_restores_block() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let manifest = root.join("Cargo.toml");
let original = "[package]\nname = \"x\"\nversion = \"0.1.0\"\n\n[dependencies]\niceberg = \"0.9\"\n\n[patch.crates-io]\niceberg = { path = \"../iceberg-arrow58\" }\n";
std::fs::write(&manifest, original).unwrap();
let wh = IcebergWarehouse::open(&root.join("wh")).unwrap();
let rec = ChangeRecorder::new("run-2");
let n = record_patch_strip(&wh, &rec, "x", &manifest).unwrap();
assert_eq!(n, 1);
let stripped = std::fs::read_to_string(&manifest).unwrap();
assert!(!stripped.contains("[patch.crates-io]"), "patch stripped: {stripped}");
let paths: BTreeMap<String, PathBuf> = [("x".to_string(), root.to_path_buf())].into();
let steps = undo_release(&wh, Some("run-2"), &paths, false, false).unwrap();
assert!(steps.iter().any(|s| s.kind == "patch_strip"));
let restored = std::fs::read_to_string(&manifest).unwrap();
assert_eq!(restored, original, "undo restored the [patch] block");
}
#[test]
fn undo_reverses_newest_first_and_immutable_publish_is_partial() {
let dir = tempfile::tempdir().unwrap();
let wh = IcebergWarehouse::open(dir.path()).unwrap();
let rec = ChangeRecorder::new("run-3");
record_branch(&wh, &rec, "nornir", "release/staging", "abc123").unwrap();
record_publish(&wh, &rec, "nornir", "nornir", "0.2.0", "crates.io", true).unwrap();
let paths: BTreeMap<String, PathBuf> = BTreeMap::new();
let steps = undo_release(&wh, Some("run-3"), &paths, true, false).unwrap();
assert_eq!(steps[0].kind, "publish");
assert!(steps[0].partial, "immutable publish → yank (partial)");
assert_eq!(steps[1].kind, "branch");
}
#[test]
fn undo_defaults_to_most_recent_run() {
let dir = tempfile::tempdir().unwrap();
let wh = IcebergWarehouse::open(dir.path()).unwrap();
let rec = ChangeRecorder::new("only-run");
record_branch(&wh, &rec, "r", "b", "").unwrap();
let paths: BTreeMap<String, PathBuf> = BTreeMap::new();
let steps = undo_release(&wh, None, &paths, true, false).unwrap();
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].kind, "branch");
}
}