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, CargoPathMaterializationClosure,
14    PathTopologyPolicy, resolve_cargo_path_dependency_graph_with_policy,
15    resolve_cargo_path_materialization_closure_with_policy,
16};
17
18/// Planner-level lifecycle state for dependency closure.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum DependencyClosurePlanState {
22    Ready,
23    FailOpen,
24}
25
26/// Risk class attached to each sync action and planner issue.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum DependencyRiskClass {
30    Low,
31    Medium,
32    High,
33    Critical,
34}
35
36/// Why a specific root is included in the closure plan.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum DependencySyncReason {
40    EntryPoint,
41    WorkspaceMember,
42    TransitivePathDependency,
43}
44
45/// Structured reason metadata for one sync action.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct DependencySyncMetadata {
48    pub reason: DependencySyncReason,
49    pub workspace_member: bool,
50    pub root_package: bool,
51    pub inbound_dependency_names: Vec<String>,
52    pub dependent_roots: Vec<PathBuf>,
53    pub notes: Vec<String>,
54}
55
56/// Deterministic sync action for one canonical root.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct DependencySyncAction {
59    pub order_index: usize,
60    pub package_root: PathBuf,
61    pub manifest_path: PathBuf,
62    pub package_name: String,
63    pub risk: DependencyRiskClass,
64    pub metadata: DependencySyncMetadata,
65}
66
67/// Planner issue emitted for unsafe or unverifiable closure states.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct DependencyPlanIssue {
70    pub code: String,
71    pub message: String,
72    pub risk: DependencyRiskClass,
73    pub diagnostics: Vec<String>,
74}
75
76/// Transfer/preflight-ready dependency closure plan.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct DependencyClosurePlan {
79    pub state: DependencyClosurePlanState,
80    pub entry_manifest_path: PathBuf,
81    pub workspace_root: Option<PathBuf>,
82    pub canonical_roots: Vec<PathBuf>,
83    pub sync_order: Vec<DependencySyncAction>,
84    pub fail_open: bool,
85    pub fail_open_reason: Option<String>,
86    pub issues: Vec<DependencyPlanIssue>,
87}
88
89impl DependencyClosurePlan {
90    /// True when closure is safe and deterministic for direct consumption.
91    pub fn is_ready(&self) -> bool {
92        self.state == DependencyClosurePlanState::Ready && !self.fail_open
93    }
94
95    /// Canonical root list in planner sync order.
96    pub fn sync_roots(&self) -> Vec<PathBuf> {
97        self.sync_order
98            .iter()
99            .map(|action| action.package_root.clone())
100            .collect()
101    }
102}
103
104/// Build a closure plan using default canonical topology policy.
105pub fn build_dependency_closure_plan(entrypoint: &Path) -> DependencyClosurePlan {
106    build_dependency_closure_plan_with_policy(entrypoint, &PathTopologyPolicy::default())
107}
108
109/// Build a closure plan using explicit topology policy.
110///
111/// This function is fail-open by design: resolver/planner failures are converted
112/// into a `FailOpen` plan with structured issues and fallback rationale.
113pub fn build_dependency_closure_plan_with_policy(
114    entrypoint: &Path,
115    policy: &PathTopologyPolicy,
116) -> DependencyClosurePlan {
117    let graph = match resolve_cargo_path_dependency_graph_with_policy(entrypoint, policy) {
118        Ok(graph) => graph,
119        Err(error) => return fail_open_plan_from_resolver_error(entrypoint, &error),
120    };
121    let materialization_entrypoint = graph.workspace_root.as_deref().unwrap_or(entrypoint);
122    let materialization = match resolve_cargo_path_materialization_closure_with_policy(
123        materialization_entrypoint,
124        policy,
125    ) {
126        Ok(materialization) => materialization,
127        Err(error) => {
128            let mut plan = fail_open_plan_from_resolver_error(entrypoint, &error);
129            plan.issues.insert(
130                0,
131                DependencyPlanIssue {
132                    code: "materialization-closure-unavailable".to_string(),
133                    message: format!(
134                        "Cargo path materialization closure is unavailable: {}: {}",
135                        error.kind(),
136                        error.detail()
137                    ),
138                    risk: DependencyRiskClass::Critical,
139                    diagnostics: vec![
140                        format!(
141                            "materialization_entrypoint={}",
142                            materialization_entrypoint.display()
143                        ),
144                        format!("source_error_kind={}", error.kind()),
145                    ],
146                },
147            );
148            plan.fail_open_reason = Some(format!(
149                "materialization closure produced {}: {}",
150                error.kind(),
151                error.detail()
152            ));
153            return plan;
154        }
155    };
156
157    plan_dependency_closure_from_graph_and_materialization(&graph, Some(&materialization))
158}
159
160/// Convert a resolved graph into deterministic sync actions.
161pub fn plan_dependency_closure_from_graph(
162    graph: &CargoPathDependencyGraph,
163) -> DependencyClosurePlan {
164    plan_dependency_closure_from_graph_and_materialization(graph, None)
165}
166
167fn plan_dependency_closure_from_graph_and_materialization(
168    graph: &CargoPathDependencyGraph,
169    materialization: Option<&CargoPathMaterializationClosure>,
170) -> DependencyClosurePlan {
171    let package_by_root: BTreeMap<PathBuf, CargoPathDependencyPackage> = graph
172        .packages
173        .iter()
174        .cloned()
175        .map(|package| (package.package_root.clone(), package))
176        .collect();
177
178    let order = match dependency_first_topological_order(&graph.packages, &graph.edges) {
179        Some(order) => order,
180        None => {
181            return DependencyClosurePlan {
182                state: DependencyClosurePlanState::FailOpen,
183                entry_manifest_path: graph.entry_manifest_path.clone(),
184                workspace_root: graph.workspace_root.clone(),
185                canonical_roots: Vec::new(),
186                sync_order: Vec::new(),
187                fail_open: true,
188                fail_open_reason: Some(
189                    "planner could not derive deterministic order from dependency graph"
190                        .to_string(),
191                ),
192                issues: vec![DependencyPlanIssue {
193                    code: "planner_non_deterministic_order".to_string(),
194                    message:
195                        "dependency graph order is unverifiable; planner switched to fail-open"
196                            .to_string(),
197                    risk: DependencyRiskClass::Critical,
198                    diagnostics: vec![
199                        format!("packages={}", graph.packages.len()),
200                        format!("edges={}", graph.edges.len()),
201                    ],
202                }],
203            };
204        }
205    };
206
207    let entry_root = graph
208        .entry_manifest_path
209        .parent()
210        .map(Path::to_path_buf)
211        .unwrap_or_else(|| PathBuf::from("/"));
212
213    let root_packages = graph
214        .root_packages
215        .iter()
216        .cloned()
217        .collect::<BTreeSet<PathBuf>>();
218
219    let mut inbound_dependency_names: BTreeMap<PathBuf, BTreeSet<String>> = BTreeMap::new();
220    let mut dependent_roots: BTreeMap<PathBuf, BTreeSet<PathBuf>> = BTreeMap::new();
221    for edge in &graph.edges {
222        inbound_dependency_names
223            .entry(edge.to.clone())
224            .or_default()
225            .insert(edge.dependency_name.clone());
226        dependent_roots
227            .entry(edge.to.clone())
228            .or_default()
229            .insert(edge.from.clone());
230    }
231
232    let mut sync_order = Vec::with_capacity(order.len());
233    for root in &order {
234        let package =
235            package_by_root
236                .get(root)
237                .cloned()
238                .unwrap_or_else(|| CargoPathDependencyPackage {
239                    package_root: root.clone(),
240                    manifest_path: root.join("Cargo.toml"),
241                    package_name: root
242                        .file_name()
243                        .and_then(|segment| segment.to_str())
244                        .unwrap_or("unknown")
245                        .to_string(),
246                    workspace_member: false,
247                });
248
249        let reason = if package.package_root == entry_root {
250            DependencySyncReason::EntryPoint
251        } else if package.workspace_member {
252            DependencySyncReason::WorkspaceMember
253        } else {
254            DependencySyncReason::TransitivePathDependency
255        };
256
257        let inbound_names = inbound_dependency_names
258            .get(&package.package_root)
259            .map(|set| set.iter().cloned().collect::<Vec<_>>())
260            .unwrap_or_default();
261        let dependents = dependent_roots
262            .get(&package.package_root)
263            .map(|set| set.iter().cloned().collect::<Vec<_>>())
264            .unwrap_or_default();
265
266        let risk = classify_sync_risk(reason, dependents.len());
267        let metadata = DependencySyncMetadata {
268            reason,
269            workspace_member: package.workspace_member,
270            root_package: root_packages.contains(&package.package_root),
271            inbound_dependency_names: inbound_names,
272            dependent_roots: dependents.clone(),
273            notes: vec![format!("dependent_root_count={}", dependents.len())],
274        };
275
276        sync_order.push(DependencySyncAction {
277            order_index: 0,
278            package_root: package.package_root.clone(),
279            manifest_path: package.manifest_path,
280            package_name: package.package_name,
281            risk,
282            metadata,
283        });
284    }
285
286    if let Some(materialization) = materialization {
287        let active_roots = graph
288            .packages
289            .iter()
290            .map(|package| package.package_root.clone())
291            .collect::<BTreeSet<_>>();
292        let materialization_roots = materialization
293            .root_packages
294            .iter()
295            .cloned()
296            .collect::<BTreeSet<_>>();
297        let mut materialization_inbound: BTreeMap<PathBuf, BTreeSet<String>> = BTreeMap::new();
298        let mut materialization_dependents: BTreeMap<PathBuf, BTreeSet<PathBuf>> = BTreeMap::new();
299        for edge in &materialization.edges {
300            materialization_inbound
301                .entry(edge.to.clone())
302                .or_default()
303                .insert(edge.dependency_name.clone());
304            materialization_dependents
305                .entry(edge.to.clone())
306                .or_default()
307                .insert(edge.from.clone());
308        }
309
310        let mut materialization_only = Vec::new();
311        for package in &materialization.packages {
312            if active_roots.contains(&package.package_root) {
313                continue;
314            }
315
316            let reason = if package.workspace_member {
317                DependencySyncReason::WorkspaceMember
318            } else {
319                DependencySyncReason::TransitivePathDependency
320            };
321            let inbound_names = materialization_inbound
322                .get(&package.package_root)
323                .map(|set| set.iter().cloned().collect::<Vec<_>>())
324                .unwrap_or_default();
325            let dependents = materialization_dependents
326                .get(&package.package_root)
327                .map(|set| set.iter().cloned().collect::<Vec<_>>())
328                .unwrap_or_default();
329
330            materialization_only.push(DependencySyncAction {
331                order_index: 0,
332                package_root: package.package_root.clone(),
333                manifest_path: package.manifest_path.clone(),
334                package_name: package.package_name.clone(),
335                risk: classify_sync_risk(reason, dependents.len()),
336                metadata: DependencySyncMetadata {
337                    reason,
338                    workspace_member: package.workspace_member,
339                    root_package: materialization_roots.contains(&package.package_root),
340                    inbound_dependency_names: inbound_names,
341                    dependent_roots: dependents.clone(),
342                    notes: vec![
343                        format!("dependent_root_count={}", dependents.len()),
344                        "materialization_only=true".to_string(),
345                        "active_execution_edge=false".to_string(),
346                    ],
347                },
348            });
349        }
350
351        materialization_only.extend(sync_order);
352        sync_order = materialization_only;
353    }
354
355    for (order_index, action) in sync_order.iter_mut().enumerate() {
356        action.order_index = order_index;
357    }
358
359    let canonical_roots = sync_order
360        .iter()
361        .map(|action| action.package_root.clone())
362        .collect::<Vec<_>>();
363
364    DependencyClosurePlan {
365        state: DependencyClosurePlanState::Ready,
366        entry_manifest_path: graph.entry_manifest_path.clone(),
367        workspace_root: graph.workspace_root.clone(),
368        canonical_roots,
369        sync_order,
370        fail_open: false,
371        fail_open_reason: None,
372        issues: Vec::new(),
373    }
374}
375
376fn classify_sync_risk(
377    reason: DependencySyncReason,
378    dependent_root_count: usize,
379) -> DependencyRiskClass {
380    match reason {
381        DependencySyncReason::EntryPoint | DependencySyncReason::WorkspaceMember => {
382            DependencyRiskClass::Low
383        }
384        DependencySyncReason::TransitivePathDependency => {
385            if dependent_root_count > 1 {
386                DependencyRiskClass::High
387            } else {
388                DependencyRiskClass::Medium
389            }
390        }
391    }
392}
393
394fn dependency_first_topological_order(
395    packages: &[CargoPathDependencyPackage],
396    edges: &[CargoPathDependencyEdge],
397) -> Option<Vec<PathBuf>> {
398    let mut nodes = packages
399        .iter()
400        .map(|package| package.package_root.clone())
401        .collect::<BTreeSet<_>>();
402    for edge in edges {
403        nodes.insert(edge.from.clone());
404        nodes.insert(edge.to.clone());
405    }
406
407    let mut indegree = nodes
408        .iter()
409        .cloned()
410        .map(|node| (node, 0usize))
411        .collect::<BTreeMap<_, _>>();
412    let mut dependents_by_dependency: BTreeMap<PathBuf, BTreeSet<PathBuf>> = BTreeMap::new();
413
414    for edge in edges {
415        let from_indegree = indegree.get_mut(&edge.from)?;
416        *from_indegree += 1;
417        dependents_by_dependency
418            .entry(edge.to.clone())
419            .or_default()
420            .insert(edge.from.clone());
421    }
422
423    let mut ready = indegree
424        .iter()
425        .filter_map(|(node, degree)| {
426            if *degree == 0 {
427                Some(node.clone())
428            } else {
429                None
430            }
431        })
432        .collect::<BTreeSet<_>>();
433
434    let mut order = Vec::with_capacity(indegree.len());
435    while let Some(node) = ready.pop_first() {
436        order.push(node.clone());
437        if let Some(dependents) = dependents_by_dependency.get(&node) {
438            for dependent in dependents {
439                let degree = indegree.get_mut(dependent)?;
440                if *degree == 0 {
441                    return None;
442                }
443                *degree -= 1;
444                if *degree == 0 {
445                    ready.insert(dependent.clone());
446                }
447            }
448        }
449    }
450
451    if order.len() == indegree.len() {
452        Some(order)
453    } else {
454        None
455    }
456}
457
458fn fail_open_plan_from_resolver_error(
459    entrypoint: &Path,
460    error: &CargoPathDependencyError,
461) -> DependencyClosurePlan {
462    let issue = issue_from_resolver_error(error);
463    let entry_manifest_path = error
464        .manifest_path()
465        .map(Path::to_path_buf)
466        .unwrap_or_else(|| entrypoint.to_path_buf());
467
468    DependencyClosurePlan {
469        state: DependencyClosurePlanState::FailOpen,
470        entry_manifest_path,
471        workspace_root: None,
472        canonical_roots: Vec::new(),
473        sync_order: Vec::new(),
474        fail_open: true,
475        fail_open_reason: Some(format!(
476            "resolver produced {}: {}",
477            error.kind(),
478            error.detail()
479        )),
480        issues: vec![issue],
481    }
482}
483
484fn issue_from_resolver_error(error: &CargoPathDependencyError) -> DependencyPlanIssue {
485    let (code, risk) = match error.kind() {
486        CargoPathDependencyErrorKind::ManifestParseFailure => {
487            ("manifest-parse-failure", DependencyRiskClass::Critical)
488        }
489        CargoPathDependencyErrorKind::MetadataParseFailure => {
490            ("metadata-parse-failure", DependencyRiskClass::Critical)
491        }
492        CargoPathDependencyErrorKind::MetadataInvocationFailure => {
493            ("metadata-invocation-failure", DependencyRiskClass::Critical)
494        }
495        CargoPathDependencyErrorKind::CyclicDependency => {
496            ("cyclic-path-dependency", DependencyRiskClass::Critical)
497        }
498        CargoPathDependencyErrorKind::PathPolicyViolation => {
499            ("path-policy-violation", DependencyRiskClass::High)
500        }
501        CargoPathDependencyErrorKind::MissingPathDependency => {
502            ("missing-path-dependency", DependencyRiskClass::High)
503        }
504    };
505
506    let mut diagnostics = error.diagnostics().to_vec();
507    if let Some(dependency_name) = error.dependency_name() {
508        diagnostics.push(format!("dependency_name={dependency_name}"));
509    }
510    if let Some(dependency_path) = error.dependency_path() {
511        diagnostics.push(format!("dependency_path={}", dependency_path.display()));
512    }
513    if !error.cycle().is_empty() {
514        diagnostics.push(format!("cycle={:?}", error.cycle()));
515    }
516
517    DependencyPlanIssue {
518        code: code.to_string(),
519        message: format!("{}: {}", error.kind(), error.detail()),
520        risk,
521        diagnostics,
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    fn package(root: &str, name: &str, workspace_member: bool) -> CargoPathDependencyPackage {
530        CargoPathDependencyPackage {
531            package_root: PathBuf::from(root),
532            manifest_path: PathBuf::from(root).join("Cargo.toml"),
533            package_name: name.to_string(),
534            workspace_member,
535        }
536    }
537
538    fn edge(from: &str, to: &str, dependency_name: &str) -> CargoPathDependencyEdge {
539        CargoPathDependencyEdge {
540            from: PathBuf::from(from),
541            to: PathBuf::from(to),
542            dependency_name: dependency_name.to_string(),
543        }
544    }
545
546    #[test]
547    fn planner_produces_dependency_first_deterministic_sync_order() {
548        let graph = CargoPathDependencyGraph {
549            entry_manifest_path: PathBuf::from("/data/projects/app/Cargo.toml"),
550            workspace_root: Some(PathBuf::from("/data/projects")),
551            root_packages: vec![PathBuf::from("/data/projects/app")],
552            packages: vec![
553                package("/data/projects/app", "app", true),
554                package("/data/projects/lib_a", "lib_a", false),
555                package("/data/projects/lib_b", "lib_b", false),
556            ],
557            edges: vec![
558                edge("/data/projects/app", "/data/projects/lib_a", "lib_a"),
559                edge("/data/projects/lib_a", "/data/projects/lib_b", "lib_b"),
560            ],
561        };
562
563        let plan = plan_dependency_closure_from_graph(&graph);
564        assert!(plan.is_ready(), "acyclic graph should be planner-ready");
565        assert_eq!(plan.sync_order.len(), 3);
566
567        let ordered_roots = plan
568            .sync_order
569            .iter()
570            .map(|action| action.package_root.as_path())
571            .collect::<Vec<_>>();
572        assert_eq!(
573            ordered_roots,
574            vec![
575                Path::new("/data/projects/lib_b"),
576                Path::new("/data/projects/lib_a"),
577                Path::new("/data/projects/app"),
578            ],
579            "planner must sync dependencies before dependents"
580        );
581        assert_eq!(
582            plan.sync_order[0].metadata.reason,
583            DependencySyncReason::TransitivePathDependency
584        );
585        assert_eq!(
586            plan.sync_order[2].metadata.reason,
587            DependencySyncReason::EntryPoint
588        );
589    }
590
591    #[test]
592    fn planner_cycle_fails_open_with_stable_issue_code() {
593        let graph = CargoPathDependencyGraph {
594            entry_manifest_path: PathBuf::from("/data/projects/cycle_a/Cargo.toml"),
595            workspace_root: None,
596            root_packages: vec![PathBuf::from("/data/projects/cycle_a")],
597            packages: vec![
598                package("/data/projects/cycle_a", "cycle_a", false),
599                package("/data/projects/cycle_b", "cycle_b", false),
600            ],
601            edges: vec![
602                edge(
603                    "/data/projects/cycle_a",
604                    "/data/projects/cycle_b",
605                    "cycle_b",
606                ),
607                edge(
608                    "/data/projects/cycle_b",
609                    "/data/projects/cycle_a",
610                    "cycle_a",
611                ),
612            ],
613        };
614
615        let plan = plan_dependency_closure_from_graph(&graph);
616        assert_eq!(plan.state, DependencyClosurePlanState::FailOpen);
617        assert!(plan.fail_open);
618        assert_eq!(plan.sync_order.len(), 0);
619        assert_eq!(plan.issues.len(), 1);
620        assert_eq!(plan.issues[0].code, "planner_non_deterministic_order");
621        assert_eq!(plan.issues[0].risk, DependencyRiskClass::Critical);
622    }
623
624    #[test]
625    fn planner_unions_cycle_tolerant_materialization_without_changing_active_dag() {
626        let graph = CargoPathDependencyGraph {
627            entry_manifest_path: PathBuf::from("/data/projects/app/Cargo.toml"),
628            workspace_root: None,
629            root_packages: vec![PathBuf::from("/data/projects/app")],
630            packages: vec![
631                package("/data/projects/app", "app", false),
632                package("/data/projects/real_dep", "real_dep", false),
633            ],
634            edges: vec![edge(
635                "/data/projects/app",
636                "/data/projects/real_dep",
637                "real_dep",
638            )],
639        };
640        let materialization = CargoPathMaterializationClosure {
641            entry_manifest_path: graph.entry_manifest_path.clone(),
642            workspace_root: None,
643            root_packages: graph.root_packages.clone(),
644            packages: vec![
645                package("/data/projects/app", "app", false),
646                package("/data/projects/optional_a", "optional_a", false),
647                package("/data/projects/optional_b", "optional_b", false),
648                package("/data/projects/real_dep", "real_dep", false),
649            ],
650            edges: vec![
651                edge(
652                    "/data/projects/app",
653                    "/data/projects/optional_a",
654                    "optional_a",
655                ),
656                edge("/data/projects/app", "/data/projects/real_dep", "real_dep"),
657                edge(
658                    "/data/projects/optional_a",
659                    "/data/projects/optional_b",
660                    "optional_b",
661                ),
662                edge(
663                    "/data/projects/optional_b",
664                    "/data/projects/optional_a",
665                    "optional_a",
666                ),
667            ],
668        };
669
670        let plan =
671            plan_dependency_closure_from_graph_and_materialization(&graph, Some(&materialization));
672
673        assert!(
674            plan.is_ready(),
675            "inactive optional cycles are materialization evidence, not active DAG edges"
676        );
677        assert_eq!(
678            plan.sync_roots().into_iter().collect::<BTreeSet<_>>(),
679            BTreeSet::from([
680                PathBuf::from("/data/projects/app"),
681                PathBuf::from("/data/projects/optional_a"),
682                PathBuf::from("/data/projects/optional_b"),
683                PathBuf::from("/data/projects/real_dep"),
684            ])
685        );
686        let real_dep_position = plan
687            .sync_order
688            .iter()
689            .position(|action| action.package_name == "real_dep")
690            .expect("active dependency action");
691        let app_position = plan
692            .sync_order
693            .iter()
694            .position(|action| action.package_name == "app")
695            .expect("active entry action");
696        assert!(
697            real_dep_position < app_position,
698            "active dependency-first order must remain intact"
699        );
700        for package_name in ["optional_a", "optional_b"] {
701            let action = plan
702                .sync_order
703                .iter()
704                .find(|action| action.package_name == package_name)
705                .expect("materialization-only action");
706            assert!(
707                action
708                    .metadata
709                    .notes
710                    .iter()
711                    .any(|note| note == "materialization_only=true")
712            );
713        }
714    }
715
716    #[test]
717    fn resolver_error_mapping_reports_path_policy_violation_code() {
718        let error = CargoPathDependencyError::new(
719            CargoPathDependencyErrorKind::PathPolicyViolation,
720            "dependency escaped canonical root",
721        )
722        .with_manifest_path("/data/projects/app/Cargo.toml")
723        .with_dependency_name("bad_dep")
724        .with_dependency_path("/tmp/outside");
725
726        let issue = issue_from_resolver_error(&error);
727        assert_eq!(issue.code, "path-policy-violation");
728        assert_eq!(issue.risk, DependencyRiskClass::High);
729        assert!(
730            issue
731                .diagnostics
732                .iter()
733                .any(|line| line.contains("dependency_path=/tmp/outside"))
734        );
735    }
736
737    #[test]
738    fn resolver_error_mapping_reports_manifest_parse_failure_code() {
739        let error = CargoPathDependencyError::new(
740            CargoPathDependencyErrorKind::ManifestParseFailure,
741            "invalid Cargo.toml syntax",
742        )
743        .with_manifest_path("/data/projects/app/Cargo.toml");
744
745        let plan = fail_open_plan_from_resolver_error(Path::new("/data/projects/app"), &error);
746        assert_eq!(plan.state, DependencyClosurePlanState::FailOpen);
747        assert_eq!(plan.issues.len(), 1);
748        assert_eq!(plan.issues[0].code, "manifest-parse-failure");
749        assert_eq!(plan.issues[0].risk, DependencyRiskClass::Critical);
750        assert!(
751            plan.fail_open_reason
752                .as_deref()
753                .is_some_and(|reason| reason.contains("manifest parse failure"))
754        );
755    }
756
757    // =======================================================================
758    // Empty and single-package closure tests
759    // =======================================================================
760
761    #[test]
762    fn empty_graph_produces_empty_ready_plan() {
763        let graph = CargoPathDependencyGraph {
764            entry_manifest_path: PathBuf::from("/data/projects/empty/Cargo.toml"),
765            workspace_root: None,
766            root_packages: Vec::new(),
767            packages: Vec::new(),
768            edges: Vec::new(),
769        };
770
771        let plan = plan_dependency_closure_from_graph(&graph);
772        assert!(plan.is_ready(), "empty graph should still be ready");
773        assert_eq!(plan.sync_order.len(), 0);
774        assert_eq!(plan.canonical_roots.len(), 0);
775        assert!(plan.issues.is_empty());
776        assert!(!plan.fail_open);
777    }
778
779    #[test]
780    fn single_package_no_deps_produces_single_entry_point_action() {
781        let graph = CargoPathDependencyGraph {
782            entry_manifest_path: PathBuf::from("/data/projects/solo/Cargo.toml"),
783            workspace_root: None,
784            root_packages: vec![PathBuf::from("/data/projects/solo")],
785            packages: vec![package("/data/projects/solo", "solo-crate", false)],
786            edges: Vec::new(),
787        };
788
789        let plan = plan_dependency_closure_from_graph(&graph);
790        assert!(plan.is_ready());
791        assert_eq!(plan.sync_order.len(), 1);
792        assert_eq!(plan.sync_order[0].order_index, 0);
793        assert_eq!(plan.sync_order[0].package_name, "solo-crate");
794        assert_eq!(
795            plan.sync_order[0].metadata.reason,
796            DependencySyncReason::EntryPoint
797        );
798        assert_eq!(plan.sync_order[0].risk, DependencyRiskClass::Low);
799        assert!(
800            plan.sync_order[0]
801                .metadata
802                .inbound_dependency_names
803                .is_empty()
804        );
805        assert!(plan.sync_order[0].metadata.dependent_roots.is_empty());
806    }
807
808    // =======================================================================
809    // Diamond dependency pattern (A→B, A→C, B→D, C→D)
810    // =======================================================================
811
812    #[test]
813    fn diamond_dependency_preserves_deterministic_order() {
814        let graph = CargoPathDependencyGraph {
815            entry_manifest_path: PathBuf::from("/data/projects/app/Cargo.toml"),
816            workspace_root: Some(PathBuf::from("/data/projects")),
817            root_packages: vec![PathBuf::from("/data/projects/app")],
818            packages: vec![
819                package("/data/projects/app", "app", true),
820                package("/data/projects/b", "lib_b", false),
821                package("/data/projects/c", "lib_c", false),
822                package("/data/projects/d", "lib_d", false),
823            ],
824            edges: vec![
825                edge("/data/projects/app", "/data/projects/b", "lib_b"),
826                edge("/data/projects/app", "/data/projects/c", "lib_c"),
827                edge("/data/projects/b", "/data/projects/d", "lib_d"),
828                edge("/data/projects/c", "/data/projects/d", "lib_d"),
829            ],
830        };
831
832        let plan = plan_dependency_closure_from_graph(&graph);
833        assert!(plan.is_ready(), "diamond graph should be planner-ready");
834        assert_eq!(plan.sync_order.len(), 4);
835
836        let ordered_roots: Vec<_> = plan
837            .sync_order
838            .iter()
839            .map(|a| a.package_root.as_path())
840            .collect();
841
842        // D must come before B and C; B and C must come before app.
843        let d_pos = ordered_roots
844            .iter()
845            .position(|r| *r == Path::new("/data/projects/d"))
846            .unwrap();
847        let b_pos = ordered_roots
848            .iter()
849            .position(|r| *r == Path::new("/data/projects/b"))
850            .unwrap();
851        let c_pos = ordered_roots
852            .iter()
853            .position(|r| *r == Path::new("/data/projects/c"))
854            .unwrap();
855        let app_pos = ordered_roots
856            .iter()
857            .position(|r| *r == Path::new("/data/projects/app"))
858            .unwrap();
859        assert!(d_pos < b_pos, "D must sync before B");
860        assert!(d_pos < c_pos, "D must sync before C");
861        assert!(b_pos < app_pos, "B must sync before app");
862        assert!(c_pos < app_pos, "C must sync before app");
863
864        // D has 2 dependent roots (B and C), so it should be High risk.
865        let d_action = &plan.sync_order[d_pos];
866        assert_eq!(d_action.risk, DependencyRiskClass::High);
867        assert_eq!(d_action.metadata.dependent_roots.len(), 2);
868
869        // B and C each have 1 dependent root (app), so Medium risk.
870        let b_action = &plan.sync_order[b_pos];
871        assert_eq!(b_action.risk, DependencyRiskClass::Medium);
872
873        // app is entry point, so Low risk.
874        let app_action = &plan.sync_order[app_pos];
875        assert_eq!(app_action.risk, DependencyRiskClass::Low);
876    }
877
878    // =======================================================================
879    // Risk classification boundary tests
880    // =======================================================================
881
882    #[test]
883    fn classify_sync_risk_entry_point_is_always_low() {
884        assert_eq!(
885            classify_sync_risk(DependencySyncReason::EntryPoint, 0),
886            DependencyRiskClass::Low
887        );
888        assert_eq!(
889            classify_sync_risk(DependencySyncReason::EntryPoint, 10),
890            DependencyRiskClass::Low
891        );
892    }
893
894    #[test]
895    fn classify_sync_risk_workspace_member_is_always_low() {
896        assert_eq!(
897            classify_sync_risk(DependencySyncReason::WorkspaceMember, 0),
898            DependencyRiskClass::Low
899        );
900        assert_eq!(
901            classify_sync_risk(DependencySyncReason::WorkspaceMember, 5),
902            DependencyRiskClass::Low
903        );
904    }
905
906    #[test]
907    fn classify_sync_risk_transitive_with_zero_dependents_is_medium() {
908        assert_eq!(
909            classify_sync_risk(DependencySyncReason::TransitivePathDependency, 0),
910            DependencyRiskClass::Medium
911        );
912    }
913
914    #[test]
915    fn classify_sync_risk_transitive_with_one_dependent_is_medium() {
916        assert_eq!(
917            classify_sync_risk(DependencySyncReason::TransitivePathDependency, 1),
918            DependencyRiskClass::Medium
919        );
920    }
921
922    #[test]
923    fn classify_sync_risk_transitive_with_two_dependents_is_high() {
924        assert_eq!(
925            classify_sync_risk(DependencySyncReason::TransitivePathDependency, 2),
926            DependencyRiskClass::High
927        );
928    }
929
930    #[test]
931    fn classify_sync_risk_transitive_with_many_dependents_is_high() {
932        assert_eq!(
933            classify_sync_risk(DependencySyncReason::TransitivePathDependency, 100),
934            DependencyRiskClass::High
935        );
936    }
937
938    // =======================================================================
939    // Wide fan-out graph test
940    // =======================================================================
941
942    #[test]
943    fn wide_fanout_graph_syncs_all_leaves_before_root() {
944        let leaf_count = 20;
945        let mut packages = vec![package("/data/projects/hub", "hub", true)];
946        let mut edges = Vec::new();
947        for i in 0..leaf_count {
948            let root = format!("/data/projects/leaf_{i}");
949            let name = format!("leaf_{i}");
950            packages.push(package(&root, &name, false));
951            edges.push(edge("/data/projects/hub", &root, &name));
952        }
953
954        let graph = CargoPathDependencyGraph {
955            entry_manifest_path: PathBuf::from("/data/projects/hub/Cargo.toml"),
956            workspace_root: Some(PathBuf::from("/data/projects")),
957            root_packages: vec![PathBuf::from("/data/projects/hub")],
958            packages,
959            edges,
960        };
961
962        let plan = plan_dependency_closure_from_graph(&graph);
963        assert!(plan.is_ready());
964        assert_eq!(plan.sync_order.len(), leaf_count + 1);
965
966        // Hub must be last.
967        let hub_action = plan.sync_order.last().unwrap();
968        assert_eq!(hub_action.package_root, PathBuf::from("/data/projects/hub"));
969        assert_eq!(hub_action.metadata.reason, DependencySyncReason::EntryPoint);
970
971        // All leaves must come before hub.
972        for action in &plan.sync_order[..leaf_count] {
973            assert_eq!(
974                action.metadata.reason,
975                DependencySyncReason::TransitivePathDependency
976            );
977            assert_eq!(action.metadata.dependent_roots.len(), 1);
978            assert_eq!(action.risk, DependencyRiskClass::Medium);
979        }
980    }
981
982    // =======================================================================
983    // Deep chain graph test
984    // =======================================================================
985
986    #[test]
987    fn deep_chain_graph_preserves_order() {
988        let depth = 10;
989        let mut packages = Vec::new();
990        let mut edges = Vec::new();
991        for i in 0..depth {
992            let root = format!("/data/projects/chain_{i}");
993            let name = format!("chain_{i}");
994            packages.push(package(&root, &name, i == depth - 1));
995            if i > 0 {
996                let parent = format!("/data/projects/chain_{}", i);
997                let child = format!("/data/projects/chain_{}", i - 1);
998                edges.push(edge(&parent, &child, &format!("chain_{}", i - 1)));
999            }
1000        }
1001
1002        let graph = CargoPathDependencyGraph {
1003            entry_manifest_path: PathBuf::from(format!(
1004                "/data/projects/chain_{}/Cargo.toml",
1005                depth - 1
1006            )),
1007            workspace_root: Some(PathBuf::from("/data/projects")),
1008            root_packages: vec![PathBuf::from(format!("/data/projects/chain_{}", depth - 1))],
1009            packages,
1010            edges,
1011        };
1012
1013        let plan = plan_dependency_closure_from_graph(&graph);
1014        assert!(plan.is_ready());
1015        assert_eq!(plan.sync_order.len(), depth);
1016
1017        // chain_0 should be first (leaf), chain_{depth-1} should be last (entry point).
1018        assert_eq!(
1019            plan.sync_order[0].package_root,
1020            PathBuf::from("/data/projects/chain_0")
1021        );
1022        assert_eq!(
1023            plan.sync_order.last().unwrap().package_root,
1024            PathBuf::from(format!("/data/projects/chain_{}", depth - 1))
1025        );
1026    }
1027
1028    // =======================================================================
1029    // All error kind mappings
1030    // =======================================================================
1031
1032    #[test]
1033    fn resolver_error_mapping_metadata_parse_failure() {
1034        let error = CargoPathDependencyError::new(
1035            CargoPathDependencyErrorKind::MetadataParseFailure,
1036            "cannot parse metadata JSON",
1037        );
1038        let issue = issue_from_resolver_error(&error);
1039        assert_eq!(issue.code, "metadata-parse-failure");
1040        assert_eq!(issue.risk, DependencyRiskClass::Critical);
1041    }
1042
1043    #[test]
1044    fn resolver_error_mapping_metadata_invocation_failure() {
1045        let error = CargoPathDependencyError::new(
1046            CargoPathDependencyErrorKind::MetadataInvocationFailure,
1047            "cargo metadata timed out after 30s",
1048        );
1049        let issue = issue_from_resolver_error(&error);
1050        assert_eq!(issue.code, "metadata-invocation-failure");
1051        assert_eq!(issue.risk, DependencyRiskClass::Critical);
1052    }
1053
1054    #[test]
1055    fn resolver_error_mapping_missing_path_dependency() {
1056        let error = CargoPathDependencyError::new(
1057            CargoPathDependencyErrorKind::MissingPathDependency,
1058            "path dep does not exist on disk",
1059        )
1060        .with_dependency_name("phantom_dep")
1061        .with_dependency_path("/data/projects/nonexistent");
1062
1063        let issue = issue_from_resolver_error(&error);
1064        assert_eq!(issue.code, "missing-path-dependency");
1065        assert_eq!(issue.risk, DependencyRiskClass::High);
1066        assert!(
1067            issue
1068                .diagnostics
1069                .iter()
1070                .any(|d| d.contains("dependency_name=phantom_dep")),
1071            "diagnostics should include dependency name"
1072        );
1073        assert!(
1074            issue
1075                .diagnostics
1076                .iter()
1077                .any(|d| d.contains("dependency_path=/data/projects/nonexistent")),
1078            "diagnostics should include dependency path"
1079        );
1080    }
1081
1082    #[test]
1083    fn resolver_error_mapping_cyclic_dependency() {
1084        let error = CargoPathDependencyError::new(
1085            CargoPathDependencyErrorKind::CyclicDependency,
1086            "circular path dependency detected",
1087        );
1088
1089        let issue = issue_from_resolver_error(&error);
1090        assert_eq!(issue.code, "cyclic-path-dependency");
1091        assert_eq!(issue.risk, DependencyRiskClass::Critical);
1092        assert!(
1093            issue.message.contains("cyclic path dependency"),
1094            "message should contain error kind, got: {}",
1095            issue.message
1096        );
1097    }
1098
1099    // =======================================================================
1100    // fail_open_plan_from_resolver_error tests
1101    // =======================================================================
1102
1103    #[test]
1104    fn fail_open_plan_preserves_manifest_path_from_error() {
1105        let error = CargoPathDependencyError::new(
1106            CargoPathDependencyErrorKind::MetadataInvocationFailure,
1107            "timeout",
1108        )
1109        .with_manifest_path("/data/projects/app/Cargo.toml");
1110
1111        let plan =
1112            fail_open_plan_from_resolver_error(Path::new("/data/projects/app/Cargo.toml"), &error);
1113        assert_eq!(
1114            plan.entry_manifest_path,
1115            PathBuf::from("/data/projects/app/Cargo.toml")
1116        );
1117        assert_eq!(plan.state, DependencyClosurePlanState::FailOpen);
1118        assert!(plan.fail_open);
1119        assert!(plan.sync_order.is_empty());
1120        assert!(plan.canonical_roots.is_empty());
1121        assert!(plan.workspace_root.is_none());
1122    }
1123
1124    #[test]
1125    fn fail_open_plan_uses_entrypoint_when_error_has_no_manifest_path() {
1126        let error = CargoPathDependencyError::new(
1127            CargoPathDependencyErrorKind::MissingPathDependency,
1128            "dep not found",
1129        );
1130
1131        let plan = fail_open_plan_from_resolver_error(Path::new("/data/projects/fallback"), &error);
1132        assert_eq!(
1133            plan.entry_manifest_path,
1134            PathBuf::from("/data/projects/fallback")
1135        );
1136    }
1137
1138    // =======================================================================
1139    // DependencyClosurePlan API tests
1140    // =======================================================================
1141
1142    #[test]
1143    fn is_ready_returns_false_for_fail_open() {
1144        let plan = DependencyClosurePlan {
1145            state: DependencyClosurePlanState::FailOpen,
1146            entry_manifest_path: PathBuf::from("/tmp/Cargo.toml"),
1147            workspace_root: None,
1148            canonical_roots: Vec::new(),
1149            sync_order: Vec::new(),
1150            fail_open: true,
1151            fail_open_reason: Some("test".to_string()),
1152            issues: Vec::new(),
1153        };
1154        assert!(!plan.is_ready());
1155    }
1156
1157    #[test]
1158    fn sync_roots_returns_roots_in_sync_order() {
1159        let plan = DependencyClosurePlan {
1160            state: DependencyClosurePlanState::Ready,
1161            entry_manifest_path: PathBuf::from("/data/projects/app/Cargo.toml"),
1162            workspace_root: None,
1163            canonical_roots: vec![
1164                PathBuf::from("/data/projects/dep"),
1165                PathBuf::from("/data/projects/app"),
1166            ],
1167            sync_order: vec![
1168                DependencySyncAction {
1169                    order_index: 0,
1170                    package_root: PathBuf::from("/data/projects/dep"),
1171                    manifest_path: PathBuf::from("/data/projects/dep/Cargo.toml"),
1172                    package_name: "dep".to_string(),
1173                    risk: DependencyRiskClass::Medium,
1174                    metadata: DependencySyncMetadata {
1175                        reason: DependencySyncReason::TransitivePathDependency,
1176                        workspace_member: false,
1177                        root_package: false,
1178                        inbound_dependency_names: vec!["dep".to_string()],
1179                        dependent_roots: vec![PathBuf::from("/data/projects/app")],
1180                        notes: vec!["dependent_root_count=1".to_string()],
1181                    },
1182                },
1183                DependencySyncAction {
1184                    order_index: 1,
1185                    package_root: PathBuf::from("/data/projects/app"),
1186                    manifest_path: PathBuf::from("/data/projects/app/Cargo.toml"),
1187                    package_name: "app".to_string(),
1188                    risk: DependencyRiskClass::Low,
1189                    metadata: DependencySyncMetadata {
1190                        reason: DependencySyncReason::EntryPoint,
1191                        workspace_member: false,
1192                        root_package: true,
1193                        inbound_dependency_names: Vec::new(),
1194                        dependent_roots: Vec::new(),
1195                        notes: vec!["dependent_root_count=0".to_string()],
1196                    },
1197                },
1198            ],
1199            fail_open: false,
1200            fail_open_reason: None,
1201            issues: Vec::new(),
1202        };
1203
1204        let roots = plan.sync_roots();
1205        assert_eq!(
1206            roots,
1207            vec![
1208                PathBuf::from("/data/projects/dep"),
1209                PathBuf::from("/data/projects/app"),
1210            ]
1211        );
1212    }
1213
1214    // =======================================================================
1215    // Workspace member classification tests
1216    // =======================================================================
1217
1218    #[test]
1219    fn workspace_member_gets_workspace_member_reason() {
1220        let graph = CargoPathDependencyGraph {
1221            entry_manifest_path: PathBuf::from("/data/projects/app/Cargo.toml"),
1222            workspace_root: Some(PathBuf::from("/data/projects")),
1223            root_packages: vec![PathBuf::from("/data/projects/app")],
1224            packages: vec![
1225                package("/data/projects/app", "app", true),
1226                package("/data/projects/member", "member", true),
1227            ],
1228            edges: vec![edge(
1229                "/data/projects/app",
1230                "/data/projects/member",
1231                "member",
1232            )],
1233        };
1234
1235        let plan = plan_dependency_closure_from_graph(&graph);
1236        assert!(plan.is_ready());
1237
1238        let member_action = plan
1239            .sync_order
1240            .iter()
1241            .find(|a| a.package_name == "member")
1242            .unwrap();
1243        assert_eq!(
1244            member_action.metadata.reason,
1245            DependencySyncReason::WorkspaceMember
1246        );
1247        assert!(member_action.metadata.workspace_member);
1248        assert_eq!(member_action.risk, DependencyRiskClass::Low);
1249    }
1250
1251    // =======================================================================
1252    // Inbound dependency name deduplication
1253    // =======================================================================
1254
1255    #[test]
1256    fn inbound_dependency_names_are_deduplicated() {
1257        let graph = CargoPathDependencyGraph {
1258            entry_manifest_path: PathBuf::from("/data/projects/app/Cargo.toml"),
1259            workspace_root: None,
1260            root_packages: vec![PathBuf::from("/data/projects/app")],
1261            packages: vec![
1262                package("/data/projects/app", "app", false),
1263                package("/data/projects/dep", "dep", false),
1264                package("/data/projects/other", "other", false),
1265            ],
1266            edges: vec![
1267                edge("/data/projects/app", "/data/projects/dep", "dep"),
1268                edge("/data/projects/other", "/data/projects/dep", "dep"),
1269            ],
1270        };
1271
1272        let plan = plan_dependency_closure_from_graph(&graph);
1273        let dep_action = plan
1274            .sync_order
1275            .iter()
1276            .find(|a| a.package_name == "dep")
1277            .unwrap();
1278        // Same dependency name from two sources — should appear only once (BTreeSet dedup).
1279        assert_eq!(dep_action.metadata.inbound_dependency_names, vec!["dep"]);
1280    }
1281
1282    // =======================================================================
1283    // Topological order determinism
1284    // =======================================================================
1285
1286    #[test]
1287    fn topological_sort_is_deterministic_across_calls() {
1288        let graph = CargoPathDependencyGraph {
1289            entry_manifest_path: PathBuf::from("/data/projects/app/Cargo.toml"),
1290            workspace_root: None,
1291            root_packages: vec![PathBuf::from("/data/projects/app")],
1292            packages: vec![
1293                package("/data/projects/app", "app", false),
1294                package("/data/projects/a", "a", false),
1295                package("/data/projects/b", "b", false),
1296                package("/data/projects/c", "c", false),
1297            ],
1298            edges: vec![
1299                edge("/data/projects/app", "/data/projects/a", "a"),
1300                edge("/data/projects/app", "/data/projects/b", "b"),
1301                edge("/data/projects/app", "/data/projects/c", "c"),
1302            ],
1303        };
1304
1305        let plan1 = plan_dependency_closure_from_graph(&graph);
1306        let plan2 = plan_dependency_closure_from_graph(&graph);
1307
1308        let roots1: Vec<_> = plan1.sync_order.iter().map(|a| &a.package_root).collect();
1309        let roots2: Vec<_> = plan2.sync_order.iter().map(|a| &a.package_root).collect();
1310        assert_eq!(roots1, roots2, "topological order must be deterministic");
1311    }
1312
1313    // =======================================================================
1314    // Notes field validation
1315    // =======================================================================
1316
1317    #[test]
1318    fn notes_include_dependent_root_count() {
1319        let graph = CargoPathDependencyGraph {
1320            entry_manifest_path: PathBuf::from("/data/projects/app/Cargo.toml"),
1321            workspace_root: None,
1322            root_packages: vec![PathBuf::from("/data/projects/app")],
1323            packages: vec![
1324                package("/data/projects/app", "app", false),
1325                package("/data/projects/dep", "dep", false),
1326            ],
1327            edges: vec![edge("/data/projects/app", "/data/projects/dep", "dep")],
1328        };
1329
1330        let plan = plan_dependency_closure_from_graph(&graph);
1331        let dep_action = plan
1332            .sync_order
1333            .iter()
1334            .find(|a| a.package_name == "dep")
1335            .unwrap();
1336        assert!(
1337            dep_action
1338                .metadata
1339                .notes
1340                .iter()
1341                .any(|n| n == "dependent_root_count=1"),
1342            "notes should include dependent_root_count"
1343        );
1344
1345        let app_action = plan
1346            .sync_order
1347            .iter()
1348            .find(|a| a.package_name == "app")
1349            .unwrap();
1350        assert!(
1351            app_action
1352                .metadata
1353                .notes
1354                .iter()
1355                .any(|n| n == "dependent_root_count=0"),
1356            "entry point should have 0 dependent roots"
1357        );
1358    }
1359
1360    // =======================================================================
1361    // Serialization round-trip
1362    // =======================================================================
1363
1364    #[test]
1365    fn plan_serialization_round_trip() {
1366        let graph = CargoPathDependencyGraph {
1367            entry_manifest_path: PathBuf::from("/data/projects/app/Cargo.toml"),
1368            workspace_root: Some(PathBuf::from("/data/projects")),
1369            root_packages: vec![PathBuf::from("/data/projects/app")],
1370            packages: vec![
1371                package("/data/projects/app", "app", true),
1372                package("/data/projects/dep", "dep", false),
1373            ],
1374            edges: vec![edge("/data/projects/app", "/data/projects/dep", "dep")],
1375        };
1376
1377        let plan = plan_dependency_closure_from_graph(&graph);
1378        let json = serde_json::to_string(&plan).expect("plan should serialize");
1379        let deserialized: DependencyClosurePlan =
1380            serde_json::from_str(&json).expect("plan should deserialize");
1381        assert_eq!(plan, deserialized);
1382    }
1383
1384    #[test]
1385    fn fail_open_plan_serialization_round_trip() {
1386        let error = CargoPathDependencyError::new(
1387            CargoPathDependencyErrorKind::CyclicDependency,
1388            "cycle detected",
1389        );
1390
1391        let plan = fail_open_plan_from_resolver_error(Path::new("/data/projects/app"), &error);
1392        let json = serde_json::to_string(&plan).expect("fail-open plan should serialize");
1393        let deserialized: DependencyClosurePlan =
1394            serde_json::from_str(&json).expect("fail-open plan should deserialize");
1395        assert_eq!(plan, deserialized);
1396    }
1397
1398    // =======================================================================
1399    // root_package flag validation
1400    // =======================================================================
1401
1402    #[test]
1403    fn root_package_flag_set_correctly() {
1404        let graph = CargoPathDependencyGraph {
1405            entry_manifest_path: PathBuf::from("/data/projects/app/Cargo.toml"),
1406            workspace_root: None,
1407            root_packages: vec![PathBuf::from("/data/projects/app")],
1408            packages: vec![
1409                package("/data/projects/app", "app", false),
1410                package("/data/projects/dep", "dep", false),
1411            ],
1412            edges: vec![edge("/data/projects/app", "/data/projects/dep", "dep")],
1413        };
1414
1415        let plan = plan_dependency_closure_from_graph(&graph);
1416        let app_action = plan
1417            .sync_order
1418            .iter()
1419            .find(|a| a.package_name == "app")
1420            .unwrap();
1421        assert!(
1422            app_action.metadata.root_package,
1423            "app should be marked as root_package"
1424        );
1425
1426        let dep_action = plan
1427            .sync_order
1428            .iter()
1429            .find(|a| a.package_name == "dep")
1430            .unwrap();
1431        assert!(
1432            !dep_action.metadata.root_package,
1433            "dep should not be marked as root_package"
1434        );
1435    }
1436
1437    // =======================================================================
1438    // Order indices are sequential
1439    // =======================================================================
1440
1441    #[test]
1442    fn order_indices_are_sequential_from_zero() {
1443        let graph = CargoPathDependencyGraph {
1444            entry_manifest_path: PathBuf::from("/data/projects/app/Cargo.toml"),
1445            workspace_root: None,
1446            root_packages: vec![PathBuf::from("/data/projects/app")],
1447            packages: vec![
1448                package("/data/projects/app", "app", false),
1449                package("/data/projects/a", "a", false),
1450                package("/data/projects/b", "b", false),
1451            ],
1452            edges: vec![
1453                edge("/data/projects/app", "/data/projects/a", "a"),
1454                edge("/data/projects/a", "/data/projects/b", "b"),
1455            ],
1456        };
1457
1458        let plan = plan_dependency_closure_from_graph(&graph);
1459        for (i, action) in plan.sync_order.iter().enumerate() {
1460            assert_eq!(action.order_index, i, "order_index should be sequential");
1461        }
1462    }
1463}