use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use std::process::Command;
use anyhow::{anyhow, Context, Result};
pub fn strip_patch_crates_io(cargo_toml: &Path) -> Result<usize> {
let text = std::fs::read_to_string(cargo_toml)
.with_context(|| format!("read {}", cargo_toml.display()))?;
let mut out = String::with_capacity(text.len());
let mut in_patch = false;
let mut stripped = 0usize;
for line in text.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with('[') && !trimmed.starts_with("[[") {
let is_patch = trimmed.starts_with("[patch.crates-io")
|| trimmed.starts_with("[patch.\"crates-io\"")
|| trimmed.starts_with("[patch.'crates-io'");
if is_patch {
in_patch = true;
stripped += 1;
continue;
}
in_patch = false;
}
if !in_patch {
out.push_str(line);
out.push('\n');
}
}
if stripped > 0 {
std::fs::write(cargo_toml, out)
.with_context(|| format!("write {}", cargo_toml.display()))?;
}
Ok(stripped)
}
pub fn strip_patch_crates_io_recursive(repo_root: &Path) -> Result<(usize, usize)> {
let mut files = 0usize;
let mut blocks = 0usize;
walk_cargo_tomls(repo_root, &mut |p| {
let n = strip_patch_crates_io(p)?;
if n > 0 {
files += 1;
blocks += n;
}
Ok(())
})?;
Ok((files, blocks))
}
pub fn yank_cascade(
repo_root: &Path,
order: &[(String, String)],
undo: bool,
dry_run: bool,
) -> Result<Vec<(String, String, YankStatus)>> {
let mut results = Vec::with_capacity(order.len());
for (krate, ver) in order {
let status = if dry_run {
YankStatus::DryRun
} else {
let mut cmd = Command::new("cargo");
cmd.current_dir(repo_root).arg("yank");
if undo {
cmd.arg("--undo");
}
cmd.args(["--version", ver, krate]);
let out = cmd.output().with_context(|| {
format!("spawn cargo yank for {krate}@{ver}")
})?;
if out.status.success() {
if undo { YankStatus::Unyanked } else { YankStatus::Yanked }
} else {
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
results.push((krate.clone(), ver.clone(), YankStatus::Failed(stderr.clone())));
return Err(anyhow!(
"yank {krate}@{ver} failed; processed={}/{} — partial state recorded",
results.len() - 1, order.len()
));
}
};
results.push((krate.clone(), ver.clone(), status));
}
Ok(results)
}
#[derive(Debug, Clone)]
pub enum YankStatus {
Yanked,
Unyanked,
DryRun,
Failed(String),
}
pub fn impacted_crates(
changed: &BTreeSet<String>,
edges: &[(String, String)],
) -> BTreeSet<String> {
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
}
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")?;
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::Value>() {
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)
}
pub fn flake_candidates(
runs: &[(String, String, bool, i64)],
window: usize,
min_failures: usize,
) -> Vec<FlakeCandidate> {
let mut grouped: BTreeMap<(String, String), Vec<(bool, i64)>> = BTreeMap::new();
for (test_id, repo, ok, ts) in runs {
grouped
.entry((test_id.clone(), repo.clone()))
.or_default()
.push((*ok, *ts));
}
let mut out = Vec::new();
for ((test_id, repo), mut hist) in grouped {
hist.sort_by_key(|r| -r.1);
let window = hist.iter().take(window).copied().collect::<Vec<_>>();
let fails = window.iter().filter(|r| !r.0).count();
let passes = window.iter().filter(|r| r.0).count();
if fails >= min_failures && passes > 0 {
out.push(FlakeCandidate {
test_id,
repo,
window_size: window.len(),
fails,
passes,
});
}
}
out
}
#[derive(Debug, Clone)]
pub struct FlakeCandidate {
pub test_id: String,
pub repo: String,
pub window_size: usize,
pub fails: usize,
pub passes: usize,
}
pub fn changelog_markdown(repo_root: &Path, range: &str) -> Result<String> {
use gix::bstr::ByteSlice;
let repo = gix::open(repo_root)
.with_context(|| format!("gix::open {}", repo_root.display()))?;
let (from, to) = match range.split_once("..") {
Some((f, t)) => (Some(f.trim()), t.trim()),
None => (None, range.trim()),
};
let to = if to.is_empty() { "HEAD" } else { to };
let tip = repo
.rev_parse_single(to)
.with_context(|| format!("resolve rev `{to}`"))?
.detach();
let mut walk = repo.rev_walk([tip]);
if let Some(from) = from.filter(|f| !f.is_empty()) {
let hidden = repo
.rev_parse_single(from)
.with_context(|| format!("resolve rev `{from}`"))?
.detach();
walk = walk.with_hidden([hidden]);
}
let mut feats = Vec::new();
let mut fixes = Vec::new();
let mut breaks = Vec::new();
let mut other = Vec::new();
for info in walk.all().with_context(|| format!("walk `{range}`"))? {
let info = info.context("walk commit")?;
let commit = info.object().context("read commit")?;
let raw = commit.message_raw_sloppy();
let subject = raw.lines().next().unwrap_or_default().to_str_lossy();
let subject = subject.trim();
if subject.is_empty() {
continue;
}
let sha = info.id().to_string();
let short = &sha[..sha.len().min(8)];
let parsed = parse_conventional(subject);
let entry = format!("- {} ({})", parsed.message, short);
if parsed.breaking {
breaks.push(entry);
} else {
match parsed.kind.as_deref() {
Some("feat") => feats.push(entry),
Some("fix") => fixes.push(entry),
_ => other.push(entry),
}
}
}
let mut md = String::new();
if !breaks.is_empty() {
md.push_str("### Breaking changes\n\n");
for e in &breaks { md.push_str(e); md.push('\n'); }
md.push('\n');
}
if !feats.is_empty() {
md.push_str("### Features\n\n");
for e in &feats { md.push_str(e); md.push('\n'); }
md.push('\n');
}
if !fixes.is_empty() {
md.push_str("### Fixes\n\n");
for e in &fixes { md.push_str(e); md.push('\n'); }
md.push('\n');
}
if !other.is_empty() {
md.push_str("### Other\n\n");
for e in &other { md.push_str(e); md.push('\n'); }
md.push('\n');
}
if md.is_empty() {
md.push_str("_No commits in range._\n");
}
Ok(md)
}
struct ConvCommit {
kind: Option<String>,
breaking: bool,
message: String,
}
fn parse_conventional(subject: &str) -> ConvCommit {
if let Some(colon) = subject.find(':') {
let (head, rest) = subject.split_at(colon);
let body = rest.trim_start_matches(':').trim().to_string();
let (kind_part, breaking) = if let Some(stripped) = head.strip_suffix('!') {
(stripped, true)
} else {
(head, false)
};
let kind = kind_part.split('(').next().unwrap_or(kind_part).trim();
if !kind.is_empty()
&& kind.len() < 32
&& kind.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
{
return ConvCommit {
kind: Some(kind.to_lowercase()),
breaking,
message: body,
};
}
}
ConvCommit {
kind: None,
breaking: false,
message: subject.to_string(),
}
}
pub fn mirror_to_holger(
repo_root: &Path,
krate: &str,
version: &str,
holger_base_url: &str,
token: Option<&str>,
) -> Result<u64> {
let tarball = repo_root
.join("target/package")
.join(format!("{krate}-{version}.crate"));
let bytes = std::fs::metadata(&tarball)
.with_context(|| format!("missing tarball {}", tarball.display()))?
.len();
let url = format!("{}/api/v1/crates/new", holger_base_url.trim_end_matches('/'));
let mut cmd = Command::new("curl");
cmd.args(["-fsS", "-X", "PUT", "--data-binary", &format!("@{}", tarball.display())]);
if let Some(tok) = token {
cmd.args(["-H", &format!("Authorization: Bearer {tok}")]);
}
cmd.arg(&url);
let out = cmd.output().context("spawn curl for holger upload")?;
if !out.status.success() {
return Err(anyhow!(
"holger upload failed for {krate}@{version}: {}",
String::from_utf8_lossy(&out.stderr)
));
}
Ok(bytes)
}
pub fn plan_version_bump(
repo_root: &Path,
pkg_name: &str,
new_version: &str,
bump_consumers: bool,
) -> Result<VersionBumpPlan> {
let mut edits = Vec::new();
walk_cargo_tomls(repo_root, &mut |path| {
let text = std::fs::read_to_string(path)
.with_context(|| format!("read {}", path.display()))?;
let doc: toml_edit::DocumentMut = match text.parse() {
Ok(d) => d,
Err(_) => return Ok(()),
};
if let Some(this_name) = doc
.get("package")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
{
if this_name == pkg_name {
let old = doc
.get("package")
.and_then(|p| p.get("version"))
.and_then(|v| v.as_str())
.unwrap_or("(unset)")
.to_string();
edits.push(VersionEdit {
cargo_toml: path.to_path_buf(),
location: EditKind::OwnPackageVersion,
pkg: pkg_name.to_string(),
old_version: old,
new_version: new_version.to_string(),
});
}
}
let mut touched_consumer = false;
for table_name in DEP_TABLE_NAMES {
if let Some(table) = doc.get(table_name).and_then(|t| t.as_table()) {
if let Some(old) = read_dep_version(table, pkg_name) {
edits.push(VersionEdit {
cargo_toml: path.to_path_buf(),
location: EditKind::DepTable((*table_name).to_string()),
pkg: pkg_name.to_string(),
old_version: old,
new_version: new_version.to_string(),
});
touched_consumer = true;
}
}
}
if let Some(ws_deps) = doc
.get("workspace")
.and_then(|w| w.get("dependencies"))
.and_then(|d| d.as_table())
{
if let Some(old) = read_dep_version(ws_deps, pkg_name) {
edits.push(VersionEdit {
cargo_toml: path.to_path_buf(),
location: EditKind::WorkspaceDep,
pkg: pkg_name.to_string(),
old_version: old,
new_version: new_version.to_string(),
});
touched_consumer = true;
}
}
if bump_consumers && touched_consumer {
if let (Some(consumer_name), Some(consumer_ver)) = (
doc.get("package").and_then(|p| p.get("name")).and_then(|n| n.as_str()),
doc.get("package").and_then(|p| p.get("version")).and_then(|v| v.as_str()),
) {
if consumer_name != pkg_name {
if let Some(bumped) = bump_patch(consumer_ver) {
edits.push(VersionEdit {
cargo_toml: path.to_path_buf(),
location: EditKind::OwnPackageVersion,
pkg: consumer_name.to_string(),
old_version: consumer_ver.to_string(),
new_version: bumped,
});
}
}
}
}
Ok(())
})?;
Ok(VersionBumpPlan { edits })
}
pub fn apply_bump_plan(plan: &VersionBumpPlan) -> Result<usize> {
let mut by_file: BTreeMap<&Path, Vec<&VersionEdit>> = BTreeMap::new();
for e in &plan.edits {
by_file.entry(&e.cargo_toml).or_default().push(e);
}
let mut written = 0usize;
for (path, edits) in by_file {
let text = std::fs::read_to_string(path)
.with_context(|| format!("read {}", path.display()))?;
let mut doc: toml_edit::DocumentMut = text
.parse()
.with_context(|| format!("parse {}", path.display()))?;
for e in edits {
apply_one_edit(&mut doc, e)?;
}
let tmp = path.with_extension("toml.tmp");
std::fs::write(&tmp, doc.to_string())
.with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, path)
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
written += 1;
}
Ok(written)
}
fn apply_one_edit(doc: &mut toml_edit::DocumentMut, e: &VersionEdit) -> Result<()> {
match &e.location {
EditKind::OwnPackageVersion => {
let v = doc
.get_mut("package")
.and_then(|p| p.as_table_mut())
.and_then(|t| t.get_mut("version"))
.ok_or_else(|| anyhow!("no [package].version in {}", e.cargo_toml.display()))?;
*v = toml_edit::value(e.new_version.clone());
}
EditKind::DepTable(table_name) => {
let table = doc
.get_mut(table_name)
.and_then(|t| t.as_table_mut())
.ok_or_else(|| anyhow!("no [{table_name}] in {}", e.cargo_toml.display()))?;
write_dep_version(table, &e.pkg, &e.new_version)?;
}
EditKind::WorkspaceDep => {
let table = doc
.get_mut("workspace")
.and_then(|w| w.as_table_mut())
.and_then(|w| w.get_mut("dependencies"))
.and_then(|d| d.as_table_mut())
.ok_or_else(|| anyhow!("no [workspace.dependencies] in {}", e.cargo_toml.display()))?;
write_dep_version(table, &e.pkg, &e.new_version)?;
}
}
Ok(())
}
fn read_dep_version(table: &toml_edit::Table, pkg: &str) -> Option<String> {
let item = table.get(pkg)?;
if let Some(s) = item.as_str() {
return Some(s.to_string());
}
if let Some(t) = item.as_inline_table() {
if let Some(v) = t.get("version").and_then(|v| v.as_str()) {
return Some(v.to_string());
}
}
if let Some(t) = item.as_table() {
if let Some(v) = t.get("version").and_then(|v| v.as_str()) {
return Some(v.to_string());
}
}
None
}
fn write_dep_version(table: &mut toml_edit::Table, pkg: &str, new_version: &str) -> Result<()> {
let item = table
.get_mut(pkg)
.ok_or_else(|| anyhow!("dep `{pkg}` vanished between plan and apply"))?;
if item.is_str() {
*item = toml_edit::value(new_version.to_string());
return Ok(());
}
if let Some(t) = item.as_inline_table_mut() {
t.insert("version", new_version.into());
return Ok(());
}
if let Some(t) = item.as_table_mut() {
t.insert("version", toml_edit::value(new_version.to_string()));
return Ok(());
}
Err(anyhow!("unsupported dep table shape for `{pkg}`"))
}
fn bump_patch(v: &str) -> Option<String> {
let mut parts = v.split('.');
let major = parts.next()?.parse::<u64>().ok()?;
let minor = parts.next()?.parse::<u64>().ok()?;
let patch_and_rest = parts.next()?;
let patch_str: String = patch_and_rest.chars().take_while(|c| c.is_ascii_digit()).collect();
let patch: u64 = patch_str.parse().ok()?;
Some(format!("{}.{}.{}", major, minor, patch + 1))
}
const DEP_TABLE_NAMES: &[&str] = &[
"dependencies",
"dev-dependencies",
"build-dependencies",
];
#[derive(Debug, Clone)]
pub struct VersionBumpPlan {
pub edits: Vec<VersionEdit>,
}
#[derive(Debug, Clone)]
pub struct VersionEdit {
pub cargo_toml: std::path::PathBuf,
pub location: EditKind,
pub pkg: String,
pub old_version: String,
pub new_version: String,
}
#[derive(Debug, Clone)]
pub enum EditKind {
OwnPackageVersion,
DepTable(String),
WorkspaceDep,
}
fn walk_cargo_tomls<F: FnMut(&Path) -> Result<()>>(
root: &Path,
f: &mut F,
) -> Result<()> {
for entry in std::fs::read_dir(root).with_context(|| format!("read {}", root.display()))? {
let entry = entry?;
let p = entry.path();
let file_name = entry.file_name();
let name = file_name.to_string_lossy();
if p.is_dir() {
if matches!(name.as_ref(), "target" | ".git" | "node_modules") {
continue;
}
walk_cargo_tomls(&p, f)?;
} else if name == "Cargo.toml" {
f(&p)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_removes_patch_block_only() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("Cargo.toml");
std::fs::write(&p, r#"[package]
name = "x"
version = "0.1.0"
[dependencies]
serde = "1"
[patch.crates-io]
znippy = { path = "../znippy" }
holger-core = { path = "../holger" }
[features]
default = []
"#).unwrap();
let n = strip_patch_crates_io(&p).unwrap();
assert_eq!(n, 1);
let after = std::fs::read_to_string(&p).unwrap();
assert!(!after.contains("[patch.crates-io]"));
assert!(!after.contains("znippy"));
assert!(after.contains("[features]"));
assert!(after.contains("serde"));
}
#[test]
fn impacted_closes_reverse_deps() {
let edges = vec![
("agent-cli".to_string(), "holger-core".to_string()),
("holger-core".to_string(), "znippy-fs".to_string()),
("unrelated".to_string(), "other".to_string()),
];
let changed: BTreeSet<_> = ["znippy-fs".to_string()].into_iter().collect();
let imp = impacted_crates(&changed, &edges);
assert!(imp.contains("znippy-fs"));
assert!(imp.contains("holger-core"));
assert!(imp.contains("agent-cli"));
assert!(!imp.contains("unrelated"));
}
#[test]
fn flake_only_when_mixed() {
let runs = vec![
("t1".into(), "r".into(), false, 5),
("t1".into(), "r".into(), true, 4),
("t1".into(), "r".into(), false, 3),
("t2".into(), "r".into(), false, 5),
("t2".into(), "r".into(), false, 4),
("t3".into(), "r".into(), true, 5),
("t3".into(), "r".into(), true, 4),
];
let cands = flake_candidates(&runs, 10, 2);
assert_eq!(cands.len(), 1);
assert_eq!(cands[0].test_id, "t1");
}
#[test]
fn conventional_parser_recognizes_breaking() {
let c = parse_conventional("feat!: rip out v1 API");
assert_eq!(c.kind.as_deref(), Some("feat"));
assert!(c.breaking);
assert_eq!(c.message, "rip out v1 API");
let c = parse_conventional("fix(parser): handle empty input");
assert_eq!(c.kind.as_deref(), Some("fix"));
assert!(!c.breaking);
let c = parse_conventional("random commit no prefix");
assert!(c.kind.is_none());
}
#[test]
fn version_bump_touches_package_and_all_dep_table_shapes() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("consumer")).unwrap();
std::fs::create_dir_all(root.join("bumped")).unwrap();
std::fs::write(root.join("Cargo.toml"), r#"[workspace]
members = ["bumped", "consumer"]
[workspace.dependencies]
bumped = { version = "1.2.3", path = "bumped" }
"#).unwrap();
std::fs::write(root.join("bumped/Cargo.toml"), r#"[package]
name = "bumped"
version = "1.2.3"
edition = "2021"
"#).unwrap();
std::fs::write(root.join("consumer/Cargo.toml"), r#"[package]
name = "consumer"
version = "0.5.0"
edition = "2021"
[dependencies]
bumped = "1.2.3"
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
bumped = { version = "1.2.3", path = "../bumped" }
"#).unwrap();
let plan = plan_version_bump(root, "bumped", "2.0.0", true).unwrap();
assert_eq!(plan.edits.len(), 5, "{:#?}", plan.edits);
let n = apply_bump_plan(&plan).unwrap();
assert_eq!(n, 3);
let consumer = std::fs::read_to_string(root.join("consumer/Cargo.toml")).unwrap();
assert!(consumer.contains(r#"version = "0.5.1""#), "consumer auto-bumped: {consumer}");
assert!(consumer.contains(r#"bumped = "2.0.0""#));
assert!(consumer.contains(r#"version = "2.0.0""#));
let bumped = std::fs::read_to_string(root.join("bumped/Cargo.toml")).unwrap();
assert!(bumped.contains(r#"version = "2.0.0""#));
let ws = std::fs::read_to_string(root.join("Cargo.toml")).unwrap();
assert!(ws.contains(r#"version = "2.0.0""#), "ws dep updated: {ws}");
}
}