Skip to main content

rch_common/
dependency_closure_planner.rs

1//! Dependency-closure planning on top of Cargo local path-dependency resolution.
2//!
3//! This planner converts resolver graph output into deterministic sync actions that
4//! can be consumed by transfer and preflight stages. It also encodes explicit
5//! fail-open fallback state when closure data is unsafe or unverifiable.
6
7use 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/// Planner-level lifecycle state for dependency closure.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum DependencyClosurePlanState {
21    Ready,
22    FailOpen,
23}
24
25/// Risk class attached to each sync action and planner issue.
26#[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/// Why a specific root is included in the closure plan.
36#[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/// Structured reason metadata for one sync action.
45#[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/// Deterministic sync action for one canonical root.
56#[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/// Planner issue emitted for unsafe or unverifiable closure states.
67#[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/// Transfer/preflight-ready dependency closure plan.
76#[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    /// True when closure is safe and deterministic for direct consumption.
90    pub fn is_ready(&self) -> bool {
91        self.state == DependencyClosurePlanState::Ready && !self.fail_open
92    }
93
94    /// Canonical root list in planner sync order.
95    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
103/// Build a closure plan using default canonical topology policy.
104pub fn build_dependency_closure_plan(entrypoint: &Path) -> DependencyClosurePlan {
105    build_dependency_closure_plan_with_policy(entrypoint, &PathTopologyPolicy::default())
106}
107
108/// Build a closure plan using explicit topology policy.
109///
110/// This function is fail-open by design: resolver/planner failures are converted
111/// into a `FailOpen` plan with structured issues and fallback rationale.
112pub 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
122/// Convert a resolved graph into deterministic sync actions.
123pub 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    // =======================================================================
548    // Empty and single-package closure tests
549    // =======================================================================
550
551    #[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    // =======================================================================
599    // Diamond dependency pattern (A→B, A→C, B→D, C→D)
600    // =======================================================================
601
602    #[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        // D must come before B and C; B and C must come before app.
633        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        // D has 2 dependent roots (B and C), so it should be High risk.
655        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        // B and C each have 1 dependent root (app), so Medium risk.
660        let b_action = &plan.sync_order[b_pos];
661        assert_eq!(b_action.risk, DependencyRiskClass::Medium);
662
663        // app is entry point, so Low risk.
664        let app_action = &plan.sync_order[app_pos];
665        assert_eq!(app_action.risk, DependencyRiskClass::Low);
666    }
667
668    // =======================================================================
669    // Risk classification boundary tests
670    // =======================================================================
671
672    #[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    // =======================================================================
729    // Wide fan-out graph test
730    // =======================================================================
731
732    #[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        // Hub must be last.
757        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        // All leaves must come before hub.
762        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    // =======================================================================
773    // Deep chain graph test
774    // =======================================================================
775
776    #[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        // chain_0 should be first (leaf), chain_{depth-1} should be last (entry point).
808        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    // =======================================================================
819    // All error kind mappings
820    // =======================================================================
821
822    #[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    // =======================================================================
890    // fail_open_plan_from_resolver_error tests
891    // =======================================================================
892
893    #[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    // =======================================================================
929    // DependencyClosurePlan API tests
930    // =======================================================================
931
932    #[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    // =======================================================================
1005    // Workspace member classification tests
1006    // =======================================================================
1007
1008    #[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    // =======================================================================
1042    // Inbound dependency name deduplication
1043    // =======================================================================
1044
1045    #[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        // Same dependency name from two sources — should appear only once (BTreeSet dedup).
1069        assert_eq!(dep_action.metadata.inbound_dependency_names, vec!["dep"]);
1070    }
1071
1072    // =======================================================================
1073    // Topological order determinism
1074    // =======================================================================
1075
1076    #[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    // =======================================================================
1104    // Notes field validation
1105    // =======================================================================
1106
1107    #[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    // =======================================================================
1151    // Serialization round-trip
1152    // =======================================================================
1153
1154    #[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    // =======================================================================
1189    // root_package flag validation
1190    // =======================================================================
1191
1192    #[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    // =======================================================================
1228    // Order indices are sequential
1229    // =======================================================================
1230
1231    #[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}