1use std::collections::BTreeMap;
14use std::path::PathBuf;
15
16use serde::{Deserialize, Serialize};
17
18use crate::dependency_closure_planner::{
19 DependencyClosurePlan, DependencyRiskClass, DependencySyncReason,
20};
21
22#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
25pub struct RootConvergence {
26 pub present: bool,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub local_revision: Option<String>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub local_hash: Option<String>,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub remote_revision: Option<String>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub remote_hash: Option<String>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub last_sync_unix_ms: Option<u64>,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum RootSyncOutcome {
45 MissingRoot,
47 SyncNeeded,
49 StaleRemote,
51 NoOpClean,
53}
54
55impl RootSyncOutcome {
56 #[must_use]
58 pub const fn needs_sync(self) -> bool {
59 matches!(self, Self::SyncNeeded | Self::StaleRemote)
60 }
61}
62
63#[must_use]
65pub fn derive_outcome(c: &RootConvergence) -> RootSyncOutcome {
66 if !c.present {
67 return RootSyncOutcome::MissingRoot;
68 }
69 match (&c.local_hash, &c.remote_hash) {
70 (_, None) => RootSyncOutcome::SyncNeeded,
71 (Some(l), Some(r)) if l == r => RootSyncOutcome::NoOpClean,
72 _ => RootSyncOutcome::StaleRemote,
73 }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct ClosureExplainEntry {
79 pub order_index: usize,
80 pub package_root: PathBuf,
81 pub package_name: String,
82 pub included_reason: DependencySyncReason,
84 pub risk: DependencyRiskClass,
86 pub convergence: RootConvergence,
87 pub sync_action: RootSyncOutcome,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub struct ClosureExplainReport {
94 pub entry_manifest_path: PathBuf,
95 pub fail_open: bool,
97 pub entries: Vec<ClosureExplainEntry>,
98 pub sync_count: usize,
100 pub missing_count: usize,
102}
103
104impl ClosureExplainReport {
105 #[must_use]
107 pub fn is_clean_noop(&self) -> bool {
108 self.sync_count == 0 && self.missing_count == 0
109 }
110
111 #[must_use]
113 pub fn render(&self) -> String {
114 let mut s = format!(
115 "path-dependency closure ({} roots, {} to sync, {} missing{}):",
116 self.entries.len(),
117 self.sync_count,
118 self.missing_count,
119 if self.fail_open { ", FAIL-OPEN" } else { "" },
120 );
121 for e in &self.entries {
122 s.push_str(&format!(
123 "\n [{}] {} ({:?}, risk={:?}) -> {:?}",
124 e.order_index, e.package_name, e.included_reason, e.risk, e.sync_action,
125 ));
126 s.push_str(&format!(
127 "\n local rev={} hash={} | remote rev={} hash={} | last_sync_ms={}",
128 opt(&e.convergence.local_revision),
129 opt(&e.convergence.local_hash),
130 opt(&e.convergence.remote_revision),
131 opt(&e.convergence.remote_hash),
132 e.convergence
133 .last_sync_unix_ms
134 .map_or_else(|| "never".to_string(), |v| v.to_string()),
135 ));
136 }
137 if self.is_clean_noop() {
138 s.push_str("\n -> clean: nothing to sync");
139 }
140 s
141 }
142}
143
144fn opt(o: &Option<String>) -> &str {
145 o.as_deref().unwrap_or("-")
146}
147
148#[must_use]
152pub fn explain_closure(
153 plan: &DependencyClosurePlan,
154 convergence: &BTreeMap<PathBuf, RootConvergence>,
155) -> ClosureExplainReport {
156 let mut entries = Vec::with_capacity(plan.sync_order.len());
157 let mut sync_count = 0;
158 let mut missing_count = 0;
159
160 for action in &plan.sync_order {
161 let conv = convergence
162 .get(&action.package_root)
163 .cloned()
164 .unwrap_or_default();
165 let outcome = derive_outcome(&conv);
166 match outcome {
167 RootSyncOutcome::MissingRoot => missing_count += 1,
168 o if o.needs_sync() => sync_count += 1,
169 _ => {}
170 }
171 entries.push(ClosureExplainEntry {
172 order_index: action.order_index,
173 package_root: action.package_root.clone(),
174 package_name: action.package_name.clone(),
175 included_reason: action.metadata.reason,
176 risk: action.risk,
177 convergence: conv,
178 sync_action: outcome,
179 });
180 }
181
182 ClosureExplainReport {
183 entry_manifest_path: plan.entry_manifest_path.clone(),
184 fail_open: plan.fail_open,
185 entries,
186 sync_count,
187 missing_count,
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194 use crate::dependency_closure_planner::{
195 DependencyClosurePlanState, DependencySyncAction, DependencySyncMetadata,
196 };
197
198 fn action(
199 idx: usize,
200 root: &str,
201 name: &str,
202 reason: DependencySyncReason,
203 ) -> DependencySyncAction {
204 DependencySyncAction {
205 order_index: idx,
206 package_root: PathBuf::from(root),
207 manifest_path: PathBuf::from(format!("{root}/Cargo.toml")),
208 package_name: name.to_string(),
209 risk: DependencyRiskClass::Low,
210 metadata: DependencySyncMetadata {
211 reason,
212 workspace_member: reason == DependencySyncReason::WorkspaceMember,
213 root_package: reason == DependencySyncReason::EntryPoint,
214 inbound_dependency_names: Vec::new(),
215 dependent_roots: Vec::new(),
216 notes: Vec::new(),
217 },
218 }
219 }
220
221 fn plan(actions: Vec<DependencySyncAction>) -> DependencyClosurePlan {
222 DependencyClosurePlan {
223 state: DependencyClosurePlanState::Ready,
224 entry_manifest_path: PathBuf::from("/proj/Cargo.toml"),
225 workspace_root: Some(PathBuf::from("/proj")),
226 canonical_roots: actions.iter().map(|a| a.package_root.clone()).collect(),
227 sync_order: actions,
228 fail_open: false,
229 fail_open_reason: None,
230 issues: Vec::new(),
231 }
232 }
233
234 fn converged(local: &str, remote: &str) -> RootConvergence {
235 RootConvergence {
236 present: true,
237 local_revision: Some("rev".to_string()),
238 local_hash: Some(local.to_string()),
239 remote_revision: Some("rev".to_string()),
240 remote_hash: Some(remote.to_string()),
241 last_sync_unix_ms: Some(1_700_000_000_000),
242 }
243 }
244
245 #[test]
246 fn derive_outcome_covers_all_states() {
247 assert_eq!(
248 derive_outcome(&RootConvergence::default()),
249 RootSyncOutcome::MissingRoot
250 );
251 assert_eq!(
252 derive_outcome(&converged("h", "h")),
253 RootSyncOutcome::NoOpClean
254 );
255 assert_eq!(
256 derive_outcome(&converged("h1", "h2")),
257 RootSyncOutcome::StaleRemote
258 );
259 let never = RootConvergence {
260 present: true,
261 local_hash: Some("h".to_string()),
262 remote_hash: None,
263 ..RootConvergence::default()
264 };
265 assert_eq!(derive_outcome(&never), RootSyncOutcome::SyncNeeded);
266 }
267
268 #[test]
269 fn multi_crate_clean_no_op() {
270 let p = plan(vec![
271 action(0, "/proj/a", "a", DependencySyncReason::EntryPoint),
272 action(1, "/proj/b", "b", DependencySyncReason::WorkspaceMember),
273 ]);
274 let mut conv = BTreeMap::new();
275 conv.insert(PathBuf::from("/proj/a"), converged("h", "h"));
276 conv.insert(PathBuf::from("/proj/b"), converged("h", "h"));
277 let report = explain_closure(&p, &conv);
278 assert_eq!(report.entries.len(), 2);
279 assert!(report.is_clean_noop());
280 assert_eq!(report.sync_count, 0);
281 assert!(
282 report
283 .entries
284 .iter()
285 .all(|e| e.sync_action == RootSyncOutcome::NoOpClean)
286 );
287 }
288
289 #[test]
290 fn sibling_repo_transitive_dependency_is_explained() {
291 let p = plan(vec![
293 action(0, "/proj/main", "main", DependencySyncReason::EntryPoint),
294 action(
295 1,
296 "/sibling/lib",
297 "lib",
298 DependencySyncReason::TransitivePathDependency,
299 ),
300 ]);
301 let mut conv = BTreeMap::new();
302 conv.insert(PathBuf::from("/proj/main"), converged("h", "h"));
303 conv.insert(PathBuf::from("/sibling/lib"), converged("h", "h"));
304 let report = explain_closure(&p, &conv);
305 let sib = report
306 .entries
307 .iter()
308 .find(|e| e.package_name == "lib")
309 .unwrap();
310 assert_eq!(
311 sib.included_reason,
312 DependencySyncReason::TransitivePathDependency
313 );
314 }
315
316 #[test]
317 fn stale_remote_root_needs_sync() {
318 let p = plan(vec![action(
319 0,
320 "/proj/a",
321 "a",
322 DependencySyncReason::EntryPoint,
323 )]);
324 let mut conv = BTreeMap::new();
325 conv.insert(
326 PathBuf::from("/proj/a"),
327 converged("localhash", "OLDremote"),
328 );
329 let report = explain_closure(&p, &conv);
330 assert_eq!(report.entries[0].sync_action, RootSyncOutcome::StaleRemote);
331 assert_eq!(report.sync_count, 1);
332 assert!(!report.is_clean_noop());
333 }
334
335 #[test]
336 fn missing_root_is_flagged() {
337 let p = plan(vec![action(
338 0,
339 "/proj/gone",
340 "gone",
341 DependencySyncReason::TransitivePathDependency,
342 )]);
343 let report = explain_closure(&p, &BTreeMap::new());
345 assert_eq!(report.entries[0].sync_action, RootSyncOutcome::MissingRoot);
346 assert_eq!(report.missing_count, 1);
347 assert!(!report.is_clean_noop());
348 }
349
350 #[test]
351 fn render_and_serde_roundtrip() {
352 let p = plan(vec![action(
353 0,
354 "/proj/a",
355 "a",
356 DependencySyncReason::EntryPoint,
357 )]);
358 let mut conv = BTreeMap::new();
359 conv.insert(PathBuf::from("/proj/a"), converged("h", "h"));
360 let report = explain_closure(&p, &conv);
361 let text = report.render();
362 assert!(text.contains("path-dependency closure"));
363 assert!(text.contains("clean: nothing to sync"));
364 let back: ClosureExplainReport =
365 serde_json::from_str(&serde_json::to_string(&report).unwrap()).unwrap();
366 assert_eq!(report, back);
367 }
368
369 #[test]
370 fn fail_open_plan_is_marked() {
371 let mut p = plan(vec![action(
372 0,
373 "/proj/a",
374 "a",
375 DependencySyncReason::EntryPoint,
376 )]);
377 p.fail_open = true;
378 let report = explain_closure(&p, &BTreeMap::new());
379 assert!(report.fail_open);
380 assert!(report.render().contains("FAIL-OPEN"));
381 }
382}