1use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, BTreeSet};
9use std::path::{Path, PathBuf};
10
11use crate::{
12 CargoPathDependencyEdge, CargoPathDependencyError, CargoPathDependencyErrorKind,
13 CargoPathDependencyGraph, CargoPathDependencyPackage, PathTopologyPolicy,
14 resolve_cargo_path_dependency_graph_with_policy,
15};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum DependencyClosurePlanState {
21 Ready,
22 FailOpen,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum DependencyRiskClass {
29 Low,
30 Medium,
31 High,
32 Critical,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum DependencySyncReason {
39 EntryPoint,
40 WorkspaceMember,
41 TransitivePathDependency,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct DependencySyncMetadata {
47 pub reason: DependencySyncReason,
48 pub workspace_member: bool,
49 pub root_package: bool,
50 pub inbound_dependency_names: Vec<String>,
51 pub dependent_roots: Vec<PathBuf>,
52 pub notes: Vec<String>,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct DependencySyncAction {
58 pub order_index: usize,
59 pub package_root: PathBuf,
60 pub manifest_path: PathBuf,
61 pub package_name: String,
62 pub risk: DependencyRiskClass,
63 pub metadata: DependencySyncMetadata,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68pub struct DependencyPlanIssue {
69 pub code: String,
70 pub message: String,
71 pub risk: DependencyRiskClass,
72 pub diagnostics: Vec<String>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct DependencyClosurePlan {
78 pub state: DependencyClosurePlanState,
79 pub entry_manifest_path: PathBuf,
80 pub workspace_root: Option<PathBuf>,
81 pub canonical_roots: Vec<PathBuf>,
82 pub sync_order: Vec<DependencySyncAction>,
83 pub fail_open: bool,
84 pub fail_open_reason: Option<String>,
85 pub issues: Vec<DependencyPlanIssue>,
86}
87
88impl DependencyClosurePlan {
89 pub fn is_ready(&self) -> bool {
91 self.state == DependencyClosurePlanState::Ready && !self.fail_open
92 }
93
94 pub fn sync_roots(&self) -> Vec<PathBuf> {
96 self.sync_order
97 .iter()
98 .map(|action| action.package_root.clone())
99 .collect()
100 }
101}
102
103pub fn build_dependency_closure_plan(entrypoint: &Path) -> DependencyClosurePlan {
105 build_dependency_closure_plan_with_policy(entrypoint, &PathTopologyPolicy::default())
106}
107
108pub fn build_dependency_closure_plan_with_policy(
113 entrypoint: &Path,
114 policy: &PathTopologyPolicy,
115) -> DependencyClosurePlan {
116 match resolve_cargo_path_dependency_graph_with_policy(entrypoint, policy) {
117 Ok(graph) => plan_dependency_closure_from_graph(&graph),
118 Err(error) => fail_open_plan_from_resolver_error(entrypoint, &error),
119 }
120}
121
122pub fn plan_dependency_closure_from_graph(
124 graph: &CargoPathDependencyGraph,
125) -> DependencyClosurePlan {
126 let package_by_root: BTreeMap<PathBuf, CargoPathDependencyPackage> = graph
127 .packages
128 .iter()
129 .cloned()
130 .map(|package| (package.package_root.clone(), package))
131 .collect();
132
133 let order = match dependency_first_topological_order(&graph.packages, &graph.edges) {
134 Some(order) => order,
135 None => {
136 return DependencyClosurePlan {
137 state: DependencyClosurePlanState::FailOpen,
138 entry_manifest_path: graph.entry_manifest_path.clone(),
139 workspace_root: graph.workspace_root.clone(),
140 canonical_roots: Vec::new(),
141 sync_order: Vec::new(),
142 fail_open: true,
143 fail_open_reason: Some(
144 "planner could not derive deterministic order from dependency graph"
145 .to_string(),
146 ),
147 issues: vec![DependencyPlanIssue {
148 code: "planner_non_deterministic_order".to_string(),
149 message:
150 "dependency graph order is unverifiable; planner switched to fail-open"
151 .to_string(),
152 risk: DependencyRiskClass::Critical,
153 diagnostics: vec![
154 format!("packages={}", graph.packages.len()),
155 format!("edges={}", graph.edges.len()),
156 ],
157 }],
158 };
159 }
160 };
161
162 let entry_root = graph
163 .entry_manifest_path
164 .parent()
165 .map(Path::to_path_buf)
166 .unwrap_or_else(|| PathBuf::from("/"));
167
168 let root_packages = graph
169 .root_packages
170 .iter()
171 .cloned()
172 .collect::<BTreeSet<PathBuf>>();
173
174 let mut inbound_dependency_names: BTreeMap<PathBuf, BTreeSet<String>> = BTreeMap::new();
175 let mut dependent_roots: BTreeMap<PathBuf, BTreeSet<PathBuf>> = BTreeMap::new();
176 for edge in &graph.edges {
177 inbound_dependency_names
178 .entry(edge.to.clone())
179 .or_default()
180 .insert(edge.dependency_name.clone());
181 dependent_roots
182 .entry(edge.to.clone())
183 .or_default()
184 .insert(edge.from.clone());
185 }
186
187 let mut sync_order = Vec::with_capacity(order.len());
188 for (order_index, root) in order.iter().enumerate() {
189 let package =
190 package_by_root
191 .get(root)
192 .cloned()
193 .unwrap_or_else(|| CargoPathDependencyPackage {
194 package_root: root.clone(),
195 manifest_path: root.join("Cargo.toml"),
196 package_name: root
197 .file_name()
198 .and_then(|segment| segment.to_str())
199 .unwrap_or("unknown")
200 .to_string(),
201 workspace_member: false,
202 });
203
204 let reason = if package.package_root == entry_root {
205 DependencySyncReason::EntryPoint
206 } else if package.workspace_member {
207 DependencySyncReason::WorkspaceMember
208 } else {
209 DependencySyncReason::TransitivePathDependency
210 };
211
212 let inbound_names = inbound_dependency_names
213 .get(&package.package_root)
214 .map(|set| set.iter().cloned().collect::<Vec<_>>())
215 .unwrap_or_default();
216 let dependents = dependent_roots
217 .get(&package.package_root)
218 .map(|set| set.iter().cloned().collect::<Vec<_>>())
219 .unwrap_or_default();
220
221 let risk = classify_sync_risk(reason, dependents.len());
222 let metadata = DependencySyncMetadata {
223 reason,
224 workspace_member: package.workspace_member,
225 root_package: root_packages.contains(&package.package_root),
226 inbound_dependency_names: inbound_names,
227 dependent_roots: dependents.clone(),
228 notes: vec![format!("dependent_root_count={}", dependents.len())],
229 };
230
231 sync_order.push(DependencySyncAction {
232 order_index,
233 package_root: package.package_root.clone(),
234 manifest_path: package.manifest_path,
235 package_name: package.package_name,
236 risk,
237 metadata,
238 });
239 }
240
241 let canonical_roots = sync_order
242 .iter()
243 .map(|action| action.package_root.clone())
244 .collect::<Vec<_>>();
245
246 DependencyClosurePlan {
247 state: DependencyClosurePlanState::Ready,
248 entry_manifest_path: graph.entry_manifest_path.clone(),
249 workspace_root: graph.workspace_root.clone(),
250 canonical_roots,
251 sync_order,
252 fail_open: false,
253 fail_open_reason: None,
254 issues: Vec::new(),
255 }
256}
257
258fn classify_sync_risk(
259 reason: DependencySyncReason,
260 dependent_root_count: usize,
261) -> DependencyRiskClass {
262 match reason {
263 DependencySyncReason::EntryPoint | DependencySyncReason::WorkspaceMember => {
264 DependencyRiskClass::Low
265 }
266 DependencySyncReason::TransitivePathDependency => {
267 if dependent_root_count > 1 {
268 DependencyRiskClass::High
269 } else {
270 DependencyRiskClass::Medium
271 }
272 }
273 }
274}
275
276fn dependency_first_topological_order(
277 packages: &[CargoPathDependencyPackage],
278 edges: &[CargoPathDependencyEdge],
279) -> Option<Vec<PathBuf>> {
280 let mut nodes = packages
281 .iter()
282 .map(|package| package.package_root.clone())
283 .collect::<BTreeSet<_>>();
284 for edge in edges {
285 nodes.insert(edge.from.clone());
286 nodes.insert(edge.to.clone());
287 }
288
289 let mut indegree = nodes
290 .iter()
291 .cloned()
292 .map(|node| (node, 0usize))
293 .collect::<BTreeMap<_, _>>();
294 let mut dependents_by_dependency: BTreeMap<PathBuf, BTreeSet<PathBuf>> = BTreeMap::new();
295
296 for edge in edges {
297 let from_indegree = indegree.get_mut(&edge.from)?;
298 *from_indegree += 1;
299 dependents_by_dependency
300 .entry(edge.to.clone())
301 .or_default()
302 .insert(edge.from.clone());
303 }
304
305 let mut ready = indegree
306 .iter()
307 .filter_map(|(node, degree)| {
308 if *degree == 0 {
309 Some(node.clone())
310 } else {
311 None
312 }
313 })
314 .collect::<BTreeSet<_>>();
315
316 let mut order = Vec::with_capacity(indegree.len());
317 while let Some(node) = ready.pop_first() {
318 order.push(node.clone());
319 if let Some(dependents) = dependents_by_dependency.get(&node) {
320 for dependent in dependents {
321 let degree = indegree.get_mut(dependent)?;
322 if *degree == 0 {
323 return None;
324 }
325 *degree -= 1;
326 if *degree == 0 {
327 ready.insert(dependent.clone());
328 }
329 }
330 }
331 }
332
333 if order.len() == indegree.len() {
334 Some(order)
335 } else {
336 None
337 }
338}
339
340fn fail_open_plan_from_resolver_error(
341 entrypoint: &Path,
342 error: &CargoPathDependencyError,
343) -> DependencyClosurePlan {
344 let issue = issue_from_resolver_error(error);
345 let entry_manifest_path = error
346 .manifest_path()
347 .map(Path::to_path_buf)
348 .unwrap_or_else(|| entrypoint.to_path_buf());
349
350 DependencyClosurePlan {
351 state: DependencyClosurePlanState::FailOpen,
352 entry_manifest_path,
353 workspace_root: None,
354 canonical_roots: Vec::new(),
355 sync_order: Vec::new(),
356 fail_open: true,
357 fail_open_reason: Some(format!(
358 "resolver produced {}: {}",
359 error.kind(),
360 error.detail()
361 )),
362 issues: vec![issue],
363 }
364}
365
366fn issue_from_resolver_error(error: &CargoPathDependencyError) -> DependencyPlanIssue {
367 let (code, risk) = match error.kind() {
368 CargoPathDependencyErrorKind::ManifestParseFailure => {
369 ("manifest-parse-failure", DependencyRiskClass::Critical)
370 }
371 CargoPathDependencyErrorKind::MetadataParseFailure => {
372 ("metadata-parse-failure", DependencyRiskClass::Critical)
373 }
374 CargoPathDependencyErrorKind::MetadataInvocationFailure => {
375 ("metadata-invocation-failure", DependencyRiskClass::Critical)
376 }
377 CargoPathDependencyErrorKind::CyclicDependency => {
378 ("cyclic-path-dependency", DependencyRiskClass::Critical)
379 }
380 CargoPathDependencyErrorKind::PathPolicyViolation => {
381 ("path-policy-violation", DependencyRiskClass::High)
382 }
383 CargoPathDependencyErrorKind::MissingPathDependency => {
384 ("missing-path-dependency", DependencyRiskClass::High)
385 }
386 };
387
388 let mut diagnostics = error.diagnostics().to_vec();
389 if let Some(dependency_name) = error.dependency_name() {
390 diagnostics.push(format!("dependency_name={dependency_name}"));
391 }
392 if let Some(dependency_path) = error.dependency_path() {
393 diagnostics.push(format!("dependency_path={}", dependency_path.display()));
394 }
395 if !error.cycle().is_empty() {
396 diagnostics.push(format!("cycle={:?}", error.cycle()));
397 }
398
399 DependencyPlanIssue {
400 code: code.to_string(),
401 message: format!("{}: {}", error.kind(), error.detail()),
402 risk,
403 diagnostics,
404 }
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 fn package(root: &str, name: &str, workspace_member: bool) -> CargoPathDependencyPackage {
412 CargoPathDependencyPackage {
413 package_root: PathBuf::from(root),
414 manifest_path: PathBuf::from(root).join("Cargo.toml"),
415 package_name: name.to_string(),
416 workspace_member,
417 }
418 }
419
420 fn edge(from: &str, to: &str, dependency_name: &str) -> CargoPathDependencyEdge {
421 CargoPathDependencyEdge {
422 from: PathBuf::from(from),
423 to: PathBuf::from(to),
424 dependency_name: dependency_name.to_string(),
425 }
426 }
427
428 #[test]
429 fn planner_produces_dependency_first_deterministic_sync_order() {
430 let graph = CargoPathDependencyGraph {
431 entry_manifest_path: PathBuf::from("/data/projects/app/Cargo.toml"),
432 workspace_root: Some(PathBuf::from("/data/projects")),
433 root_packages: vec![PathBuf::from("/data/projects/app")],
434 packages: vec![
435 package("/data/projects/app", "app", true),
436 package("/data/projects/lib_a", "lib_a", false),
437 package("/data/projects/lib_b", "lib_b", false),
438 ],
439 edges: vec![
440 edge("/data/projects/app", "/data/projects/lib_a", "lib_a"),
441 edge("/data/projects/lib_a", "/data/projects/lib_b", "lib_b"),
442 ],
443 };
444
445 let plan = plan_dependency_closure_from_graph(&graph);
446 assert!(plan.is_ready(), "acyclic graph should be planner-ready");
447 assert_eq!(plan.sync_order.len(), 3);
448
449 let ordered_roots = plan
450 .sync_order
451 .iter()
452 .map(|action| action.package_root.as_path())
453 .collect::<Vec<_>>();
454 assert_eq!(
455 ordered_roots,
456 vec![
457 Path::new("/data/projects/lib_b"),
458 Path::new("/data/projects/lib_a"),
459 Path::new("/data/projects/app"),
460 ],
461 "planner must sync dependencies before dependents"
462 );
463 assert_eq!(
464 plan.sync_order[0].metadata.reason,
465 DependencySyncReason::TransitivePathDependency
466 );
467 assert_eq!(
468 plan.sync_order[2].metadata.reason,
469 DependencySyncReason::EntryPoint
470 );
471 }
472
473 #[test]
474 fn planner_cycle_fails_open_with_stable_issue_code() {
475 let graph = CargoPathDependencyGraph {
476 entry_manifest_path: PathBuf::from("/data/projects/cycle_a/Cargo.toml"),
477 workspace_root: None,
478 root_packages: vec![PathBuf::from("/data/projects/cycle_a")],
479 packages: vec![
480 package("/data/projects/cycle_a", "cycle_a", false),
481 package("/data/projects/cycle_b", "cycle_b", false),
482 ],
483 edges: vec![
484 edge(
485 "/data/projects/cycle_a",
486 "/data/projects/cycle_b",
487 "cycle_b",
488 ),
489 edge(
490 "/data/projects/cycle_b",
491 "/data/projects/cycle_a",
492 "cycle_a",
493 ),
494 ],
495 };
496
497 let plan = plan_dependency_closure_from_graph(&graph);
498 assert_eq!(plan.state, DependencyClosurePlanState::FailOpen);
499 assert!(plan.fail_open);
500 assert_eq!(plan.sync_order.len(), 0);
501 assert_eq!(plan.issues.len(), 1);
502 assert_eq!(plan.issues[0].code, "planner_non_deterministic_order");
503 assert_eq!(plan.issues[0].risk, DependencyRiskClass::Critical);
504 }
505
506 #[test]
507 fn resolver_error_mapping_reports_path_policy_violation_code() {
508 let error = CargoPathDependencyError::new(
509 CargoPathDependencyErrorKind::PathPolicyViolation,
510 "dependency escaped canonical root",
511 )
512 .with_manifest_path("/data/projects/app/Cargo.toml")
513 .with_dependency_name("bad_dep")
514 .with_dependency_path("/tmp/outside");
515
516 let issue = issue_from_resolver_error(&error);
517 assert_eq!(issue.code, "path-policy-violation");
518 assert_eq!(issue.risk, DependencyRiskClass::High);
519 assert!(
520 issue
521 .diagnostics
522 .iter()
523 .any(|line| line.contains("dependency_path=/tmp/outside"))
524 );
525 }
526
527 #[test]
528 fn resolver_error_mapping_reports_manifest_parse_failure_code() {
529 let error = CargoPathDependencyError::new(
530 CargoPathDependencyErrorKind::ManifestParseFailure,
531 "invalid Cargo.toml syntax",
532 )
533 .with_manifest_path("/data/projects/app/Cargo.toml");
534
535 let plan = fail_open_plan_from_resolver_error(Path::new("/data/projects/app"), &error);
536 assert_eq!(plan.state, DependencyClosurePlanState::FailOpen);
537 assert_eq!(plan.issues.len(), 1);
538 assert_eq!(plan.issues[0].code, "manifest-parse-failure");
539 assert_eq!(plan.issues[0].risk, DependencyRiskClass::Critical);
540 assert!(
541 plan.fail_open_reason
542 .as_deref()
543 .is_some_and(|reason| reason.contains("manifest parse failure"))
544 );
545 }
546
547 #[test]
552 fn empty_graph_produces_empty_ready_plan() {
553 let graph = CargoPathDependencyGraph {
554 entry_manifest_path: PathBuf::from("/data/projects/empty/Cargo.toml"),
555 workspace_root: None,
556 root_packages: Vec::new(),
557 packages: Vec::new(),
558 edges: Vec::new(),
559 };
560
561 let plan = plan_dependency_closure_from_graph(&graph);
562 assert!(plan.is_ready(), "empty graph should still be ready");
563 assert_eq!(plan.sync_order.len(), 0);
564 assert_eq!(plan.canonical_roots.len(), 0);
565 assert!(plan.issues.is_empty());
566 assert!(!plan.fail_open);
567 }
568
569 #[test]
570 fn single_package_no_deps_produces_single_entry_point_action() {
571 let graph = CargoPathDependencyGraph {
572 entry_manifest_path: PathBuf::from("/data/projects/solo/Cargo.toml"),
573 workspace_root: None,
574 root_packages: vec![PathBuf::from("/data/projects/solo")],
575 packages: vec![package("/data/projects/solo", "solo-crate", false)],
576 edges: Vec::new(),
577 };
578
579 let plan = plan_dependency_closure_from_graph(&graph);
580 assert!(plan.is_ready());
581 assert_eq!(plan.sync_order.len(), 1);
582 assert_eq!(plan.sync_order[0].order_index, 0);
583 assert_eq!(plan.sync_order[0].package_name, "solo-crate");
584 assert_eq!(
585 plan.sync_order[0].metadata.reason,
586 DependencySyncReason::EntryPoint
587 );
588 assert_eq!(plan.sync_order[0].risk, DependencyRiskClass::Low);
589 assert!(
590 plan.sync_order[0]
591 .metadata
592 .inbound_dependency_names
593 .is_empty()
594 );
595 assert!(plan.sync_order[0].metadata.dependent_roots.is_empty());
596 }
597
598 #[test]
603 fn diamond_dependency_preserves_deterministic_order() {
604 let graph = CargoPathDependencyGraph {
605 entry_manifest_path: PathBuf::from("/data/projects/app/Cargo.toml"),
606 workspace_root: Some(PathBuf::from("/data/projects")),
607 root_packages: vec![PathBuf::from("/data/projects/app")],
608 packages: vec![
609 package("/data/projects/app", "app", true),
610 package("/data/projects/b", "lib_b", false),
611 package("/data/projects/c", "lib_c", false),
612 package("/data/projects/d", "lib_d", false),
613 ],
614 edges: vec![
615 edge("/data/projects/app", "/data/projects/b", "lib_b"),
616 edge("/data/projects/app", "/data/projects/c", "lib_c"),
617 edge("/data/projects/b", "/data/projects/d", "lib_d"),
618 edge("/data/projects/c", "/data/projects/d", "lib_d"),
619 ],
620 };
621
622 let plan = plan_dependency_closure_from_graph(&graph);
623 assert!(plan.is_ready(), "diamond graph should be planner-ready");
624 assert_eq!(plan.sync_order.len(), 4);
625
626 let ordered_roots: Vec<_> = plan
627 .sync_order
628 .iter()
629 .map(|a| a.package_root.as_path())
630 .collect();
631
632 let d_pos = ordered_roots
634 .iter()
635 .position(|r| *r == Path::new("/data/projects/d"))
636 .unwrap();
637 let b_pos = ordered_roots
638 .iter()
639 .position(|r| *r == Path::new("/data/projects/b"))
640 .unwrap();
641 let c_pos = ordered_roots
642 .iter()
643 .position(|r| *r == Path::new("/data/projects/c"))
644 .unwrap();
645 let app_pos = ordered_roots
646 .iter()
647 .position(|r| *r == Path::new("/data/projects/app"))
648 .unwrap();
649 assert!(d_pos < b_pos, "D must sync before B");
650 assert!(d_pos < c_pos, "D must sync before C");
651 assert!(b_pos < app_pos, "B must sync before app");
652 assert!(c_pos < app_pos, "C must sync before app");
653
654 let d_action = &plan.sync_order[d_pos];
656 assert_eq!(d_action.risk, DependencyRiskClass::High);
657 assert_eq!(d_action.metadata.dependent_roots.len(), 2);
658
659 let b_action = &plan.sync_order[b_pos];
661 assert_eq!(b_action.risk, DependencyRiskClass::Medium);
662
663 let app_action = &plan.sync_order[app_pos];
665 assert_eq!(app_action.risk, DependencyRiskClass::Low);
666 }
667
668 #[test]
673 fn classify_sync_risk_entry_point_is_always_low() {
674 assert_eq!(
675 classify_sync_risk(DependencySyncReason::EntryPoint, 0),
676 DependencyRiskClass::Low
677 );
678 assert_eq!(
679 classify_sync_risk(DependencySyncReason::EntryPoint, 10),
680 DependencyRiskClass::Low
681 );
682 }
683
684 #[test]
685 fn classify_sync_risk_workspace_member_is_always_low() {
686 assert_eq!(
687 classify_sync_risk(DependencySyncReason::WorkspaceMember, 0),
688 DependencyRiskClass::Low
689 );
690 assert_eq!(
691 classify_sync_risk(DependencySyncReason::WorkspaceMember, 5),
692 DependencyRiskClass::Low
693 );
694 }
695
696 #[test]
697 fn classify_sync_risk_transitive_with_zero_dependents_is_medium() {
698 assert_eq!(
699 classify_sync_risk(DependencySyncReason::TransitivePathDependency, 0),
700 DependencyRiskClass::Medium
701 );
702 }
703
704 #[test]
705 fn classify_sync_risk_transitive_with_one_dependent_is_medium() {
706 assert_eq!(
707 classify_sync_risk(DependencySyncReason::TransitivePathDependency, 1),
708 DependencyRiskClass::Medium
709 );
710 }
711
712 #[test]
713 fn classify_sync_risk_transitive_with_two_dependents_is_high() {
714 assert_eq!(
715 classify_sync_risk(DependencySyncReason::TransitivePathDependency, 2),
716 DependencyRiskClass::High
717 );
718 }
719
720 #[test]
721 fn classify_sync_risk_transitive_with_many_dependents_is_high() {
722 assert_eq!(
723 classify_sync_risk(DependencySyncReason::TransitivePathDependency, 100),
724 DependencyRiskClass::High
725 );
726 }
727
728 #[test]
733 fn wide_fanout_graph_syncs_all_leaves_before_root() {
734 let leaf_count = 20;
735 let mut packages = vec![package("/data/projects/hub", "hub", true)];
736 let mut edges = Vec::new();
737 for i in 0..leaf_count {
738 let root = format!("/data/projects/leaf_{i}");
739 let name = format!("leaf_{i}");
740 packages.push(package(&root, &name, false));
741 edges.push(edge("/data/projects/hub", &root, &name));
742 }
743
744 let graph = CargoPathDependencyGraph {
745 entry_manifest_path: PathBuf::from("/data/projects/hub/Cargo.toml"),
746 workspace_root: Some(PathBuf::from("/data/projects")),
747 root_packages: vec![PathBuf::from("/data/projects/hub")],
748 packages,
749 edges,
750 };
751
752 let plan = plan_dependency_closure_from_graph(&graph);
753 assert!(plan.is_ready());
754 assert_eq!(plan.sync_order.len(), leaf_count + 1);
755
756 let hub_action = plan.sync_order.last().unwrap();
758 assert_eq!(hub_action.package_root, PathBuf::from("/data/projects/hub"));
759 assert_eq!(hub_action.metadata.reason, DependencySyncReason::EntryPoint);
760
761 for action in &plan.sync_order[..leaf_count] {
763 assert_eq!(
764 action.metadata.reason,
765 DependencySyncReason::TransitivePathDependency
766 );
767 assert_eq!(action.metadata.dependent_roots.len(), 1);
768 assert_eq!(action.risk, DependencyRiskClass::Medium);
769 }
770 }
771
772 #[test]
777 fn deep_chain_graph_preserves_order() {
778 let depth = 10;
779 let mut packages = Vec::new();
780 let mut edges = Vec::new();
781 for i in 0..depth {
782 let root = format!("/data/projects/chain_{i}");
783 let name = format!("chain_{i}");
784 packages.push(package(&root, &name, i == depth - 1));
785 if i > 0 {
786 let parent = format!("/data/projects/chain_{}", i);
787 let child = format!("/data/projects/chain_{}", i - 1);
788 edges.push(edge(&parent, &child, &format!("chain_{}", i - 1)));
789 }
790 }
791
792 let graph = CargoPathDependencyGraph {
793 entry_manifest_path: PathBuf::from(format!(
794 "/data/projects/chain_{}/Cargo.toml",
795 depth - 1
796 )),
797 workspace_root: Some(PathBuf::from("/data/projects")),
798 root_packages: vec![PathBuf::from(format!("/data/projects/chain_{}", depth - 1))],
799 packages,
800 edges,
801 };
802
803 let plan = plan_dependency_closure_from_graph(&graph);
804 assert!(plan.is_ready());
805 assert_eq!(plan.sync_order.len(), depth);
806
807 assert_eq!(
809 plan.sync_order[0].package_root,
810 PathBuf::from("/data/projects/chain_0")
811 );
812 assert_eq!(
813 plan.sync_order.last().unwrap().package_root,
814 PathBuf::from(format!("/data/projects/chain_{}", depth - 1))
815 );
816 }
817
818 #[test]
823 fn resolver_error_mapping_metadata_parse_failure() {
824 let error = CargoPathDependencyError::new(
825 CargoPathDependencyErrorKind::MetadataParseFailure,
826 "cannot parse metadata JSON",
827 );
828 let issue = issue_from_resolver_error(&error);
829 assert_eq!(issue.code, "metadata-parse-failure");
830 assert_eq!(issue.risk, DependencyRiskClass::Critical);
831 }
832
833 #[test]
834 fn resolver_error_mapping_metadata_invocation_failure() {
835 let error = CargoPathDependencyError::new(
836 CargoPathDependencyErrorKind::MetadataInvocationFailure,
837 "cargo metadata timed out after 30s",
838 );
839 let issue = issue_from_resolver_error(&error);
840 assert_eq!(issue.code, "metadata-invocation-failure");
841 assert_eq!(issue.risk, DependencyRiskClass::Critical);
842 }
843
844 #[test]
845 fn resolver_error_mapping_missing_path_dependency() {
846 let error = CargoPathDependencyError::new(
847 CargoPathDependencyErrorKind::MissingPathDependency,
848 "path dep does not exist on disk",
849 )
850 .with_dependency_name("phantom_dep")
851 .with_dependency_path("/data/projects/nonexistent");
852
853 let issue = issue_from_resolver_error(&error);
854 assert_eq!(issue.code, "missing-path-dependency");
855 assert_eq!(issue.risk, DependencyRiskClass::High);
856 assert!(
857 issue
858 .diagnostics
859 .iter()
860 .any(|d| d.contains("dependency_name=phantom_dep")),
861 "diagnostics should include dependency name"
862 );
863 assert!(
864 issue
865 .diagnostics
866 .iter()
867 .any(|d| d.contains("dependency_path=/data/projects/nonexistent")),
868 "diagnostics should include dependency path"
869 );
870 }
871
872 #[test]
873 fn resolver_error_mapping_cyclic_dependency() {
874 let error = CargoPathDependencyError::new(
875 CargoPathDependencyErrorKind::CyclicDependency,
876 "circular path dependency detected",
877 );
878
879 let issue = issue_from_resolver_error(&error);
880 assert_eq!(issue.code, "cyclic-path-dependency");
881 assert_eq!(issue.risk, DependencyRiskClass::Critical);
882 assert!(
883 issue.message.contains("cyclic path dependency"),
884 "message should contain error kind, got: {}",
885 issue.message
886 );
887 }
888
889 #[test]
894 fn fail_open_plan_preserves_manifest_path_from_error() {
895 let error = CargoPathDependencyError::new(
896 CargoPathDependencyErrorKind::MetadataInvocationFailure,
897 "timeout",
898 )
899 .with_manifest_path("/data/projects/app/Cargo.toml");
900
901 let plan =
902 fail_open_plan_from_resolver_error(Path::new("/data/projects/app/Cargo.toml"), &error);
903 assert_eq!(
904 plan.entry_manifest_path,
905 PathBuf::from("/data/projects/app/Cargo.toml")
906 );
907 assert_eq!(plan.state, DependencyClosurePlanState::FailOpen);
908 assert!(plan.fail_open);
909 assert!(plan.sync_order.is_empty());
910 assert!(plan.canonical_roots.is_empty());
911 assert!(plan.workspace_root.is_none());
912 }
913
914 #[test]
915 fn fail_open_plan_uses_entrypoint_when_error_has_no_manifest_path() {
916 let error = CargoPathDependencyError::new(
917 CargoPathDependencyErrorKind::MissingPathDependency,
918 "dep not found",
919 );
920
921 let plan = fail_open_plan_from_resolver_error(Path::new("/data/projects/fallback"), &error);
922 assert_eq!(
923 plan.entry_manifest_path,
924 PathBuf::from("/data/projects/fallback")
925 );
926 }
927
928 #[test]
933 fn is_ready_returns_false_for_fail_open() {
934 let plan = DependencyClosurePlan {
935 state: DependencyClosurePlanState::FailOpen,
936 entry_manifest_path: PathBuf::from("/tmp/Cargo.toml"),
937 workspace_root: None,
938 canonical_roots: Vec::new(),
939 sync_order: Vec::new(),
940 fail_open: true,
941 fail_open_reason: Some("test".to_string()),
942 issues: Vec::new(),
943 };
944 assert!(!plan.is_ready());
945 }
946
947 #[test]
948 fn sync_roots_returns_roots_in_sync_order() {
949 let plan = DependencyClosurePlan {
950 state: DependencyClosurePlanState::Ready,
951 entry_manifest_path: PathBuf::from("/data/projects/app/Cargo.toml"),
952 workspace_root: None,
953 canonical_roots: vec![
954 PathBuf::from("/data/projects/dep"),
955 PathBuf::from("/data/projects/app"),
956 ],
957 sync_order: vec![
958 DependencySyncAction {
959 order_index: 0,
960 package_root: PathBuf::from("/data/projects/dep"),
961 manifest_path: PathBuf::from("/data/projects/dep/Cargo.toml"),
962 package_name: "dep".to_string(),
963 risk: DependencyRiskClass::Medium,
964 metadata: DependencySyncMetadata {
965 reason: DependencySyncReason::TransitivePathDependency,
966 workspace_member: false,
967 root_package: false,
968 inbound_dependency_names: vec!["dep".to_string()],
969 dependent_roots: vec![PathBuf::from("/data/projects/app")],
970 notes: vec!["dependent_root_count=1".to_string()],
971 },
972 },
973 DependencySyncAction {
974 order_index: 1,
975 package_root: PathBuf::from("/data/projects/app"),
976 manifest_path: PathBuf::from("/data/projects/app/Cargo.toml"),
977 package_name: "app".to_string(),
978 risk: DependencyRiskClass::Low,
979 metadata: DependencySyncMetadata {
980 reason: DependencySyncReason::EntryPoint,
981 workspace_member: false,
982 root_package: true,
983 inbound_dependency_names: Vec::new(),
984 dependent_roots: Vec::new(),
985 notes: vec!["dependent_root_count=0".to_string()],
986 },
987 },
988 ],
989 fail_open: false,
990 fail_open_reason: None,
991 issues: Vec::new(),
992 };
993
994 let roots = plan.sync_roots();
995 assert_eq!(
996 roots,
997 vec![
998 PathBuf::from("/data/projects/dep"),
999 PathBuf::from("/data/projects/app"),
1000 ]
1001 );
1002 }
1003
1004 #[test]
1009 fn workspace_member_gets_workspace_member_reason() {
1010 let graph = CargoPathDependencyGraph {
1011 entry_manifest_path: PathBuf::from("/data/projects/app/Cargo.toml"),
1012 workspace_root: Some(PathBuf::from("/data/projects")),
1013 root_packages: vec![PathBuf::from("/data/projects/app")],
1014 packages: vec![
1015 package("/data/projects/app", "app", true),
1016 package("/data/projects/member", "member", true),
1017 ],
1018 edges: vec![edge(
1019 "/data/projects/app",
1020 "/data/projects/member",
1021 "member",
1022 )],
1023 };
1024
1025 let plan = plan_dependency_closure_from_graph(&graph);
1026 assert!(plan.is_ready());
1027
1028 let member_action = plan
1029 .sync_order
1030 .iter()
1031 .find(|a| a.package_name == "member")
1032 .unwrap();
1033 assert_eq!(
1034 member_action.metadata.reason,
1035 DependencySyncReason::WorkspaceMember
1036 );
1037 assert!(member_action.metadata.workspace_member);
1038 assert_eq!(member_action.risk, DependencyRiskClass::Low);
1039 }
1040
1041 #[test]
1046 fn inbound_dependency_names_are_deduplicated() {
1047 let graph = CargoPathDependencyGraph {
1048 entry_manifest_path: PathBuf::from("/data/projects/app/Cargo.toml"),
1049 workspace_root: None,
1050 root_packages: vec![PathBuf::from("/data/projects/app")],
1051 packages: vec![
1052 package("/data/projects/app", "app", false),
1053 package("/data/projects/dep", "dep", false),
1054 package("/data/projects/other", "other", false),
1055 ],
1056 edges: vec![
1057 edge("/data/projects/app", "/data/projects/dep", "dep"),
1058 edge("/data/projects/other", "/data/projects/dep", "dep"),
1059 ],
1060 };
1061
1062 let plan = plan_dependency_closure_from_graph(&graph);
1063 let dep_action = plan
1064 .sync_order
1065 .iter()
1066 .find(|a| a.package_name == "dep")
1067 .unwrap();
1068 assert_eq!(dep_action.metadata.inbound_dependency_names, vec!["dep"]);
1070 }
1071
1072 #[test]
1077 fn topological_sort_is_deterministic_across_calls() {
1078 let graph = CargoPathDependencyGraph {
1079 entry_manifest_path: PathBuf::from("/data/projects/app/Cargo.toml"),
1080 workspace_root: None,
1081 root_packages: vec![PathBuf::from("/data/projects/app")],
1082 packages: vec![
1083 package("/data/projects/app", "app", false),
1084 package("/data/projects/a", "a", false),
1085 package("/data/projects/b", "b", false),
1086 package("/data/projects/c", "c", false),
1087 ],
1088 edges: vec![
1089 edge("/data/projects/app", "/data/projects/a", "a"),
1090 edge("/data/projects/app", "/data/projects/b", "b"),
1091 edge("/data/projects/app", "/data/projects/c", "c"),
1092 ],
1093 };
1094
1095 let plan1 = plan_dependency_closure_from_graph(&graph);
1096 let plan2 = plan_dependency_closure_from_graph(&graph);
1097
1098 let roots1: Vec<_> = plan1.sync_order.iter().map(|a| &a.package_root).collect();
1099 let roots2: Vec<_> = plan2.sync_order.iter().map(|a| &a.package_root).collect();
1100 assert_eq!(roots1, roots2, "topological order must be deterministic");
1101 }
1102
1103 #[test]
1108 fn notes_include_dependent_root_count() {
1109 let graph = CargoPathDependencyGraph {
1110 entry_manifest_path: PathBuf::from("/data/projects/app/Cargo.toml"),
1111 workspace_root: None,
1112 root_packages: vec![PathBuf::from("/data/projects/app")],
1113 packages: vec![
1114 package("/data/projects/app", "app", false),
1115 package("/data/projects/dep", "dep", false),
1116 ],
1117 edges: vec![edge("/data/projects/app", "/data/projects/dep", "dep")],
1118 };
1119
1120 let plan = plan_dependency_closure_from_graph(&graph);
1121 let dep_action = plan
1122 .sync_order
1123 .iter()
1124 .find(|a| a.package_name == "dep")
1125 .unwrap();
1126 assert!(
1127 dep_action
1128 .metadata
1129 .notes
1130 .iter()
1131 .any(|n| n == "dependent_root_count=1"),
1132 "notes should include dependent_root_count"
1133 );
1134
1135 let app_action = plan
1136 .sync_order
1137 .iter()
1138 .find(|a| a.package_name == "app")
1139 .unwrap();
1140 assert!(
1141 app_action
1142 .metadata
1143 .notes
1144 .iter()
1145 .any(|n| n == "dependent_root_count=0"),
1146 "entry point should have 0 dependent roots"
1147 );
1148 }
1149
1150 #[test]
1155 fn plan_serialization_round_trip() {
1156 let graph = CargoPathDependencyGraph {
1157 entry_manifest_path: PathBuf::from("/data/projects/app/Cargo.toml"),
1158 workspace_root: Some(PathBuf::from("/data/projects")),
1159 root_packages: vec![PathBuf::from("/data/projects/app")],
1160 packages: vec![
1161 package("/data/projects/app", "app", true),
1162 package("/data/projects/dep", "dep", false),
1163 ],
1164 edges: vec![edge("/data/projects/app", "/data/projects/dep", "dep")],
1165 };
1166
1167 let plan = plan_dependency_closure_from_graph(&graph);
1168 let json = serde_json::to_string(&plan).expect("plan should serialize");
1169 let deserialized: DependencyClosurePlan =
1170 serde_json::from_str(&json).expect("plan should deserialize");
1171 assert_eq!(plan, deserialized);
1172 }
1173
1174 #[test]
1175 fn fail_open_plan_serialization_round_trip() {
1176 let error = CargoPathDependencyError::new(
1177 CargoPathDependencyErrorKind::CyclicDependency,
1178 "cycle detected",
1179 );
1180
1181 let plan = fail_open_plan_from_resolver_error(Path::new("/data/projects/app"), &error);
1182 let json = serde_json::to_string(&plan).expect("fail-open plan should serialize");
1183 let deserialized: DependencyClosurePlan =
1184 serde_json::from_str(&json).expect("fail-open plan should deserialize");
1185 assert_eq!(plan, deserialized);
1186 }
1187
1188 #[test]
1193 fn root_package_flag_set_correctly() {
1194 let graph = CargoPathDependencyGraph {
1195 entry_manifest_path: PathBuf::from("/data/projects/app/Cargo.toml"),
1196 workspace_root: None,
1197 root_packages: vec![PathBuf::from("/data/projects/app")],
1198 packages: vec![
1199 package("/data/projects/app", "app", false),
1200 package("/data/projects/dep", "dep", false),
1201 ],
1202 edges: vec![edge("/data/projects/app", "/data/projects/dep", "dep")],
1203 };
1204
1205 let plan = plan_dependency_closure_from_graph(&graph);
1206 let app_action = plan
1207 .sync_order
1208 .iter()
1209 .find(|a| a.package_name == "app")
1210 .unwrap();
1211 assert!(
1212 app_action.metadata.root_package,
1213 "app should be marked as root_package"
1214 );
1215
1216 let dep_action = plan
1217 .sync_order
1218 .iter()
1219 .find(|a| a.package_name == "dep")
1220 .unwrap();
1221 assert!(
1222 !dep_action.metadata.root_package,
1223 "dep should not be marked as root_package"
1224 );
1225 }
1226
1227 #[test]
1232 fn order_indices_are_sequential_from_zero() {
1233 let graph = CargoPathDependencyGraph {
1234 entry_manifest_path: PathBuf::from("/data/projects/app/Cargo.toml"),
1235 workspace_root: None,
1236 root_packages: vec![PathBuf::from("/data/projects/app")],
1237 packages: vec![
1238 package("/data/projects/app", "app", false),
1239 package("/data/projects/a", "a", false),
1240 package("/data/projects/b", "b", false),
1241 ],
1242 edges: vec![
1243 edge("/data/projects/app", "/data/projects/a", "a"),
1244 edge("/data/projects/a", "/data/projects/b", "b"),
1245 ],
1246 };
1247
1248 let plan = plan_dependency_closure_from_graph(&graph);
1249 for (i, action) in plan.sync_order.iter().enumerate() {
1250 assert_eq!(action.order_index, i, "order_index should be sequential");
1251 }
1252 }
1253}