use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::dependency_closure_planner::{
DependencyClosurePlan, DependencyRiskClass, DependencySyncReason,
};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RootConvergence {
pub present: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local_revision: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remote_revision: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remote_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_sync_unix_ms: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RootSyncOutcome {
MissingRoot,
SyncNeeded,
StaleRemote,
NoOpClean,
}
impl RootSyncOutcome {
#[must_use]
pub const fn needs_sync(self) -> bool {
matches!(self, Self::SyncNeeded | Self::StaleRemote)
}
}
#[must_use]
pub fn derive_outcome(c: &RootConvergence) -> RootSyncOutcome {
if !c.present {
return RootSyncOutcome::MissingRoot;
}
match (&c.local_hash, &c.remote_hash) {
(_, None) => RootSyncOutcome::SyncNeeded,
(Some(l), Some(r)) if l == r => RootSyncOutcome::NoOpClean,
_ => RootSyncOutcome::StaleRemote,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClosureExplainEntry {
pub order_index: usize,
pub package_root: PathBuf,
pub package_name: String,
pub included_reason: DependencySyncReason,
pub risk: DependencyRiskClass,
pub convergence: RootConvergence,
pub sync_action: RootSyncOutcome,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClosureExplainReport {
pub entry_manifest_path: PathBuf,
pub fail_open: bool,
pub entries: Vec<ClosureExplainEntry>,
pub sync_count: usize,
pub missing_count: usize,
}
impl ClosureExplainReport {
#[must_use]
pub fn is_clean_noop(&self) -> bool {
self.sync_count == 0 && self.missing_count == 0
}
#[must_use]
pub fn render(&self) -> String {
let mut s = format!(
"path-dependency closure ({} roots, {} to sync, {} missing{}):",
self.entries.len(),
self.sync_count,
self.missing_count,
if self.fail_open { ", FAIL-OPEN" } else { "" },
);
for e in &self.entries {
s.push_str(&format!(
"\n [{}] {} ({:?}, risk={:?}) -> {:?}",
e.order_index, e.package_name, e.included_reason, e.risk, e.sync_action,
));
s.push_str(&format!(
"\n local rev={} hash={} | remote rev={} hash={} | last_sync_ms={}",
opt(&e.convergence.local_revision),
opt(&e.convergence.local_hash),
opt(&e.convergence.remote_revision),
opt(&e.convergence.remote_hash),
e.convergence
.last_sync_unix_ms
.map_or_else(|| "never".to_string(), |v| v.to_string()),
));
}
if self.is_clean_noop() {
s.push_str("\n -> clean: nothing to sync");
}
s
}
}
fn opt(o: &Option<String>) -> &str {
o.as_deref().unwrap_or("-")
}
#[must_use]
pub fn explain_closure(
plan: &DependencyClosurePlan,
convergence: &BTreeMap<PathBuf, RootConvergence>,
) -> ClosureExplainReport {
let mut entries = Vec::with_capacity(plan.sync_order.len());
let mut sync_count = 0;
let mut missing_count = 0;
for action in &plan.sync_order {
let conv = convergence
.get(&action.package_root)
.cloned()
.unwrap_or_default();
let outcome = derive_outcome(&conv);
match outcome {
RootSyncOutcome::MissingRoot => missing_count += 1,
o if o.needs_sync() => sync_count += 1,
_ => {}
}
entries.push(ClosureExplainEntry {
order_index: action.order_index,
package_root: action.package_root.clone(),
package_name: action.package_name.clone(),
included_reason: action.metadata.reason,
risk: action.risk,
convergence: conv,
sync_action: outcome,
});
}
ClosureExplainReport {
entry_manifest_path: plan.entry_manifest_path.clone(),
fail_open: plan.fail_open,
entries,
sync_count,
missing_count,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dependency_closure_planner::{
DependencyClosurePlanState, DependencySyncAction, DependencySyncMetadata,
};
fn action(
idx: usize,
root: &str,
name: &str,
reason: DependencySyncReason,
) -> DependencySyncAction {
DependencySyncAction {
order_index: idx,
package_root: PathBuf::from(root),
manifest_path: PathBuf::from(format!("{root}/Cargo.toml")),
package_name: name.to_string(),
risk: DependencyRiskClass::Low,
metadata: DependencySyncMetadata {
reason,
workspace_member: reason == DependencySyncReason::WorkspaceMember,
root_package: reason == DependencySyncReason::EntryPoint,
inbound_dependency_names: Vec::new(),
dependent_roots: Vec::new(),
notes: Vec::new(),
},
}
}
fn plan(actions: Vec<DependencySyncAction>) -> DependencyClosurePlan {
DependencyClosurePlan {
state: DependencyClosurePlanState::Ready,
entry_manifest_path: PathBuf::from("/proj/Cargo.toml"),
workspace_root: Some(PathBuf::from("/proj")),
canonical_roots: actions.iter().map(|a| a.package_root.clone()).collect(),
sync_order: actions,
fail_open: false,
fail_open_reason: None,
issues: Vec::new(),
}
}
fn converged(local: &str, remote: &str) -> RootConvergence {
RootConvergence {
present: true,
local_revision: Some("rev".to_string()),
local_hash: Some(local.to_string()),
remote_revision: Some("rev".to_string()),
remote_hash: Some(remote.to_string()),
last_sync_unix_ms: Some(1_700_000_000_000),
}
}
#[test]
fn derive_outcome_covers_all_states() {
assert_eq!(
derive_outcome(&RootConvergence::default()),
RootSyncOutcome::MissingRoot
);
assert_eq!(
derive_outcome(&converged("h", "h")),
RootSyncOutcome::NoOpClean
);
assert_eq!(
derive_outcome(&converged("h1", "h2")),
RootSyncOutcome::StaleRemote
);
let never = RootConvergence {
present: true,
local_hash: Some("h".to_string()),
remote_hash: None,
..RootConvergence::default()
};
assert_eq!(derive_outcome(&never), RootSyncOutcome::SyncNeeded);
}
#[test]
fn multi_crate_clean_no_op() {
let p = plan(vec![
action(0, "/proj/a", "a", DependencySyncReason::EntryPoint),
action(1, "/proj/b", "b", DependencySyncReason::WorkspaceMember),
]);
let mut conv = BTreeMap::new();
conv.insert(PathBuf::from("/proj/a"), converged("h", "h"));
conv.insert(PathBuf::from("/proj/b"), converged("h", "h"));
let report = explain_closure(&p, &conv);
assert_eq!(report.entries.len(), 2);
assert!(report.is_clean_noop());
assert_eq!(report.sync_count, 0);
assert!(
report
.entries
.iter()
.all(|e| e.sync_action == RootSyncOutcome::NoOpClean)
);
}
#[test]
fn sibling_repo_transitive_dependency_is_explained() {
let p = plan(vec![
action(0, "/proj/main", "main", DependencySyncReason::EntryPoint),
action(
1,
"/sibling/lib",
"lib",
DependencySyncReason::TransitivePathDependency,
),
]);
let mut conv = BTreeMap::new();
conv.insert(PathBuf::from("/proj/main"), converged("h", "h"));
conv.insert(PathBuf::from("/sibling/lib"), converged("h", "h"));
let report = explain_closure(&p, &conv);
let sib = report
.entries
.iter()
.find(|e| e.package_name == "lib")
.unwrap();
assert_eq!(
sib.included_reason,
DependencySyncReason::TransitivePathDependency
);
}
#[test]
fn stale_remote_root_needs_sync() {
let p = plan(vec![action(
0,
"/proj/a",
"a",
DependencySyncReason::EntryPoint,
)]);
let mut conv = BTreeMap::new();
conv.insert(
PathBuf::from("/proj/a"),
converged("localhash", "OLDremote"),
);
let report = explain_closure(&p, &conv);
assert_eq!(report.entries[0].sync_action, RootSyncOutcome::StaleRemote);
assert_eq!(report.sync_count, 1);
assert!(!report.is_clean_noop());
}
#[test]
fn missing_root_is_flagged() {
let p = plan(vec![action(
0,
"/proj/gone",
"gone",
DependencySyncReason::TransitivePathDependency,
)]);
let report = explain_closure(&p, &BTreeMap::new());
assert_eq!(report.entries[0].sync_action, RootSyncOutcome::MissingRoot);
assert_eq!(report.missing_count, 1);
assert!(!report.is_clean_noop());
}
#[test]
fn render_and_serde_roundtrip() {
let p = plan(vec![action(
0,
"/proj/a",
"a",
DependencySyncReason::EntryPoint,
)]);
let mut conv = BTreeMap::new();
conv.insert(PathBuf::from("/proj/a"), converged("h", "h"));
let report = explain_closure(&p, &conv);
let text = report.render();
assert!(text.contains("path-dependency closure"));
assert!(text.contains("clean: nothing to sync"));
let back: ClosureExplainReport =
serde_json::from_str(&serde_json::to_string(&report).unwrap()).unwrap();
assert_eq!(report, back);
}
#[test]
fn fail_open_plan_is_marked() {
let mut p = plan(vec![action(
0,
"/proj/a",
"a",
DependencySyncReason::EntryPoint,
)]);
p.fail_open = true;
let report = explain_closure(&p, &BTreeMap::new());
assert!(report.fail_open);
assert!(report.render().contains("FAIL-OPEN"));
}
}