use std::collections::BTreeMap;
use super::model::{PublishEntry, ReleasePlan};
use crate::release::change::{ChangeKind, ChangeReport};
use crate::release::discovered::{CargoMetadata, DiscoveredCrate};
use crate::release::error::Diagnostic;
use crate::release::graph::UnitGraph;
use crate::release::metadata::CrateMetadata;
use crate::release::version::Ybf;
use crate::release::version::compute_bump_plan;
#[derive(Debug, Clone)]
pub(crate) struct PlanError {
pub(crate) diagnostics: Vec<Diagnostic>,
}
impl std::fmt::Display for PlanError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.diagnostics.len() == 1 {
write!(formatter, "release plan failed: {}", self.diagnostics[0])
} else {
writeln!(
formatter,
"release plan failed ({} findings):",
self.diagnostics.len()
)?;
for d in &self.diagnostics {
writeln!(formatter, " {d}")?;
}
Ok(())
}
}
}
impl std::error::Error for PlanError {}
pub(crate) fn build_release_plan(
document: &CargoMetadata,
crates: &[DiscoveredCrate],
change_report: &ChangeReport,
unit_graph: &UnitGraph,
commit_sha: &str,
transaction_id: &str,
) -> Result<ReleasePlan, PlanError> {
let mut diagnostics: Vec<Diagnostic> = Vec::new();
let mut current_versions: BTreeMap<String, Ybf> = BTreeMap::new();
for pkg in &document.packages {
if let Some(raw) = &pkg.version {
match Ybf::parse(raw) {
Ok(v) => {
current_versions.insert(pkg.name.clone(), v);
}
Err(error) => {
diagnostics.push(Diagnostic {
crate_name: pkg.name.clone(),
message: format!("{error}"),
});
}
}
}
}
if !diagnostics.is_empty() {
diagnostics.sort_by(|a, b| {
a.crate_name
.cmp(&b.crate_name)
.then(a.message.cmp(&b.message))
});
return Err(PlanError { diagnostics });
}
let bump_plan = match compute_bump_plan(¤t_versions, &change_report.changed_units, crates)
{
Ok(plan) => plan,
Err(error) => {
return Err(PlanError {
diagnostics: error.diagnostics,
});
}
};
let unit_order = match unit_graph.topological_order() {
Ok(order) => order,
Err(error) => {
diagnostics.push(Diagnostic {
crate_name: "graph".to_string(),
message: format!("{error}"),
});
return Err(PlanError { diagnostics });
}
};
let unit_position: BTreeMap<String, usize> = unit_order
.iter()
.enumerate()
.map(|(i, name)| (name.clone(), i + 1))
.collect();
let crate_unit: BTreeMap<String, String> = crates
.iter()
.filter_map(|c| match &c.metadata {
CrateMetadata::Valid(md) if md.publish => {
md.release_unit.clone().map(|u| (c.name.clone(), u))
}
_ => None,
})
.collect();
let mut entries: BTreeMap<String, PublishEntry> = BTreeMap::new();
let mut changed_count: usize = 0;
let mut staged: Vec<(String, String, ChangeKind, Ybf, Ybf, bool)> = Vec::new();
for (crate_name, bump) in &bump_plan.bumps {
let unit = crate_unit.get(crate_name).cloned().unwrap_or_default();
let unit_pos = unit_position.get(&unit).copied().unwrap_or(0);
let change_kind = change_report
.changed_units
.get(&unit)
.copied()
.unwrap_or(ChangeKind::None);
if bump.changed {
changed_count += 1;
}
staged.push((
crate_name.clone(),
unit.clone(),
change_kind,
bump.from,
bump.to,
bump.changed,
));
let _ = unit_pos;
}
staged.sort_by(|a, b| {
let ua = crate_unit
.get(&a.0)
.and_then(|u| unit_position.get(u))
.copied()
.unwrap_or(0);
let ub = crate_unit
.get(&b.0)
.and_then(|u| unit_position.get(u))
.copied()
.unwrap_or(0);
ua.cmp(&ub)
.then_with(|| core_intra_priority(&a.0).cmp(&core_intra_priority(&b.0)))
.then_with(|| a.0.cmp(&b.0))
});
for (order, (crate_name, unit, change_kind, from, to, changed)) in
staged.into_iter().enumerate()
{
entries.insert(
crate_name.clone(),
PublishEntry {
crate_name,
unit,
from,
to,
change_kind,
order: order + 1,
},
);
let _ = changed;
}
Ok(ReleasePlan {
transaction_id: transaction_id.to_string(),
commit: commit_sha.to_string(),
entries,
changed_count,
})
}
fn core_intra_priority(crate_name: &str) -> u8 {
match crate_name {
"arcature-dx" => 0,
"arcature" => 1,
_ => 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::release::discovered::{Package, Target};
use crate::release::graph::{UnitGraph, UnitNode};
use crate::release::metadata::ReleaseMetadata;
use crate::release::metadata::Role;
fn package(name: &str, version: &str, metadata: Option<&str>) -> Package {
Package {
name: name.to_string(),
version: Some(version.to_string()),
manifest_path: format!("/repo/crates/{name}/Cargo.toml"),
publish: None,
metadata: metadata.map(|m| serde_json::from_str(m).unwrap()),
targets: vec![Target {
kind: vec!["lib".to_string()],
}],
dependencies: Vec::new(),
}
}
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 unit_graph(units: &[&str]) -> UnitGraph {
let mut nodes = std::collections::BTreeMap::new();
for u in units {
nodes.insert(
u.to_string(),
UnitNode {
name: u.to_string(),
members: std::collections::BTreeSet::new(),
dependencies: std::collections::BTreeMap::new(),
},
);
}
UnitGraph { nodes }
}
fn change_report(units: &[(&str, ChangeKind)]) -> ChangeReport {
ChangeReport {
changed_units: units.iter().map(|(u, k)| (u.to_string(), *k)).collect(),
entry_count: units.len(),
}
}
#[test]
fn no_fragments_produces_no_change_plan() {
let doc = CargoMetadata {
packages: vec![package(
"arcature-auth",
"2026.1.0",
Some(
r#"{"arcature":{"publish":true,"role":"subsystem","release-unit":"arcature-auth"}}"#,
),
)],
};
let crates = vec![discovered("arcature-auth", "arcature-auth")];
let report = change_report(&[]);
let graph = unit_graph(&["arcature-auth"]);
let plan =
build_release_plan(&doc, &crates, &report, &graph, "abc123", "2026-08-15.01").unwrap();
assert!(plan.is_empty());
assert_eq!(plan.changed_count, 0);
assert_eq!(plan.total_count(), 1);
let entry = &plan.entries["arcature-auth"];
assert_eq!(entry.from, Ybf::parse("2026.1.0").unwrap());
assert_eq!(entry.to, Ybf::parse("2026.1.0").unwrap());
assert_eq!(entry.change_kind, ChangeKind::None);
}
#[test]
fn compatible_fragment_bumps_fix() {
let doc = CargoMetadata {
packages: vec![package(
"arcature-auth",
"2026.1.0",
Some(
r#"{"arcature":{"publish":true,"role":"subsystem","release-unit":"arcature-auth"}}"#,
),
)],
};
let crates = vec![discovered("arcature-auth", "arcature-auth")];
let report = change_report(&[("arcature-auth", ChangeKind::Compatible)]);
let graph = unit_graph(&["arcature-auth"]);
let plan =
build_release_plan(&doc, &crates, &report, &graph, "abc123", "2026-08-15.01").unwrap();
assert!(!plan.is_empty());
assert_eq!(plan.changed_count, 1);
let entry = &plan.entries["arcature-auth"];
assert_eq!(entry.to, Ybf::parse("2026.1.1").unwrap());
assert_eq!(entry.change_kind, ChangeKind::Compatible);
}
#[test]
fn breaking_fragment_bumps_break() {
let doc = CargoMetadata {
packages: vec![package(
"arcature-auth",
"2026.1.5",
Some(
r#"{"arcature":{"publish":true,"role":"subsystem","release-unit":"arcature-auth"}}"#,
),
)],
};
let crates = vec![discovered("arcature-auth", "arcature-auth")];
let report = change_report(&[("arcature-auth", ChangeKind::Breaking)]);
let graph = unit_graph(&["arcature-auth"]);
let plan =
build_release_plan(&doc, &crates, &report, &graph, "abc123", "2026-08-15.01").unwrap();
let entry = &plan.entries["arcature-auth"];
assert_eq!(entry.to, Ybf::parse("2026.2.0").unwrap());
assert_eq!(entry.change_kind, ChangeKind::Breaking);
}
#[test]
fn core_bumps_both_members() {
let doc = CargoMetadata {
packages: vec![
package(
"arcature",
"2026.1.0",
Some(r#"{"arcature":{"publish":true,"role":"facade","release-unit":"core"}}"#),
),
package(
"arcature-dx",
"2026.1.0",
Some(
r#"{"arcature":{"publish":true,"role":"proc-macro","release-unit":"core"}}"#,
),
),
],
};
let crates = vec![
discovered("arcature", "core"),
discovered("arcature-dx", "core"),
];
let report = change_report(&[("core", ChangeKind::Compatible)]);
let graph = unit_graph(&["core"]);
let plan =
build_release_plan(&doc, &crates, &report, &graph, "abc123", "2026-08-15.01").unwrap();
assert_eq!(plan.entries["arcature"].to, Ybf::parse("2026.1.1").unwrap());
assert_eq!(
plan.entries["arcature-dx"].to,
Ybf::parse("2026.1.1").unwrap()
);
}
#[test]
fn independent_units_get_independent_versions() {
let doc = CargoMetadata {
packages: vec![
package(
"arcature-auth",
"2026.1.0",
Some(
r#"{"arcature":{"publish":true,"role":"subsystem","release-unit":"arcature-auth"}}"#,
),
),
package(
"arcature-db",
"2026.1.0",
Some(
r#"{"arcature":{"publish":true,"role":"subsystem","release-unit":"arcature-db"}}"#,
),
),
],
};
let crates = vec![
discovered("arcature-auth", "arcature-auth"),
discovered("arcature-db", "arcature-db"),
];
let report = change_report(&[
("arcature-auth", ChangeKind::Breaking),
("arcature-db", ChangeKind::Compatible),
]);
let graph = unit_graph(&["arcature-auth", "arcature-db"]);
let plan =
build_release_plan(&doc, &crates, &report, &graph, "abc123", "2026-08-15.01").unwrap();
assert_eq!(
plan.entries["arcature-auth"].to,
Ybf::parse("2026.2.0").unwrap()
);
assert_eq!(
plan.entries["arcature-db"].to,
Ybf::parse("2026.1.1").unwrap()
);
}
#[test]
fn topological_order_assigned() {
let doc = CargoMetadata {
packages: vec![
package(
"arcature-auth",
"2026.1.0",
Some(
r#"{"arcature":{"publish":true,"role":"subsystem","release-unit":"arcature-auth"}}"#,
),
),
package(
"arcature-db",
"2026.1.0",
Some(
r#"{"arcature":{"publish":true,"role":"subsystem","release-unit":"arcature-db"}}"#,
),
),
],
};
let crates = vec![
discovered("arcature-auth", "arcature-auth"),
discovered("arcature-db", "arcature-db"),
];
let report = change_report(&[
("arcature-auth", ChangeKind::Compatible),
("arcature-db", ChangeKind::Compatible),
]);
let graph = unit_graph(&["arcature-auth", "arcature-db"]);
let plan =
build_release_plan(&doc, &crates, &report, &graph, "abc123", "2026-08-15.01").unwrap();
assert_eq!(plan.entries["arcature-auth"].order, 1);
assert_eq!(plan.entries["arcature-db"].order, 2);
}
#[test]
fn core_pair_gets_unique_sequential_order() {
let doc = CargoMetadata {
packages: vec![
package(
"arcature",
"2026.1.0",
Some(r#"{"arcature":{"publish":true,"role":"facade","release-unit":"core"}}"#),
),
package(
"arcature-dx",
"2026.1.0",
Some(
r#"{"arcature":{"publish":true,"role":"proc-macro","release-unit":"core"}}"#,
),
),
],
};
let crates = vec![
discovered("arcature", "core"),
discovered("arcature-dx", "core"),
];
let report = change_report(&[("core", ChangeKind::Compatible)]);
let graph = unit_graph(&["core"]);
let plan =
build_release_plan(&doc, &crates, &report, &graph, "abc123", "2026-08-15.01").unwrap();
assert_eq!(plan.entries["arcature-dx"].order, 1);
assert_eq!(plan.entries["arcature"].order, 2);
let orders: Vec<usize> = plan.entries.values().map(|e| e.order).collect();
let mut seen = std::collections::BTreeSet::new();
for order in &orders {
assert!(seen.insert(*order), "duplicate order {order}");
}
}
#[test]
fn malformed_version_is_error() {
let doc = CargoMetadata {
packages: vec![package(
"arcature-auth",
"not-a-version",
Some(
r#"{"arcature":{"publish":true,"role":"subsystem","release-unit":"arcature-auth"}}"#,
),
)],
};
let crates = vec![discovered("arcature-auth", "arcature-auth")];
let report = change_report(&[]);
let graph = unit_graph(&["arcature-auth"]);
let err = build_release_plan(&doc, &crates, &report, &graph, "abc123", "2026-08-15.01")
.expect_err("malformed");
assert!(err.diagnostics[0].message.contains("invalid YBF"));
}
}