use std::collections::BTreeMap;
use super::ybf::Ybf;
use crate::release::change::ChangeKind;
use crate::release::discovered::DiscoveredCrate;
use crate::release::error::Diagnostic;
use crate::release::metadata::CrateMetadata;
#[derive(Debug, Clone, PartialEq, Eq)]
#[allow(dead_code)]
pub(crate) struct CrateBump {
#[allow(dead_code)]
pub(crate) crate_name: String,
pub(crate) from: Ybf,
pub(crate) to: Ybf,
pub(crate) changed: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[allow(dead_code)]
pub(crate) struct BumpPlan {
pub(crate) bumps: BTreeMap<String, CrateBump>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub(crate) struct BumpError {
pub(crate) diagnostics: Vec<Diagnostic>,
}
impl std::fmt::Display for BumpError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.diagnostics.len() == 1 {
write!(
formatter,
"bump computation failed: {}",
self.diagnostics[0]
)
} else {
writeln!(
formatter,
"bump computation failed ({} findings):",
self.diagnostics.len()
)?;
for d in &self.diagnostics {
writeln!(formatter, " {d}")?;
}
Ok(())
}
}
}
impl std::error::Error for BumpError {}
#[allow(dead_code)]
pub(crate) fn compute_bump_plan(
current_versions: &BTreeMap<String, Ybf>,
change_kinds: &BTreeMap<String, ChangeKind>,
crates: &[DiscoveredCrate],
) -> Result<BumpPlan, BumpError> {
let mut diagnostics: Vec<Diagnostic> = Vec::new();
let mut bumps: BTreeMap<String, CrateBump> = BTreeMap::new();
let mut units: BTreeMap<String, Vec<&DiscoveredCrate>> = BTreeMap::new();
for c in crates {
let unit = match &c.metadata {
CrateMetadata::Valid(md) if md.publish => match &md.release_unit {
Some(u) => u.clone(),
None => continue,
},
_ => continue,
};
units.entry(unit).or_default().push(c);
}
for (unit, members) in &units {
let kind = change_kinds.get(unit).copied().unwrap_or(ChangeKind::None);
let anchor = match members.first() {
Some(c) => match current_versions.get(&c.name) {
Some(v) => *v,
None => {
diagnostics.push(Diagnostic {
crate_name: c.name.clone(),
message: format!(
"current version not found for {unit} member {name}",
name = c.name
),
});
continue;
}
},
None => continue,
};
if members.len() > 1 {
for m in members {
if let Some(v) = current_versions.get(&m.name)
&& *v != anchor
{
diagnostics.push(Diagnostic {
crate_name: m.name.clone(),
message: format!(
"core member {name} has version {v} but {first} has {anchor}; \
release-unit members must share one version",
name = m.name,
first = members[0].name,
),
});
}
}
}
let target = match kind {
ChangeKind::None => anchor,
ChangeKind::Compatible => anchor.bump_fix(),
ChangeKind::Breaking => anchor.bump_break(),
};
for m in members {
if let Some(from) = current_versions.get(&m.name) {
bumps.insert(
m.name.clone(),
CrateBump {
crate_name: m.name.clone(),
from: *from,
to: target,
changed: kind != ChangeKind::None,
},
);
}
}
}
if diagnostics.is_empty() {
Ok(BumpPlan { bumps })
} else {
diagnostics.sort_by(|a, b| {
a.crate_name
.cmp(&b.crate_name)
.then(a.message.cmp(&b.message))
});
Err(BumpError { diagnostics })
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::release::metadata::ReleaseMetadata;
use crate::release::metadata::Role;
fn discovered(name: &str, unit: &str) -> DiscoveredCrate {
DiscoveredCrate {
name: name.to_string(),
manifest_path: std::path::PathBuf::from(format!("/repo/crates/{name}/Cargo.toml")),
publishable: true,
proc_macro: name == "arcature-dx",
metadata: CrateMetadata::Valid(ReleaseMetadata {
publish: true,
role: Some(if name == "arcature-dx" {
Role::ProcMacro
} else if unit == "core" {
Role::Facade
} else {
Role::Subsystem
}),
release_unit: Some(unit.to_string()),
}),
}
}
fn versions(list: &[(&str, &str)]) -> BTreeMap<String, Ybf> {
list.iter()
.map(|(n, v)| (n.to_string(), Ybf::parse(v).unwrap()))
.collect()
}
#[test]
fn none_keeps_version() {
let crates = vec![discovered("arcature-auth", "arcature-auth")];
let current = versions(&[("arcature-auth", "2026.1.0")]);
let changes = BTreeMap::new();
let plan = compute_bump_plan(¤t, &changes, &crates).unwrap();
let bump = &plan.bumps["arcature-auth"];
assert!(!bump.changed);
assert_eq!(bump.from, Ybf::parse("2026.1.0").unwrap());
assert_eq!(bump.to, Ybf::parse("2026.1.0").unwrap());
}
#[test]
fn compatible_bumps_fix() {
let crates = vec![discovered("arcature-auth", "arcature-auth")];
let current = versions(&[("arcature-auth", "2026.1.3")]);
let changes = BTreeMap::from([("arcature-auth".to_string(), ChangeKind::Compatible)]);
let plan = compute_bump_plan(¤t, &changes, &crates).unwrap();
let bump = &plan.bumps["arcature-auth"];
assert!(bump.changed);
assert_eq!(bump.to, Ybf::parse("2026.1.4").unwrap());
}
#[test]
fn breaking_bumps_break_and_resets_fix() {
let crates = vec![discovered("arcature-auth", "arcature-auth")];
let current = versions(&[("arcature-auth", "2026.1.5")]);
let changes = BTreeMap::from([("arcature-auth".to_string(), ChangeKind::Breaking)]);
let plan = compute_bump_plan(¤t, &changes, &crates).unwrap();
let bump = &plan.bumps["arcature-auth"];
assert_eq!(bump.to, Ybf::parse("2026.2.0").unwrap());
}
#[test]
fn core_bumps_both_members_to_same_version() {
let crates = vec![
discovered("arcature", "core"),
discovered("arcature-dx", "core"),
];
let current = versions(&[("arcature", "2026.1.0"), ("arcature-dx", "2026.1.0")]);
let changes = BTreeMap::from([("core".to_string(), ChangeKind::Compatible)]);
let plan = compute_bump_plan(¤t, &changes, &crates).unwrap();
assert_eq!(plan.bumps["arcature"].to, Ybf::parse("2026.1.1").unwrap());
assert_eq!(
plan.bumps["arcature-dx"].to,
Ybf::parse("2026.1.1").unwrap()
);
}
#[test]
fn core_member_version_mismatch_is_error() {
let crates = vec![
discovered("arcature", "core"),
discovered("arcature-dx", "core"),
];
let current = versions(&[("arcature", "2026.1.0"), ("arcature-dx", "2026.1.1")]);
let changes = BTreeMap::from([("core".to_string(), ChangeKind::Compatible)]);
let err = compute_bump_plan(¤t, &changes, &crates).expect_err("mismatch");
assert!(!err.diagnostics.is_empty());
let msg = err.diagnostics[0].message.clone();
assert!(msg.contains("must share one version"), "{msg}");
}
#[test]
fn missing_current_version_is_error() {
let crates = vec![discovered("arcature-auth", "arcature-auth")];
let current = BTreeMap::new();
let changes = BTreeMap::from([("arcature-auth".to_string(), ChangeKind::Compatible)]);
let err = compute_bump_plan(¤t, &changes, &crates).expect_err("missing version");
assert!(err.diagnostics[0].message.contains("not found"));
}
#[test]
fn independent_units_bump_independently() {
let crates = vec![
discovered("arcature-auth", "arcature-auth"),
discovered("arcature-db", "arcature-db"),
];
let current = versions(&[("arcature-auth", "2026.1.0"), ("arcature-db", "2026.1.0")]);
let changes = BTreeMap::from([
("arcature-auth".to_string(), ChangeKind::Breaking),
("arcature-db".to_string(), ChangeKind::Compatible),
]);
let plan = compute_bump_plan(¤t, &changes, &crates).unwrap();
assert_eq!(
plan.bumps["arcature-auth"].to,
Ybf::parse("2026.2.0").unwrap()
);
assert_eq!(
plan.bumps["arcature-db"].to,
Ybf::parse("2026.1.1").unwrap()
);
}
}