use std::collections::{BTreeMap, BTreeSet};
use crate::release::discovered::DiscoveredCrate;
use crate::release::error::Diagnostic;
use crate::release::graph::UnitGraph;
use crate::release::metadata::CrateMetadata;
use crate::release::publish::plan::LoadedPlan;
pub(crate) fn validate_plan(
plan: &LoadedPlan,
head_sha: &str,
crates: &[DiscoveredCrate],
unit_graph: &UnitGraph,
) -> Vec<Diagnostic> {
let mut findings = Vec::new();
if plan.commit != head_sha {
findings.push(Diagnostic {
crate_name: "plan".to_string(),
message: format!(
"plan commit {} does not match HEAD {}",
plan.commit, head_sha
),
});
}
let publishable: BTreeMap<&str, &DiscoveredCrate> = crates
.iter()
.filter_map(|c| match &c.metadata {
CrateMetadata::Valid(md) if md.publish => Some((c.name.as_str(), c)),
_ => None,
})
.collect();
for entry in plan.entries.values() {
if !publishable.contains_key(entry.crate_name.as_str()) {
findings.push(Diagnostic {
crate_name: entry.crate_name.clone(),
message: "crate in plan is not publishable".to_string(),
});
}
}
let plan_unit_of: BTreeMap<&str, &str> = plan
.entries
.values()
.filter_map(|e| {
publishable
.get(e.crate_name.as_str())
.and_then(|c| match &c.metadata {
CrateMetadata::Valid(md) => md.release_unit.as_deref(),
_ => None,
})
.map(|unit| (e.crate_name.as_str(), unit))
})
.collect();
let plan_units: BTreeSet<&str> = plan_unit_of.values().copied().collect();
for unit_name in &plan_units {
let node = match unit_graph.nodes.get(*unit_name) {
Some(n) => n,
None => {
findings.push(Diagnostic {
crate_name: "plan".to_string(),
message: format!("unit {unit_name} not found in dependency graph"),
});
continue;
}
};
for dep_unit in node.dependencies.keys() {
if !plan_units.contains(dep_unit.as_str()) {
continue;
}
let min_dependent_order = plan
.entries
.values()
.filter(|e| {
plan_unit_of
.get(e.crate_name.as_str())
.is_some_and(|u| *u == *unit_name)
})
.map(|e| e.order)
.min()
.unwrap_or(usize::MAX);
let max_dependency_order = plan
.entries
.values()
.filter(|e| {
plan_unit_of
.get(e.crate_name.as_str())
.is_some_and(|u| *u == dep_unit.as_str())
})
.map(|e| e.order)
.max()
.unwrap_or(0);
if min_dependent_order <= max_dependency_order {
findings.push(Diagnostic {
crate_name: "plan".to_string(),
message: format!(
"topological order violated: unit {unit_name} (min order {min_dependent_order}) \
must publish after its dependency {dep_unit} (max order {max_dependency_order})"
),
});
}
}
}
let arcature = plan.entries.get("arcature");
let arcature_dx = plan.entries.get("arcature-dx");
match (arcature, arcature_dx) {
(Some(a), Some(b)) => {
if a.version != b.version {
findings.push(Diagnostic {
crate_name: "core".to_string(),
message: format!(
"core atomicity violated: arcature {} != arcature-dx {}",
a.version, b.version
),
});
}
}
(Some(_), None) => {
findings.push(Diagnostic {
crate_name: "core".to_string(),
message: "core atomicity violated: arcature in plan but arcature-dx is not"
.to_string(),
});
}
(None, Some(_)) => {
findings.push(Diagnostic {
crate_name: "core".to_string(),
message: "core atomicity violated: arcature-dx in plan but arcature is not"
.to_string(),
});
}
(None, None) => {}
}
let mut seen_orders: BTreeSet<usize> = BTreeSet::new();
for entry in plan.entries.values() {
if !seen_orders.insert(entry.order) {
findings.push(Diagnostic {
crate_name: "plan".to_string(),
message: format!("duplicate order value {}", entry.order),
});
}
}
findings
}
#[cfg(test)]
mod tests {
use super::*;
use crate::release::discovered::DiscoveredCrate;
use crate::release::graph::{UnitGraph, UnitNode};
use crate::release::metadata::{CrateMetadata, ReleaseMetadata};
use crate::release::publish::plan::{LoadedEntry, LoadedPlan};
use crate::release::version::Ybf;
use std::collections::{BTreeMap, BTreeSet};
const SHA: &str = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0";
fn make_crate(name: &str, unit: &str, publish: bool) -> DiscoveredCrate {
DiscoveredCrate {
name: name.to_string(),
manifest_path: std::path::PathBuf::from(format!("crates/{name}/Cargo.toml")),
proc_macro: false,
publishable: publish,
metadata: CrateMetadata::Valid(ReleaseMetadata {
publish,
role: Some(crate::release::metadata::Role::Subsystem),
release_unit: Some(unit.to_string()),
}),
}
}
fn make_plan(entries: Vec<LoadedEntry>) -> LoadedPlan {
let map: BTreeMap<String, LoadedEntry> = entries
.into_iter()
.map(|e| (e.crate_name.clone(), e))
.collect();
LoadedPlan {
transaction_id: "2026-08-15.01".to_string(),
commit: SHA.to_string(),
entries: map,
}
}
fn entry(name: &str, version: &str, order: usize) -> LoadedEntry {
LoadedEntry {
crate_name: name.to_string(),
version: Ybf::parse(version).unwrap(),
order,
}
}
fn two_unit_graph() -> UnitGraph {
let mut nodes = BTreeMap::new();
nodes.insert(
"a".to_string(),
UnitNode {
name: "a".to_string(),
members: BTreeSet::from(["crate-a".to_string()]),
dependencies: BTreeMap::new(),
},
);
nodes.insert(
"b".to_string(),
UnitNode {
name: "b".to_string(),
members: BTreeSet::from(["crate-b".to_string()]),
dependencies: BTreeMap::from([("a".to_string(), BTreeSet::new())]),
},
);
UnitGraph { nodes }
}
#[test]
fn valid_plan_no_findings() {
let crates = vec![
make_crate("crate-a", "a", true),
make_crate("crate-b", "b", true),
];
let plan = make_plan(vec![
entry("crate-a", "2026.1.1", 1),
entry("crate-b", "2026.1.1", 2),
]);
let findings = validate_plan(&plan, SHA, &crates, &two_unit_graph());
assert!(findings.is_empty(), "expected no findings: {findings:?}");
}
#[test]
fn commit_mismatch_reported() {
let crates = vec![make_crate("crate-a", "a", true)];
let plan = make_plan(vec![entry("crate-a", "2026.1.1", 1)]);
let findings = validate_plan(
&plan,
"0000000000000000000000000000000000000000",
&crates,
&two_unit_graph(),
);
assert_eq!(findings.len(), 1);
assert!(findings[0].message.contains("does not match HEAD"));
}
#[test]
fn non_publishable_crate_reported() {
let crates = vec![make_crate("crate-a", "a", false)];
let plan = make_plan(vec![entry("crate-a", "2026.1.1", 1)]);
let findings = validate_plan(&plan, SHA, &crates, &two_unit_graph());
assert!(
findings
.iter()
.any(|f| f.message.contains("not publishable"))
);
}
#[test]
fn topo_order_violation_reported() {
let crates = vec![
make_crate("crate-a", "a", true),
make_crate("crate-b", "b", true),
];
let plan = make_plan(vec![
entry("crate-b", "2026.1.1", 1),
entry("crate-a", "2026.1.1", 2),
]);
let findings = validate_plan(&plan, SHA, &crates, &two_unit_graph());
assert!(
findings
.iter()
.any(|f| f.message.contains("topological order violated"))
);
}
#[test]
fn core_atomicity_version_mismatch() {
let crates = vec![
make_crate("arcature", "core", true),
make_crate("arcature-dx", "core", true),
];
let plan = make_plan(vec![
entry("arcature", "2026.1.5", 1),
entry("arcature-dx", "2026.1.6", 2),
]);
let findings = validate_plan(&plan, SHA, &crates, &two_unit_graph());
assert!(
findings
.iter()
.any(|f| f.message.contains("core atomicity"))
);
}
#[test]
fn core_atomicity_half_missing() {
let crates = vec![
make_crate("arcature", "core", true),
make_crate("arcature-dx", "core", true),
];
let plan = make_plan(vec![entry("arcature", "2026.1.5", 1)]);
let findings = validate_plan(&plan, SHA, &crates, &two_unit_graph());
assert!(
findings
.iter()
.any(|f| f.message.contains("arcature-dx is not"))
);
}
#[test]
fn duplicate_order_reported() {
let crates = vec![
make_crate("crate-a", "a", true),
make_crate("crate-b", "b", true),
];
let plan = make_plan(vec![
entry("crate-a", "2026.1.1", 1),
entry("crate-b", "2026.1.1", 1),
]);
let findings = validate_plan(&plan, SHA, &crates, &two_unit_graph());
assert!(
findings
.iter()
.any(|f| f.message.contains("duplicate order"))
);
}
#[test]
fn empty_plan_no_findings() {
let plan = make_plan(vec![]);
let findings = validate_plan(&plan, SHA, &[], &two_unit_graph());
assert!(findings.is_empty());
}
}