Skip to main content

rch_common/
cargo_path_deps.rs

1//! Cargo local path-dependency graph resolver.
2//!
3//! The resolver builds a deterministic graph of local `path` dependencies using
4//! a two-phase strategy:
5//! 1. `cargo metadata` (primary source of truth when available)
6//! 2. Recursive manifest parsing fallback (for malformed metadata and metadata failures)
7//!
8//! Every discovered path is normalized through [`PathTopologyPolicy`] to enforce
9//! canonical-root safety and stable path identity.
10
11use serde::{Deserialize, Serialize};
12use std::collections::{BTreeMap, BTreeSet};
13use std::fmt;
14use std::path::{Path, PathBuf};
15use std::process::Command;
16
17use crate::{PathNormalizationErrorKind, PathTopologyPolicy, normalize_project_path_with_policy};
18
19/// Deterministic graph of local Cargo path dependencies.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct CargoPathDependencyGraph {
22    /// Canonical path to the entry manifest used for resolution.
23    pub entry_manifest_path: PathBuf,
24    /// Canonical workspace root when entrypoint is a workspace manifest.
25    pub workspace_root: Option<PathBuf>,
26    /// Canonical root packages used as traversal roots (sorted).
27    pub root_packages: Vec<PathBuf>,
28    /// Reachable local packages in deterministic order.
29    pub packages: Vec<CargoPathDependencyPackage>,
30    /// Reachable path-dependency edges in deterministic order.
31    pub edges: Vec<CargoPathDependencyEdge>,
32}
33
34/// One package node in the resolved path-dependency graph.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct CargoPathDependencyPackage {
37    /// Canonical package root directory.
38    pub package_root: PathBuf,
39    /// Canonical package manifest path.
40    pub manifest_path: PathBuf,
41    /// Package name (best effort, deterministic fallback if missing).
42    pub package_name: String,
43    /// Whether this package is a workspace member/root.
44    pub workspace_member: bool,
45}
46
47/// One directed path-dependency edge.
48#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
49pub struct CargoPathDependencyEdge {
50    /// Canonical source package root.
51    pub from: PathBuf,
52    /// Canonical dependency package root.
53    pub to: PathBuf,
54    /// Dependency key from manifest (`[dependencies] <name> = { path = ... }`).
55    pub dependency_name: String,
56}
57
58/// Taxonomy for local path-dependency resolution failures.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum CargoPathDependencyErrorKind {
61    ManifestParseFailure,
62    MissingPathDependency,
63    CyclicDependency,
64    PathPolicyViolation,
65    MetadataParseFailure,
66    MetadataInvocationFailure,
67}
68
69impl fmt::Display for CargoPathDependencyErrorKind {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        match self {
72            Self::ManifestParseFailure => write!(f, "manifest parse failure"),
73            Self::MissingPathDependency => write!(f, "missing path dependency"),
74            Self::CyclicDependency => write!(f, "cyclic path dependency"),
75            Self::PathPolicyViolation => write!(f, "path policy violation"),
76            Self::MetadataParseFailure => write!(f, "metadata parse failure"),
77            Self::MetadataInvocationFailure => write!(f, "metadata invocation failure"),
78        }
79    }
80}
81
82/// Structured error with explicit diagnostics.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct CargoPathDependencyError {
85    kind: CargoPathDependencyErrorKind,
86    detail: String,
87    manifest_path: Option<Box<PathBuf>>,
88    dependency_name: Option<Box<str>>,
89    dependency_path: Option<Box<PathBuf>>,
90    cycle: Vec<PathBuf>,
91    diagnostics: Vec<String>,
92}
93
94impl CargoPathDependencyError {
95    pub(crate) fn new(kind: CargoPathDependencyErrorKind, detail: impl Into<String>) -> Self {
96        Self {
97            kind,
98            detail: detail.into(),
99            manifest_path: None,
100            dependency_name: None,
101            dependency_path: None,
102            cycle: Vec::new(),
103            diagnostics: Vec::new(),
104        }
105    }
106
107    pub(crate) fn with_manifest_path(mut self, manifest_path: impl Into<PathBuf>) -> Self {
108        self.manifest_path = Some(Box::new(manifest_path.into()));
109        self
110    }
111
112    pub(crate) fn with_dependency_name(mut self, dependency_name: impl Into<String>) -> Self {
113        self.dependency_name = Some(dependency_name.into().into_boxed_str());
114        self
115    }
116
117    pub(crate) fn with_dependency_path(mut self, dependency_path: impl Into<PathBuf>) -> Self {
118        self.dependency_path = Some(Box::new(dependency_path.into()));
119        self
120    }
121
122    fn with_cycle(mut self, cycle: Vec<PathBuf>) -> Self {
123        self.cycle = cycle;
124        self
125    }
126
127    fn with_diagnostic(mut self, diagnostic: impl Into<String>) -> Self {
128        self.diagnostics.push(diagnostic.into());
129        self
130    }
131
132    fn with_diagnostics<I>(mut self, diagnostics: I) -> Self
133    where
134        I: IntoIterator,
135        I::Item: Into<String>,
136    {
137        self.diagnostics
138            .extend(diagnostics.into_iter().map(Into::into));
139        self
140    }
141
142    fn push_diagnostic(&mut self, diagnostic: impl Into<String>) {
143        self.diagnostics.push(diagnostic.into());
144    }
145
146    /// Error category.
147    pub fn kind(&self) -> &CargoPathDependencyErrorKind {
148        &self.kind
149    }
150
151    /// Human-readable detail.
152    pub fn detail(&self) -> &str {
153        &self.detail
154    }
155
156    /// Manifest path associated with the error when available.
157    pub fn manifest_path(&self) -> Option<&Path> {
158        self.manifest_path.as_deref().map(PathBuf::as_path)
159    }
160
161    /// Dependency key associated with the error when available.
162    pub fn dependency_name(&self) -> Option<&str> {
163        self.dependency_name.as_deref()
164    }
165
166    /// Dependency path associated with the error when available.
167    pub fn dependency_path(&self) -> Option<&Path> {
168        self.dependency_path.as_deref().map(PathBuf::as_path)
169    }
170
171    /// Cycle path for cyclic failures (ordered, includes repeated terminal node).
172    pub fn cycle(&self) -> &[PathBuf] {
173        &self.cycle
174    }
175
176    /// Structured diagnostic lines.
177    pub fn diagnostics(&self) -> &[String] {
178        &self.diagnostics
179    }
180}
181
182impl fmt::Display for CargoPathDependencyError {
183    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184        write!(f, "{}: {}", self.kind, self.detail)?;
185        if let Some(manifest_path) = &self.manifest_path {
186            write!(f, " (manifest: {})", manifest_path.display())?;
187        }
188        if let Some(dependency_name) = &self.dependency_name {
189            write!(f, " (dependency: {dependency_name})")?;
190        }
191        if let Some(dependency_path) = &self.dependency_path {
192            write!(f, " (path: {})", dependency_path.display())?;
193        }
194        Ok(())
195    }
196}
197
198impl std::error::Error for CargoPathDependencyError {}
199
200/// Resolve local Cargo path dependencies for `entrypoint` using the default topology policy.
201pub fn resolve_cargo_path_dependency_graph(
202    entrypoint: &Path,
203) -> Result<CargoPathDependencyGraph, CargoPathDependencyError> {
204    resolve_cargo_path_dependency_graph_with_policy(entrypoint, &PathTopologyPolicy::default())
205}
206
207/// Resolve local Cargo path dependencies for `entrypoint` with explicit topology policy.
208pub fn resolve_cargo_path_dependency_graph_with_policy(
209    entrypoint: &Path,
210    policy: &PathTopologyPolicy,
211) -> Result<CargoPathDependencyGraph, CargoPathDependencyError> {
212    resolve_cargo_path_dependency_graph_with_policy_and_provider(
213        entrypoint,
214        policy,
215        invoke_cargo_metadata,
216    )
217}
218
219fn resolve_cargo_path_dependency_graph_with_policy_and_provider<F>(
220    entrypoint: &Path,
221    policy: &PathTopologyPolicy,
222    metadata_provider: F,
223) -> Result<CargoPathDependencyGraph, CargoPathDependencyError>
224where
225    F: Fn(&Path) -> Result<String, CargoPathDependencyError>,
226{
227    let entry_manifest = resolve_entry_manifest(entrypoint, policy)?;
228
229    match resolve_from_metadata(&entry_manifest, policy, &metadata_provider) {
230        Ok(graph) => Ok(graph),
231        Err(metadata_error) => match resolve_from_manifest_fallback(&entry_manifest, policy) {
232            Ok(graph) => Ok(graph),
233            Err(mut error) => {
234                error.push_diagnostic(format!("metadata phase failure: {metadata_error}"));
235                if !metadata_error.diagnostics().is_empty() {
236                    error.push_diagnostic("metadata diagnostics follow".to_string());
237                    error
238                        .diagnostics
239                        .extend(metadata_error.diagnostics().iter().cloned());
240                }
241                Err(error)
242            }
243        },
244    }
245}
246
247fn resolve_entry_manifest(
248    entrypoint: &Path,
249    policy: &PathTopologyPolicy,
250) -> Result<PathBuf, CargoPathDependencyError> {
251    let maybe_manifest = entrypoint
252        .file_name()
253        .is_some_and(|name| name == "Cargo.toml");
254    let root_candidate = if maybe_manifest {
255        entrypoint.parent().ok_or_else(|| {
256            CargoPathDependencyError::new(
257                CargoPathDependencyErrorKind::ManifestParseFailure,
258                format!("invalid manifest path: {}", entrypoint.display()),
259            )
260        })?
261    } else {
262        entrypoint
263    };
264
265    let normalized_root = normalize_path_for_policy(
266        root_candidate,
267        policy,
268        None,
269        None,
270        "resolve entrypoint root",
271    )?;
272    let manifest_path = normalized_root.join("Cargo.toml");
273    if !manifest_path.is_file() {
274        return Err(CargoPathDependencyError::new(
275            CargoPathDependencyErrorKind::ManifestParseFailure,
276            format!("manifest does not exist: {}", manifest_path.display()),
277        )
278        .with_manifest_path(manifest_path));
279    }
280
281    Ok(manifest_path)
282}
283
284/// Maximum time to wait for `cargo metadata` before killing the process.
285const CARGO_METADATA_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
286
287fn invoke_cargo_metadata(manifest_path: &Path) -> Result<String, CargoPathDependencyError> {
288    use std::io::Read;
289
290    let mut child = Command::new("cargo")
291        .arg("metadata")
292        .arg("--format-version")
293        .arg("1")
294        .arg("--manifest-path")
295        .arg(manifest_path)
296        .stdout(std::process::Stdio::piped())
297        .stderr(std::process::Stdio::piped())
298        .spawn()
299        .map_err(|error| {
300            CargoPathDependencyError::new(
301                CargoPathDependencyErrorKind::MetadataInvocationFailure,
302                format!("failed to spawn cargo metadata: {error}"),
303            )
304            .with_manifest_path(manifest_path)
305        })?;
306
307    // Drain stdout and stderr concurrently in background threads.
308    //
309    // The previous implementation polled `try_wait` without ever reading from
310    // the pipes, which deadlocked for any project whose `cargo metadata` JSON
311    // exceeded the OS pipe capacity (~64KB on Linux). The child blocked on
312    // `write()` to a full pipe, never exited, and we always hit the 30s
313    // timeout. The RCH workspace itself emits ~2.5MB of metadata, so this
314    // function timed out for *its own callers*. Concurrent draining keeps
315    // the pipe flowing so the child can complete normally; on timeout we
316    // still kill+reap below, after which the drain threads see EOF and exit.
317    let mut stdout_pipe = child
318        .stdout
319        .take()
320        .expect("child spawned with piped stdout");
321    let mut stderr_pipe = child
322        .stderr
323        .take()
324        .expect("child spawned with piped stderr");
325
326    let stdout_thread = std::thread::spawn(move || -> std::io::Result<Vec<u8>> {
327        let mut buf = Vec::new();
328        stdout_pipe.read_to_end(&mut buf)?;
329        Ok(buf)
330    });
331    let stderr_thread = std::thread::spawn(move || -> std::io::Result<Vec<u8>> {
332        let mut buf = Vec::new();
333        stderr_pipe.read_to_end(&mut buf)?;
334        Ok(buf)
335    });
336
337    // Poll `try_wait` with a small sleep. On timeout we kill the child and
338    // reap it so the process + OS resources don't leak.
339    let deadline = std::time::Instant::now() + CARGO_METADATA_TIMEOUT;
340    loop {
341        match child.try_wait() {
342            Ok(Some(_)) => break,
343            Ok(None) => {
344                if std::time::Instant::now() >= deadline {
345                    let kill_err = child.kill().err();
346                    let reap_deadline =
347                        std::time::Instant::now() + std::time::Duration::from_secs(2);
348                    let reaped = loop {
349                        match child.try_wait() {
350                            Ok(Some(_)) => break true,
351                            Ok(None) => {
352                                if std::time::Instant::now() >= reap_deadline {
353                                    break false;
354                                }
355                                std::thread::sleep(std::time::Duration::from_millis(20));
356                            }
357                            Err(_) => break false,
358                        }
359                    };
360                    // Reap the drain threads now that the pipes are closed
361                    // (kill closed them, or EOF arrived). Discard their output
362                    // — we have a timeout error to return either way.
363                    let _ = stdout_thread.join();
364                    let _ = stderr_thread.join();
365
366                    let mut err = CargoPathDependencyError::new(
367                        CargoPathDependencyErrorKind::MetadataInvocationFailure,
368                        format!(
369                            "cargo metadata timed out after {}s for {}",
370                            CARGO_METADATA_TIMEOUT.as_secs(),
371                            manifest_path.display()
372                        ),
373                    )
374                    .with_manifest_path(manifest_path)
375                    .with_diagnostic(format!("timeout_secs={}", CARGO_METADATA_TIMEOUT.as_secs()));
376                    if let Some(ke) = kill_err {
377                        err = err.with_diagnostic(format!("kill_failed={ke}"));
378                    }
379                    if !reaped {
380                        err = err.with_diagnostic("reap_failed=true".to_string());
381                    }
382                    return Err(err);
383                }
384                std::thread::sleep(std::time::Duration::from_millis(50));
385            }
386            Err(error) => {
387                let _ = child.kill();
388                let _ = child.wait();
389                let _ = stdout_thread.join();
390                let _ = stderr_thread.join();
391                return Err(CargoPathDependencyError::new(
392                    CargoPathDependencyErrorKind::MetadataInvocationFailure,
393                    format!("failed to poll cargo metadata: {error}"),
394                )
395                .with_manifest_path(manifest_path));
396            }
397        }
398    }
399
400    let status = child.wait().map_err(|error| {
401        CargoPathDependencyError::new(
402            CargoPathDependencyErrorKind::MetadataInvocationFailure,
403            format!("failed to wait on cargo metadata: {error}"),
404        )
405        .with_manifest_path(manifest_path)
406    })?;
407
408    let stdout_bytes = stdout_thread
409        .join()
410        .map_err(|_| {
411            CargoPathDependencyError::new(
412                CargoPathDependencyErrorKind::MetadataInvocationFailure,
413                "stdout drain thread panicked".to_string(),
414            )
415            .with_manifest_path(manifest_path)
416        })?
417        .map_err(|error| {
418            CargoPathDependencyError::new(
419                CargoPathDependencyErrorKind::MetadataInvocationFailure,
420                format!("failed to read cargo metadata stdout: {error}"),
421            )
422            .with_manifest_path(manifest_path)
423        })?;
424    let stderr_bytes = stderr_thread
425        .join()
426        .map_err(|_| {
427            CargoPathDependencyError::new(
428                CargoPathDependencyErrorKind::MetadataInvocationFailure,
429                "stderr drain thread panicked".to_string(),
430            )
431            .with_manifest_path(manifest_path)
432        })?
433        .map_err(|error| {
434            CargoPathDependencyError::new(
435                CargoPathDependencyErrorKind::MetadataInvocationFailure,
436                format!("failed to read cargo metadata stderr: {error}"),
437            )
438            .with_manifest_path(manifest_path)
439        })?;
440
441    if !status.success() {
442        let stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
443        let detail = if stderr.trim().is_empty() {
444            format!("cargo metadata exited with status {status}")
445        } else {
446            stderr.trim().to_string()
447        };
448        return Err(CargoPathDependencyError::new(
449            CargoPathDependencyErrorKind::MetadataInvocationFailure,
450            detail,
451        )
452        .with_manifest_path(manifest_path));
453    }
454
455    String::from_utf8(stdout_bytes).map_err(|error| {
456        CargoPathDependencyError::new(
457            CargoPathDependencyErrorKind::MetadataParseFailure,
458            format!("metadata stdout is not valid UTF-8: {error}"),
459        )
460        .with_manifest_path(manifest_path)
461    })
462}
463
464#[derive(Debug, Default)]
465struct PartialGraph {
466    workspace_root: Option<PathBuf>,
467    roots: BTreeSet<PathBuf>,
468    packages: BTreeMap<PathBuf, PackageRecord>,
469    adjacency: BTreeMap<PathBuf, BTreeSet<EdgeTail>>,
470}
471
472impl PartialGraph {
473    fn add_root(&mut self, root: PathBuf) {
474        self.roots.insert(root);
475    }
476
477    fn add_package(
478        &mut self,
479        package_root: PathBuf,
480        manifest_path: PathBuf,
481        package_name: String,
482        workspace_member: bool,
483    ) {
484        self.packages
485            .entry(package_root.clone())
486            .and_modify(|existing| {
487                if existing.package_name == default_package_name(&package_root)
488                    && package_name != existing.package_name
489                {
490                    existing.package_name = package_name.clone();
491                }
492                existing.workspace_member |= workspace_member;
493                existing.manifest_path = manifest_path.clone();
494            })
495            .or_insert(PackageRecord {
496                manifest_path,
497                package_name,
498                workspace_member,
499            });
500        self.adjacency.entry(package_root).or_default();
501    }
502
503    fn add_edge(&mut self, from: PathBuf, to: PathBuf, dependency_name: String) {
504        self.adjacency.entry(from).or_default().insert(EdgeTail {
505            to,
506            dependency_name,
507        });
508    }
509}
510
511#[derive(Debug, Clone)]
512struct PackageRecord {
513    manifest_path: PathBuf,
514    package_name: String,
515    workspace_member: bool,
516}
517
518#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
519struct EdgeTail {
520    to: PathBuf,
521    dependency_name: String,
522}
523
524#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
525struct EdgeRecord {
526    from: PathBuf,
527    to: PathBuf,
528    dependency_name: String,
529}
530
531#[derive(Debug, Clone, Copy, PartialEq, Eq)]
532enum VisitState {
533    Visiting,
534    Visited,
535}
536
537fn finalize_graph(
538    entry_manifest_path: PathBuf,
539    partial: PartialGraph,
540) -> Result<CargoPathDependencyGraph, CargoPathDependencyError> {
541    let mut states: BTreeMap<PathBuf, VisitState> = BTreeMap::new();
542    let mut stack: Vec<PathBuf> = Vec::new();
543    let mut reachable_nodes: BTreeSet<PathBuf> = BTreeSet::new();
544    let mut reachable_edges: BTreeSet<EdgeRecord> = BTreeSet::new();
545
546    for root in &partial.roots {
547        traverse_for_reachable(
548            root,
549            &partial.adjacency,
550            &mut states,
551            &mut stack,
552            &mut reachable_nodes,
553            &mut reachable_edges,
554        )?;
555    }
556
557    let packages = reachable_nodes
558        .iter()
559        .map(|root| {
560            if let Some(package) = partial.packages.get(root) {
561                CargoPathDependencyPackage {
562                    package_root: root.clone(),
563                    manifest_path: package.manifest_path.clone(),
564                    package_name: package.package_name.clone(),
565                    workspace_member: package.workspace_member,
566                }
567            } else {
568                CargoPathDependencyPackage {
569                    package_root: root.clone(),
570                    manifest_path: root.join("Cargo.toml"),
571                    package_name: default_package_name(root),
572                    workspace_member: partial.roots.contains(root),
573                }
574            }
575        })
576        .collect::<Vec<_>>();
577
578    let edges = reachable_edges
579        .into_iter()
580        .map(|edge| CargoPathDependencyEdge {
581            from: edge.from,
582            to: edge.to,
583            dependency_name: edge.dependency_name,
584        })
585        .collect::<Vec<_>>();
586
587    Ok(CargoPathDependencyGraph {
588        entry_manifest_path,
589        workspace_root: partial.workspace_root,
590        root_packages: partial.roots.into_iter().collect(),
591        packages,
592        edges,
593    })
594}
595
596fn traverse_for_reachable(
597    node: &Path,
598    adjacency: &BTreeMap<PathBuf, BTreeSet<EdgeTail>>,
599    states: &mut BTreeMap<PathBuf, VisitState>,
600    stack: &mut Vec<PathBuf>,
601    reachable_nodes: &mut BTreeSet<PathBuf>,
602    reachable_edges: &mut BTreeSet<EdgeRecord>,
603) -> Result<(), CargoPathDependencyError> {
604    match states.get(node).copied() {
605        Some(VisitState::Visited) => return Ok(()),
606        Some(VisitState::Visiting) => {
607            let cycle = cycle_from_stack(stack, node);
608            return Err(CargoPathDependencyError::new(
609                CargoPathDependencyErrorKind::CyclicDependency,
610                "cycle detected while traversing dependency graph",
611            )
612            .with_cycle(cycle));
613        }
614        None => {}
615    }
616
617    states.insert(node.to_path_buf(), VisitState::Visiting);
618    stack.push(node.to_path_buf());
619    reachable_nodes.insert(node.to_path_buf());
620
621    if let Some(edges) = adjacency.get(node) {
622        for edge in edges {
623            reachable_edges.insert(EdgeRecord {
624                from: node.to_path_buf(),
625                to: edge.to.clone(),
626                dependency_name: edge.dependency_name.clone(),
627            });
628            if states.get(&edge.to) == Some(&VisitState::Visiting) {
629                let cycle = cycle_from_stack(stack, &edge.to);
630                return Err(CargoPathDependencyError::new(
631                    CargoPathDependencyErrorKind::CyclicDependency,
632                    format!(
633                        "cycle detected between {} and {}",
634                        node.display(),
635                        edge.to.display()
636                    ),
637                )
638                .with_cycle(cycle));
639            }
640            traverse_for_reachable(
641                &edge.to,
642                adjacency,
643                states,
644                stack,
645                reachable_nodes,
646                reachable_edges,
647            )?;
648        }
649    }
650
651    stack.pop();
652    states.insert(node.to_path_buf(), VisitState::Visited);
653    Ok(())
654}
655
656fn cycle_from_stack(stack: &[PathBuf], terminal: &Path) -> Vec<PathBuf> {
657    if let Some(position) = stack.iter().position(|entry| entry == terminal) {
658        let mut cycle = stack[position..].to_vec();
659        cycle.push(terminal.to_path_buf());
660        cycle
661    } else {
662        vec![terminal.to_path_buf()]
663    }
664}
665
666fn default_package_name(root: &Path) -> String {
667    root.file_name()
668        .and_then(|segment| segment.to_str())
669        .map(ToOwned::to_owned)
670        .unwrap_or_else(|| root.display().to_string())
671}
672
673fn allowed_dependency_roots(policy: &PathTopologyPolicy) -> Vec<PathBuf> {
674    let mut roots = vec![
675        policy.canonical_root().to_path_buf(),
676        policy.alias_root().to_path_buf(),
677    ];
678
679    for candidate in [policy.canonical_root(), policy.alias_root()] {
680        if let Ok(resolved) = std::fs::canonicalize(candidate)
681            && !roots.iter().any(|root| root == &resolved)
682        {
683            roots.push(resolved);
684        }
685    }
686
687    roots
688}
689
690fn validate_absolute_dependency_scope(
691    dependency_candidate: &Path,
692    policy: &PathTopologyPolicy,
693    manifest_path: &Path,
694    dependency_name: &str,
695    context: &str,
696) -> Result<(), CargoPathDependencyError> {
697    if !dependency_candidate.is_absolute() {
698        return Ok(());
699    }
700    let allowed_roots = allowed_dependency_roots(policy);
701    if allowed_roots
702        .iter()
703        .any(|root| dependency_candidate.starts_with(root))
704    {
705        return Ok(());
706    }
707
708    let mut error = CargoPathDependencyError::new(
709        CargoPathDependencyErrorKind::PathPolicyViolation,
710        format!("{context}: {}", dependency_candidate.display()),
711    )
712    .with_manifest_path(manifest_path)
713    .with_dependency_name(dependency_name.to_string())
714    .with_dependency_path(dependency_candidate.to_path_buf());
715
716    for root in &allowed_roots {
717        error = error.with_diagnostic(format!("allowed root: {}", root.display()));
718    }
719
720    Err(error)
721}
722
723fn normalize_path_for_policy(
724    path: &Path,
725    policy: &PathTopologyPolicy,
726    manifest_path: Option<&Path>,
727    dependency_name: Option<&str>,
728    context: &str,
729) -> Result<PathBuf, CargoPathDependencyError> {
730    normalize_project_path_with_policy(path, policy)
731        .map(|normalized| normalized.canonical_path().to_path_buf())
732        .map_err(|error| {
733            let mapped_kind = if error.kind() == &PathNormalizationErrorKind::InputResolveFailed {
734                CargoPathDependencyErrorKind::MissingPathDependency
735            } else {
736                CargoPathDependencyErrorKind::PathPolicyViolation
737            };
738            let mut mapped = CargoPathDependencyError::new(
739                mapped_kind,
740                format!("{context}: {} ({})", error.kind(), error.detail()),
741            )
742            .with_diagnostic(format!("normalization_error_kind={}", error.kind()))
743            .with_diagnostic(format!("normalization_detail={}", error.detail()))
744            .with_diagnostics(error.decision_trace().iter().map(ToString::to_string));
745
746            if let Some(manifest_path) = manifest_path {
747                mapped = mapped.with_manifest_path(manifest_path);
748            }
749            if let Some(dependency_name) = dependency_name {
750                mapped = mapped.with_dependency_name(dependency_name.to_string());
751            }
752            mapped.with_dependency_path(path)
753        })
754}
755
756#[derive(Debug, Deserialize)]
757struct MetadataDocument {
758    #[serde(default)]
759    packages: Vec<MetadataPackage>,
760    #[serde(default)]
761    workspace_members: Vec<String>,
762    workspace_root: Option<String>,
763    resolve: Option<MetadataResolve>,
764}
765
766#[derive(Debug, Deserialize)]
767struct MetadataResolve {
768    root: Option<String>,
769    #[serde(default)]
770    nodes: Vec<MetadataResolveNode>,
771}
772
773#[derive(Debug, Deserialize)]
774struct MetadataResolveNode {
775    id: String,
776    #[serde(default)]
777    deps: Vec<MetadataResolveDep>,
778}
779
780#[derive(Debug, Deserialize)]
781struct MetadataResolveDep {
782    name: String,
783    pkg: String,
784    #[serde(default)]
785    dep_kinds: Vec<MetadataResolveDepKind>,
786}
787
788#[derive(Debug, Deserialize)]
789struct MetadataResolveDepKind {
790    kind: Option<String>,
791}
792
793impl MetadataResolveDep {
794    fn is_runtime_relevant(&self) -> bool {
795        if self.dep_kinds.is_empty() {
796            return true;
797        }
798
799        self.dep_kinds
800            .iter()
801            .any(|dep_kind| dep_kind.kind.as_deref() != Some("dev"))
802    }
803}
804
805#[derive(Debug, Deserialize)]
806struct MetadataPackage {
807    id: String,
808    name: String,
809    manifest_path: String,
810    #[serde(default)]
811    dependencies: Vec<MetadataDependency>,
812}
813
814#[derive(Debug, Deserialize)]
815struct MetadataDependency {
816    name: String,
817    path: Option<String>,
818    #[serde(default)]
819    optional: bool,
820}
821
822#[derive(Debug)]
823struct MetadataPackageRecord {
824    package_id: String,
825    package_root: PathBuf,
826    manifest_path: PathBuf,
827    dependencies: Vec<MetadataDependency>,
828}
829
830fn resolve_from_metadata<F>(
831    entry_manifest_path: &Path,
832    policy: &PathTopologyPolicy,
833    metadata_provider: &F,
834) -> Result<CargoPathDependencyGraph, CargoPathDependencyError>
835where
836    F: Fn(&Path) -> Result<String, CargoPathDependencyError>,
837{
838    let raw_metadata = metadata_provider(entry_manifest_path)?;
839    let metadata = serde_json::from_str::<MetadataDocument>(&raw_metadata).map_err(|error| {
840        CargoPathDependencyError::new(
841            CargoPathDependencyErrorKind::MetadataParseFailure,
842            format!("failed to parse cargo metadata JSON: {error}"),
843        )
844        .with_manifest_path(entry_manifest_path)
845    })?;
846
847    let mut partial = PartialGraph::default();
848    let workspace_member_ids = metadata
849        .workspace_members
850        .iter()
851        .cloned()
852        .collect::<BTreeSet<_>>();
853    let mut id_to_root: BTreeMap<String, PathBuf> = BTreeMap::new();
854    let mut package_records: Vec<MetadataPackageRecord> = Vec::new();
855
856    for package in metadata.packages {
857        let manifest_path = PathBuf::from(&package.manifest_path);
858        let manifest_dir = manifest_path.parent().ok_or_else(|| {
859            CargoPathDependencyError::new(
860                CargoPathDependencyErrorKind::MetadataParseFailure,
861                format!(
862                    "metadata package has invalid manifest path: {}",
863                    manifest_path.display()
864                ),
865            )
866            .with_manifest_path(entry_manifest_path)
867        })?;
868        let workspace_member = workspace_member_ids.contains(&package.id);
869        let package_root = match normalize_path_for_policy(
870            manifest_dir,
871            policy,
872            Some(entry_manifest_path),
873            None,
874            "normalize metadata package root",
875        ) {
876            Ok(root) => root,
877            Err(error)
878                if !workspace_member
879                    && error.kind() == &CargoPathDependencyErrorKind::PathPolicyViolation =>
880            {
881                continue;
882            }
883            Err(error) => return Err(error),
884        };
885        let canonical_manifest_path = package_root.join("Cargo.toml");
886
887        partial.add_package(
888            package_root.clone(),
889            canonical_manifest_path.clone(),
890            package.name.clone(),
891            workspace_member,
892        );
893
894        id_to_root.insert(package.id.clone(), package_root.clone());
895        package_records.push(MetadataPackageRecord {
896            package_id: package.id,
897            package_root,
898            manifest_path: canonical_manifest_path,
899            dependencies: package.dependencies,
900        });
901    }
902
903    let resolve_nodes = metadata
904        .resolve
905        .as_ref()
906        .map(|resolve| {
907            resolve
908                .nodes
909                .iter()
910                .map(|node| (node.id.clone(), node))
911                .collect::<BTreeMap<_, _>>()
912        })
913        .unwrap_or_default();
914
915    for package in &package_records {
916        if let Some(node) = resolve_nodes.get(&package.package_id) {
917            for dependency in &node.deps {
918                if !dependency.is_runtime_relevant() {
919                    continue;
920                }
921                let Some(dependency_root) = id_to_root.get(&dependency.pkg) else {
922                    continue;
923                };
924                let dependency_manifest = dependency_root.join("Cargo.toml");
925                partial.add_package(
926                    dependency_root.clone(),
927                    dependency_manifest,
928                    dependency.name.clone(),
929                    false,
930                );
931                partial.add_edge(
932                    package.package_root.clone(),
933                    dependency_root.clone(),
934                    dependency.name.clone(),
935                );
936            }
937            continue;
938        }
939
940        for dependency in &package.dependencies {
941            if dependency.optional {
942                continue;
943            }
944            let Some(raw_path) = dependency.path.as_deref() else {
945                continue;
946            };
947
948            let dependency_candidate =
949                resolve_dependency_candidate(&package.package_root, raw_path);
950            validate_absolute_dependency_scope(
951                &dependency_candidate,
952                policy,
953                &package.manifest_path,
954                &dependency.name,
955                "metadata dependency path policy violation",
956            )?;
957            if !dependency_candidate.exists() {
958                return Err(CargoPathDependencyError::new(
959                    CargoPathDependencyErrorKind::MissingPathDependency,
960                    format!(
961                        "metadata dependency path does not exist: {}",
962                        dependency_candidate.display()
963                    ),
964                )
965                .with_manifest_path(package.manifest_path.clone())
966                .with_dependency_name(dependency.name.clone())
967                .with_dependency_path(dependency_candidate));
968            }
969
970            let dependency_root = normalize_path_for_policy(
971                &dependency_candidate,
972                policy,
973                Some(&package.manifest_path),
974                Some(&dependency.name),
975                "normalize metadata dependency path",
976            )?;
977            let dependency_manifest = dependency_root.join("Cargo.toml");
978            if !dependency_manifest.is_file() {
979                return Err(CargoPathDependencyError::new(
980                    CargoPathDependencyErrorKind::MissingPathDependency,
981                    format!(
982                        "dependency manifest is missing: {}",
983                        dependency_manifest.display()
984                    ),
985                )
986                .with_manifest_path(package.manifest_path.clone())
987                .with_dependency_name(dependency.name.clone())
988                .with_dependency_path(dependency_manifest));
989            }
990
991            partial.add_package(
992                dependency_root.clone(),
993                dependency_manifest,
994                dependency.name.clone(),
995                false,
996            );
997            partial.add_edge(
998                package.package_root.clone(),
999                dependency_root,
1000                dependency.name.clone(),
1001            );
1002        }
1003    }
1004
1005    if let Some(workspace_root) = metadata.workspace_root {
1006        let workspace_root = normalize_path_for_policy(
1007            Path::new(&workspace_root),
1008            policy,
1009            Some(entry_manifest_path),
1010            None,
1011            "normalize metadata workspace root",
1012        )?;
1013        partial.workspace_root = Some(workspace_root);
1014    }
1015
1016    if !workspace_member_ids.is_empty() {
1017        for workspace_id in &workspace_member_ids {
1018            let root = id_to_root.get(workspace_id).ok_or_else(|| {
1019                CargoPathDependencyError::new(
1020                    CargoPathDependencyErrorKind::MetadataParseFailure,
1021                    format!("workspace member id missing from package list: {workspace_id}"),
1022                )
1023                .with_manifest_path(entry_manifest_path)
1024            })?;
1025            partial.add_root(root.clone());
1026            if let Some(package) = partial.packages.get_mut(root) {
1027                package.workspace_member = true;
1028            }
1029        }
1030    } else if let Some(resolve_root) = metadata.resolve.and_then(|resolve| resolve.root) {
1031        let root = id_to_root.get(&resolve_root).ok_or_else(|| {
1032            CargoPathDependencyError::new(
1033                CargoPathDependencyErrorKind::MetadataParseFailure,
1034                format!("resolve root id missing from package list: {resolve_root}"),
1035            )
1036            .with_manifest_path(entry_manifest_path)
1037        })?;
1038        partial.add_root(root.clone());
1039    } else {
1040        let entry_root = entry_manifest_path.parent().ok_or_else(|| {
1041            CargoPathDependencyError::new(
1042                CargoPathDependencyErrorKind::MetadataParseFailure,
1043                format!(
1044                    "entry manifest has no parent: {}",
1045                    entry_manifest_path.display()
1046                ),
1047            )
1048            .with_manifest_path(entry_manifest_path)
1049        })?;
1050        partial.add_root(entry_root.to_path_buf());
1051    }
1052
1053    for root in partial.roots.clone() {
1054        partial
1055            .packages
1056            .entry(root.clone())
1057            .or_insert(PackageRecord {
1058                manifest_path: root.join("Cargo.toml"),
1059                package_name: default_package_name(&root),
1060                workspace_member: true,
1061            });
1062    }
1063
1064    finalize_graph(entry_manifest_path.to_path_buf(), partial)
1065}
1066
1067#[derive(Debug, Clone)]
1068struct ManifestDocument {
1069    package_name: Option<String>,
1070    has_workspace: bool,
1071    workspace_members: Vec<String>,
1072    workspace_path_dependencies: BTreeMap<String, String>,
1073    patch_path_dependencies: BTreeMap<String, String>,
1074    path_dependencies: Vec<ManifestDependency>,
1075}
1076
1077#[derive(Debug, Clone)]
1078struct ManifestDependency {
1079    dependency_name: String,
1080    dependency_path: Option<String>,
1081    uses_workspace_inheritance: bool,
1082}
1083
1084fn resolve_from_manifest_fallback(
1085    entry_manifest_path: &Path,
1086    policy: &PathTopologyPolicy,
1087) -> Result<CargoPathDependencyGraph, CargoPathDependencyError> {
1088    let entry_manifest = read_manifest_document(entry_manifest_path)?;
1089    let entry_root = entry_manifest_path.parent().ok_or_else(|| {
1090        CargoPathDependencyError::new(
1091            CargoPathDependencyErrorKind::ManifestParseFailure,
1092            format!(
1093                "entry manifest has no parent: {}",
1094                entry_manifest_path.display()
1095            ),
1096        )
1097        .with_manifest_path(entry_manifest_path)
1098    })?;
1099    let entry_root = normalize_path_for_policy(
1100        entry_root,
1101        policy,
1102        Some(entry_manifest_path),
1103        None,
1104        "normalize fallback entry root",
1105    )?;
1106
1107    let mut partial = PartialGraph::default();
1108    if entry_manifest.has_workspace {
1109        partial.workspace_root = Some(entry_root.clone());
1110    }
1111    let workspace_path_dependencies = entry_manifest.workspace_path_dependencies.clone();
1112    let patch_path_dependencies = entry_manifest.patch_path_dependencies.clone();
1113
1114    let workspace_member_manifests = expand_workspace_members(
1115        &entry_root,
1116        &entry_manifest.workspace_members,
1117        entry_manifest_path,
1118    )?;
1119    let workspace_member_set = workspace_member_manifests
1120        .iter()
1121        .cloned()
1122        .collect::<BTreeSet<_>>();
1123    let include_entry_manifest =
1124        entry_manifest.package_name.is_some() || workspace_member_set.is_empty();
1125
1126    let mut manifest_cache: BTreeMap<PathBuf, ManifestDocument> = BTreeMap::new();
1127    let mut states: BTreeMap<PathBuf, VisitState> = BTreeMap::new();
1128    let mut stack: Vec<PathBuf> = Vec::new();
1129
1130    for manifest_path in &workspace_member_set {
1131        visit_manifest_recursive(
1132            manifest_path,
1133            policy,
1134            &entry_root,
1135            &workspace_member_set,
1136            &workspace_path_dependencies,
1137            &patch_path_dependencies,
1138            true,
1139            &mut partial,
1140            &mut manifest_cache,
1141            &mut states,
1142            &mut stack,
1143        )?;
1144    }
1145    if include_entry_manifest {
1146        visit_manifest_recursive(
1147            entry_manifest_path,
1148            policy,
1149            &entry_root,
1150            &workspace_member_set,
1151            &workspace_path_dependencies,
1152            &patch_path_dependencies,
1153            true,
1154            &mut partial,
1155            &mut manifest_cache,
1156            &mut states,
1157            &mut stack,
1158        )?;
1159    }
1160
1161    finalize_graph(entry_manifest_path.to_path_buf(), partial)
1162}
1163
1164#[allow(clippy::too_many_arguments)]
1165fn visit_manifest_recursive(
1166    manifest_path: &Path,
1167    policy: &PathTopologyPolicy,
1168    workspace_root: &Path,
1169    workspace_member_manifests: &BTreeSet<PathBuf>,
1170    workspace_path_dependencies: &BTreeMap<String, String>,
1171    patch_path_dependencies: &BTreeMap<String, String>,
1172    mark_workspace_member: bool,
1173    partial: &mut PartialGraph,
1174    manifest_cache: &mut BTreeMap<PathBuf, ManifestDocument>,
1175    states: &mut BTreeMap<PathBuf, VisitState>,
1176    stack: &mut Vec<PathBuf>,
1177) -> Result<PathBuf, CargoPathDependencyError> {
1178    let manifest_root = manifest_path.parent().ok_or_else(|| {
1179        CargoPathDependencyError::new(
1180            CargoPathDependencyErrorKind::ManifestParseFailure,
1181            format!("manifest has no parent: {}", manifest_path.display()),
1182        )
1183        .with_manifest_path(manifest_path)
1184    })?;
1185    let package_root = normalize_path_for_policy(
1186        manifest_root,
1187        policy,
1188        Some(manifest_path),
1189        None,
1190        "normalize manifest package root",
1191    )?;
1192    let canonical_manifest = package_root.join("Cargo.toml");
1193    if !canonical_manifest.is_file() {
1194        return Err(CargoPathDependencyError::new(
1195            CargoPathDependencyErrorKind::ManifestParseFailure,
1196            format!("manifest file missing: {}", canonical_manifest.display()),
1197        )
1198        .with_manifest_path(canonical_manifest));
1199    }
1200
1201    if states.get(&package_root) == Some(&VisitState::Visiting) {
1202        let cycle = cycle_from_stack(stack, &package_root);
1203        return Err(CargoPathDependencyError::new(
1204            CargoPathDependencyErrorKind::CyclicDependency,
1205            format!(
1206                "cyclic path dependency detected at {}",
1207                package_root.display()
1208            ),
1209        )
1210        .with_cycle(cycle));
1211    }
1212    if states.get(&package_root) == Some(&VisitState::Visited) {
1213        if mark_workspace_member {
1214            partial.add_root(package_root.clone());
1215            if let Some(package) = partial.packages.get_mut(&package_root) {
1216                package.workspace_member = true;
1217            }
1218        }
1219        return Ok(package_root);
1220    }
1221
1222    states.insert(package_root.clone(), VisitState::Visiting);
1223    stack.push(package_root.clone());
1224
1225    let manifest = if let Some(cached) = manifest_cache.get(&canonical_manifest) {
1226        cached.clone()
1227    } else {
1228        let parsed = read_manifest_document(&canonical_manifest)?;
1229        manifest_cache.insert(canonical_manifest.clone(), parsed.clone());
1230        parsed
1231    };
1232
1233    let workspace_member =
1234        mark_workspace_member || workspace_member_manifests.contains(&canonical_manifest);
1235    partial.add_package(
1236        package_root.clone(),
1237        canonical_manifest.clone(),
1238        manifest
1239            .package_name
1240            .clone()
1241            .unwrap_or_else(|| default_package_name(&package_root)),
1242        workspace_member,
1243    );
1244    if workspace_member {
1245        partial.add_root(package_root.clone());
1246    }
1247
1248    for dependency in &manifest.path_dependencies {
1249        let Some(dependency_candidate) = resolve_manifest_dependency_candidate(
1250            &package_root,
1251            workspace_root,
1252            dependency,
1253            workspace_path_dependencies,
1254            patch_path_dependencies,
1255        ) else {
1256            continue;
1257        };
1258        validate_absolute_dependency_scope(
1259            &dependency_candidate,
1260            policy,
1261            &canonical_manifest,
1262            &dependency.dependency_name,
1263            "manifest dependency path policy violation",
1264        )?;
1265        if !dependency_candidate.exists() {
1266            return Err(CargoPathDependencyError::new(
1267                CargoPathDependencyErrorKind::MissingPathDependency,
1268                format!(
1269                    "dependency path does not exist: {}",
1270                    dependency_candidate.display()
1271                ),
1272            )
1273            .with_manifest_path(canonical_manifest.clone())
1274            .with_dependency_name(dependency.dependency_name.clone())
1275            .with_dependency_path(dependency_candidate));
1276        }
1277
1278        let dependency_root = normalize_path_for_policy(
1279            &dependency_candidate,
1280            policy,
1281            Some(&canonical_manifest),
1282            Some(&dependency.dependency_name),
1283            "normalize manifest dependency path",
1284        )?;
1285        let dependency_manifest = dependency_root.join("Cargo.toml");
1286        if !dependency_manifest.is_file() {
1287            return Err(CargoPathDependencyError::new(
1288                CargoPathDependencyErrorKind::MissingPathDependency,
1289                format!(
1290                    "dependency manifest missing: {}",
1291                    dependency_manifest.display()
1292                ),
1293            )
1294            .with_manifest_path(canonical_manifest.clone())
1295            .with_dependency_name(dependency.dependency_name.clone())
1296            .with_dependency_path(dependency_manifest));
1297        }
1298
1299        partial.add_edge(
1300            package_root.clone(),
1301            dependency_root.clone(),
1302            dependency.dependency_name.clone(),
1303        );
1304        visit_manifest_recursive(
1305            &dependency_manifest,
1306            policy,
1307            workspace_root,
1308            workspace_member_manifests,
1309            workspace_path_dependencies,
1310            patch_path_dependencies,
1311            false,
1312            partial,
1313            manifest_cache,
1314            states,
1315            stack,
1316        )?;
1317    }
1318
1319    stack.pop();
1320    states.insert(package_root, VisitState::Visited);
1321    Ok(canonical_manifest
1322        .parent()
1323        .unwrap_or_else(|| Path::new("/"))
1324        .to_path_buf())
1325}
1326
1327fn resolve_dependency_candidate(base_root: &Path, raw_dependency_path: &str) -> PathBuf {
1328    let raw = PathBuf::from(raw_dependency_path);
1329    let resolved = if raw.is_absolute() {
1330        raw
1331    } else {
1332        base_root.join(raw)
1333    };
1334    if resolved
1335        .file_name()
1336        .is_some_and(|file_name| file_name == "Cargo.toml")
1337    {
1338        resolved.parent().map(Path::to_path_buf).unwrap_or(resolved)
1339    } else {
1340        resolved
1341    }
1342}
1343
1344fn resolve_manifest_dependency_candidate(
1345    package_root: &Path,
1346    workspace_root: &Path,
1347    dependency: &ManifestDependency,
1348    workspace_path_dependencies: &BTreeMap<String, String>,
1349    patch_path_dependencies: &BTreeMap<String, String>,
1350) -> Option<PathBuf> {
1351    if let Some(raw_dependency_path) = dependency.dependency_path.as_deref() {
1352        return Some(resolve_dependency_candidate(
1353            package_root,
1354            raw_dependency_path,
1355        ));
1356    }
1357
1358    if dependency.uses_workspace_inheritance
1359        && let Some(raw_dependency_path) =
1360            workspace_path_dependencies.get(&dependency.dependency_name)
1361    {
1362        return Some(resolve_dependency_candidate(
1363            workspace_root,
1364            raw_dependency_path,
1365        ));
1366    }
1367
1368    patch_path_dependencies
1369        .get(&dependency.dependency_name)
1370        .map(|raw_dependency_path| {
1371            resolve_dependency_candidate(workspace_root, raw_dependency_path)
1372        })
1373}
1374
1375fn read_manifest_document(
1376    manifest_path: &Path,
1377) -> Result<ManifestDocument, CargoPathDependencyError> {
1378    let contents = std::fs::read_to_string(manifest_path).map_err(|error| {
1379        CargoPathDependencyError::new(
1380            CargoPathDependencyErrorKind::ManifestParseFailure,
1381            format!(
1382                "failed to read manifest {}: {error}",
1383                manifest_path.display()
1384            ),
1385        )
1386        .with_manifest_path(manifest_path)
1387    })?;
1388    let table = toml::from_str::<toml::Table>(&contents).map_err(|error| {
1389        CargoPathDependencyError::new(
1390            CargoPathDependencyErrorKind::ManifestParseFailure,
1391            format!(
1392                "failed to parse manifest {}: {error}",
1393                manifest_path.display()
1394            ),
1395        )
1396        .with_manifest_path(manifest_path)
1397    })?;
1398
1399    let package_name = table
1400        .get("package")
1401        .and_then(toml::Value::as_table)
1402        .and_then(|package| package.get("name"))
1403        .and_then(toml::Value::as_str)
1404        .map(ToOwned::to_owned);
1405    let has_workspace = table.contains_key("workspace");
1406    let workspace_members = table
1407        .get("workspace")
1408        .and_then(toml::Value::as_table)
1409        .and_then(|workspace| workspace.get("members"))
1410        .and_then(toml::Value::as_array)
1411        .map(|members| {
1412            members
1413                .iter()
1414                .filter_map(toml::Value::as_str)
1415                .map(ToOwned::to_owned)
1416                .collect::<Vec<_>>()
1417        })
1418        .unwrap_or_default();
1419    let workspace_path_dependencies = table
1420        .get("workspace")
1421        .and_then(toml::Value::as_table)
1422        .and_then(|workspace| workspace.get("dependencies"))
1423        .map(collect_named_path_dependencies)
1424        .unwrap_or_default();
1425    let patch_path_dependencies = table
1426        .get("patch")
1427        .and_then(toml::Value::as_table)
1428        .map(collect_patch_path_dependencies)
1429        .unwrap_or_default();
1430
1431    let mut path_dependencies = Vec::new();
1432    collect_dependency_specs(table.get("dependencies"), &mut path_dependencies);
1433    collect_dependency_specs(table.get("build-dependencies"), &mut path_dependencies);
1434
1435    if let Some(targets) = table.get("target").and_then(toml::Value::as_table) {
1436        for target_config in targets.values() {
1437            if let Some(target_table) = target_config.as_table() {
1438                collect_dependency_specs(target_table.get("dependencies"), &mut path_dependencies);
1439                collect_dependency_specs(
1440                    target_table.get("build-dependencies"),
1441                    &mut path_dependencies,
1442                );
1443            }
1444        }
1445    }
1446
1447    Ok(ManifestDocument {
1448        package_name,
1449        has_workspace,
1450        workspace_members,
1451        workspace_path_dependencies,
1452        patch_path_dependencies,
1453        path_dependencies,
1454    })
1455}
1456
1457fn collect_dependency_specs(
1458    maybe_table_value: Option<&toml::Value>,
1459    collector: &mut Vec<ManifestDependency>,
1460) {
1461    let Some(table) = maybe_table_value.and_then(toml::Value::as_table) else {
1462        return;
1463    };
1464    for (dependency_name, dependency_value) in table {
1465        match dependency_value {
1466            toml::Value::String(_) => {
1467                collector.push(ManifestDependency {
1468                    dependency_name: dependency_name.clone(),
1469                    dependency_path: None,
1470                    uses_workspace_inheritance: false,
1471                });
1472            }
1473            toml::Value::Table(dependency_table) => {
1474                if dependency_table
1475                    .get("optional")
1476                    .and_then(toml::Value::as_bool)
1477                    .unwrap_or(false)
1478                {
1479                    continue;
1480                }
1481                collector.push(ManifestDependency {
1482                    dependency_name: dependency_name.clone(),
1483                    dependency_path: dependency_table
1484                        .get("path")
1485                        .and_then(toml::Value::as_str)
1486                        .map(ToOwned::to_owned),
1487                    uses_workspace_inheritance: dependency_table
1488                        .get("workspace")
1489                        .and_then(toml::Value::as_bool)
1490                        .unwrap_or(false),
1491                });
1492            }
1493            _ => {}
1494        }
1495    }
1496}
1497
1498fn collect_named_path_dependencies(maybe_table_value: &toml::Value) -> BTreeMap<String, String> {
1499    let Some(table) = maybe_table_value.as_table() else {
1500        return BTreeMap::new();
1501    };
1502
1503    let mut dependencies = BTreeMap::new();
1504    for (dependency_name, dependency_value) in table {
1505        let Some(dependency_table) = dependency_value.as_table() else {
1506            continue;
1507        };
1508        let Some(path) = dependency_table.get("path").and_then(toml::Value::as_str) else {
1509            continue;
1510        };
1511        dependencies.insert(dependency_name.clone(), path.to_string());
1512    }
1513    dependencies
1514}
1515
1516fn collect_patch_path_dependencies(patch_table: &toml::value::Table) -> BTreeMap<String, String> {
1517    let mut dependencies = BTreeMap::new();
1518    for patch_source in patch_table.values() {
1519        dependencies.extend(collect_named_path_dependencies(patch_source));
1520    }
1521    dependencies
1522}
1523
1524fn expand_workspace_members(
1525    workspace_root: &Path,
1526    members: &[String],
1527    manifest_path: &Path,
1528) -> Result<Vec<PathBuf>, CargoPathDependencyError> {
1529    let mut manifests = BTreeSet::new();
1530    for member in members {
1531        let expanded_paths = expand_member_pattern(workspace_root, member).map_err(|error| {
1532            CargoPathDependencyError::new(
1533                CargoPathDependencyErrorKind::ManifestParseFailure,
1534                format!("failed to expand workspace member '{member}': {error}"),
1535            )
1536            .with_manifest_path(manifest_path)
1537        })?;
1538        for candidate in expanded_paths {
1539            let manifest_candidate = if candidate
1540                .file_name()
1541                .is_some_and(|file_name| file_name == "Cargo.toml")
1542            {
1543                candidate
1544            } else {
1545                candidate.join("Cargo.toml")
1546            };
1547            if !manifest_candidate.is_file() {
1548                return Err(CargoPathDependencyError::new(
1549                    CargoPathDependencyErrorKind::MissingPathDependency,
1550                    format!(
1551                        "workspace member manifest missing: {}",
1552                        manifest_candidate.display()
1553                    ),
1554                )
1555                .with_manifest_path(manifest_path)
1556                .with_dependency_name(member.clone())
1557                .with_dependency_path(manifest_candidate));
1558            }
1559            manifests.insert(manifest_candidate);
1560        }
1561    }
1562    Ok(manifests.into_iter().collect())
1563}
1564
1565fn expand_member_pattern(base: &Path, pattern: &str) -> Result<Vec<PathBuf>, std::io::Error> {
1566    if !contains_glob(pattern) {
1567        return Ok(vec![base.join(pattern)]);
1568    }
1569
1570    let mut candidates = vec![base.to_path_buf()];
1571    let normalized_pattern = pattern.replace('\\', "/");
1572    for segment in normalized_pattern.split('/') {
1573        if segment.is_empty() || segment == "." {
1574            continue;
1575        }
1576        if segment == ".." {
1577            candidates = candidates
1578                .into_iter()
1579                .map(|candidate| {
1580                    candidate
1581                        .parent()
1582                        .unwrap_or_else(|| Path::new("/"))
1583                        .to_path_buf()
1584                })
1585                .collect();
1586            continue;
1587        }
1588
1589        let wildcard_segment = contains_wildcard(segment);
1590        let mut next_candidates = Vec::new();
1591        for candidate in &candidates {
1592            if wildcard_segment {
1593                if !candidate.is_dir() {
1594                    continue;
1595                }
1596                for entry in std::fs::read_dir(candidate)? {
1597                    let entry = entry?;
1598                    let file_name = entry.file_name();
1599                    let Some(file_name) = file_name.to_str() else {
1600                        continue;
1601                    };
1602                    if wildcard_match(segment, file_name) {
1603                        next_candidates.push(entry.path());
1604                    }
1605                }
1606            } else {
1607                next_candidates.push(candidate.join(segment));
1608            }
1609        }
1610        candidates = next_candidates;
1611    }
1612
1613    Ok(candidates)
1614}
1615
1616fn contains_glob(pattern: &str) -> bool {
1617    pattern.chars().any(|ch| matches!(ch, '*' | '?' | '['))
1618}
1619
1620fn contains_wildcard(segment: &str) -> bool {
1621    segment.contains('*') || segment.contains('?')
1622}
1623
1624fn wildcard_match(pattern: &str, value: &str) -> bool {
1625    wildcard_match_bytes(pattern.as_bytes(), value.as_bytes())
1626}
1627
1628fn wildcard_match_bytes(pattern: &[u8], value: &[u8]) -> bool {
1629    if pattern.is_empty() {
1630        return value.is_empty();
1631    }
1632    if pattern[0] == b'*' {
1633        for index in 0..=value.len() {
1634            if wildcard_match_bytes(&pattern[1..], &value[index..]) {
1635                return true;
1636            }
1637        }
1638        return false;
1639    }
1640    if value.is_empty() {
1641        return false;
1642    }
1643    if pattern[0] == b'?' || pattern[0] == value[0] {
1644        return wildcard_match_bytes(&pattern[1..], &value[1..]);
1645    }
1646    false
1647}
1648
1649#[cfg(test)]
1650mod tests {
1651    use super::*;
1652    use crate::e2e::{MultiRepoFixtureConfig, reset_multi_repo_fixtures};
1653    use std::fs;
1654    use std::sync::atomic::{AtomicU64, Ordering};
1655
1656    #[cfg(unix)]
1657    use std::os::unix::fs::symlink;
1658
1659    static FIXTURE_COUNTER: AtomicU64 = AtomicU64::new(0);
1660
1661    #[cfg(unix)]
1662    struct TopologyFixture {
1663        root: PathBuf,
1664        canonical_root: PathBuf,
1665        alias_root: PathBuf,
1666    }
1667
1668    #[cfg(unix)]
1669    impl TopologyFixture {
1670        fn new(prefix: &str) -> Self {
1671            let id = FIXTURE_COUNTER.fetch_add(1, Ordering::SeqCst);
1672            let root = std::env::temp_dir().join(format!(
1673                "rch-cargo-path-deps-{}-{}-{}",
1674                prefix,
1675                std::process::id(),
1676                id
1677            ));
1678            let canonical_root = root.join("data/projects");
1679            let alias_root = root.join("dp");
1680            fs::create_dir_all(&canonical_root).expect("create canonical root");
1681            symlink(&canonical_root, &alias_root).expect("create alias symlink");
1682
1683            Self {
1684                root,
1685                canonical_root,
1686                alias_root,
1687            }
1688        }
1689
1690        fn policy(&self) -> PathTopologyPolicy {
1691            PathTopologyPolicy::new(self.canonical_root.clone(), self.alias_root.clone())
1692        }
1693    }
1694
1695    #[cfg(unix)]
1696    impl Drop for TopologyFixture {
1697        fn drop(&mut self) {
1698            let _ = fs::remove_dir_all(&self.root);
1699        }
1700    }
1701
1702    #[cfg(unix)]
1703    fn write_lib_crate(root: &Path, crate_name: &str, deps: &[(&str, &str)]) {
1704        fs::create_dir_all(root.join("src")).expect("create crate src");
1705        fs::write(root.join("Cargo.toml"), crate_manifest(crate_name, deps))
1706            .expect("write manifest");
1707        fs::write(
1708            root.join("src/lib.rs"),
1709            format!(
1710                "pub fn {}() -> &'static str {{ \"{}\" }}\n",
1711                crate_name, crate_name
1712            ),
1713        )
1714        .expect("write lib.rs");
1715    }
1716
1717    #[cfg(unix)]
1718    fn write_bin_crate(root: &Path, crate_name: &str, deps: &[(&str, &str)]) {
1719        fs::create_dir_all(root.join("src")).expect("create crate src");
1720        fs::write(root.join("Cargo.toml"), crate_manifest(crate_name, deps))
1721            .expect("write manifest");
1722        fs::write(
1723            root.join("src/main.rs"),
1724            format!("fn main() {{ println!(\"{}\"); }}\n", crate_name),
1725        )
1726        .expect("write main.rs");
1727    }
1728
1729    #[cfg(unix)]
1730    fn crate_manifest(crate_name: &str, deps: &[(&str, &str)]) -> String {
1731        let mut dependencies = String::new();
1732        for (name, path) in deps {
1733            dependencies.push_str(&format!("{name} = {{ path = \"{path}\" }}\n"));
1734        }
1735        format!(
1736            "[package]\nname = \"{crate_name}\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\n{dependencies}"
1737        )
1738    }
1739
1740    #[cfg(unix)]
1741    #[test]
1742    fn resolves_workspace_transitive_path_dependencies() {
1743        let fixture = TopologyFixture::new("workspace");
1744        let scenario_root = fixture.canonical_root.join("workspace_transitive");
1745        let workspace_root = scenario_root.join("workspace");
1746        let shared_root = scenario_root.join("shared/shared_lib");
1747        let util_root = workspace_root.join("crates/util");
1748        let app_root = workspace_root.join("crates/app");
1749
1750        write_lib_crate(&shared_root, "workspace_shared", &[]);
1751        write_lib_crate(
1752            &util_root,
1753            "workspace_util",
1754            &[("workspace_shared", "../../../shared/shared_lib")],
1755        );
1756        write_bin_crate(&app_root, "workspace_app", &[("workspace_util", "../util")]);
1757        fs::create_dir_all(&workspace_root).expect("create workspace root");
1758        fs::write(
1759            workspace_root.join("Cargo.toml"),
1760            "[workspace]\nmembers = [\"crates/app\", \"crates/util\"]\nresolver = \"3\"\n",
1761        )
1762        .expect("write workspace manifest");
1763
1764        let graph =
1765            resolve_cargo_path_dependency_graph_with_policy(&workspace_root, &fixture.policy())
1766                .expect("resolve workspace graph");
1767
1768        let app_root = app_root.canonicalize().expect("canonical app root");
1769        let util_root = util_root.canonicalize().expect("canonical util root");
1770        let shared_root = shared_root.canonicalize().expect("canonical shared root");
1771        assert_eq!(
1772            graph.root_packages,
1773            vec![app_root.clone(), util_root.clone()]
1774        );
1775        let package_roots = graph
1776            .packages
1777            .iter()
1778            .map(|package| package.package_root.clone())
1779            .collect::<Vec<_>>();
1780        assert!(
1781            package_roots
1782                .windows(2)
1783                .all(|window| window[0] <= window[1]),
1784            "packages should be deterministically sorted"
1785        );
1786        assert_eq!(
1787            package_roots.into_iter().collect::<BTreeSet<_>>(),
1788            BTreeSet::from([app_root.clone(), shared_root.clone(), util_root.clone()])
1789        );
1790        assert_eq!(
1791            graph.edges,
1792            vec![
1793                CargoPathDependencyEdge {
1794                    from: app_root,
1795                    to: util_root.clone(),
1796                    dependency_name: "workspace_util".to_string(),
1797                },
1798                CargoPathDependencyEdge {
1799                    from: util_root,
1800                    to: shared_root,
1801                    dependency_name: "workspace_shared".to_string(),
1802                },
1803            ]
1804        );
1805    }
1806
1807    #[cfg(unix)]
1808    #[test]
1809    fn resolves_virtual_workspace_members_from_alias_path() {
1810        let fixture = TopologyFixture::new("virtual-workspace");
1811        let scenario_root = fixture.canonical_root.join("virtual_workspace");
1812        let workspace_root = scenario_root.join("ws");
1813        let member_a = workspace_root.join("members/a");
1814        let member_b = workspace_root.join("members/b");
1815
1816        write_lib_crate(&member_b, "virtual_b", &[]);
1817        write_lib_crate(&member_a, "virtual_a", &[("virtual_b", "../b")]);
1818        fs::create_dir_all(&workspace_root).expect("create workspace root");
1819        fs::write(
1820            workspace_root.join("Cargo.toml"),
1821            "[workspace]\nmembers = [\"members/a\", \"members/b\"]\nresolver = \"3\"\n",
1822        )
1823        .expect("write workspace manifest");
1824
1825        let relative = workspace_root
1826            .strip_prefix(&fixture.canonical_root)
1827            .expect("workspace under canonical root");
1828        let alias_workspace = fixture.alias_root.join(relative);
1829
1830        let graph =
1831            resolve_cargo_path_dependency_graph_with_policy(&alias_workspace, &fixture.policy())
1832                .expect("resolve virtual workspace graph");
1833
1834        let member_a = member_a.canonicalize().expect("canonical member a");
1835        let member_b = member_b.canonicalize().expect("canonical member b");
1836        assert_eq!(
1837            graph.workspace_root,
1838            Some(
1839                workspace_root
1840                    .canonicalize()
1841                    .expect("canonical workspace root")
1842            )
1843        );
1844        assert_eq!(
1845            graph.root_packages,
1846            vec![member_a.clone(), member_b.clone()]
1847        );
1848        assert_eq!(
1849            graph.edges,
1850            vec![CargoPathDependencyEdge {
1851                from: member_a,
1852                to: member_b,
1853                dependency_name: "virtual_b".to_string(),
1854            }]
1855        );
1856    }
1857
1858    #[cfg(unix)]
1859    #[test]
1860    fn resolves_nested_manifest_transitive_closure() {
1861        let fixture = TopologyFixture::new("nested");
1862        let scenario_root = fixture.canonical_root.join("nested_manifests");
1863        let app_root = scenario_root.join("app");
1864        let util_root = scenario_root.join("libs/util");
1865        let core_root = scenario_root.join("libs/core");
1866
1867        write_lib_crate(&core_root, "nested_core", &[]);
1868        write_lib_crate(&util_root, "nested_util", &[("nested_core", "../core")]);
1869        write_bin_crate(&app_root, "nested_app", &[("nested_util", "../libs/util")]);
1870
1871        let graph = resolve_cargo_path_dependency_graph_with_policy(
1872            &app_root.join("Cargo.toml"),
1873            &fixture.policy(),
1874        )
1875        .expect("resolve nested manifest graph");
1876
1877        let app_root = app_root.canonicalize().expect("canonical app");
1878        let util_root = util_root.canonicalize().expect("canonical util");
1879        let core_root = core_root.canonicalize().expect("canonical core");
1880        assert_eq!(graph.root_packages, vec![app_root.clone()]);
1881        assert_eq!(
1882            graph.edges,
1883            vec![
1884                CargoPathDependencyEdge {
1885                    from: app_root,
1886                    to: util_root.clone(),
1887                    dependency_name: "nested_util".to_string(),
1888                },
1889                CargoPathDependencyEdge {
1890                    from: util_root,
1891                    to: core_root,
1892                    dependency_name: "nested_core".to_string(),
1893                },
1894            ]
1895        );
1896    }
1897
1898    #[cfg(unix)]
1899    #[test]
1900    fn malformed_metadata_uses_manifest_fallback() {
1901        let fixture = TopologyFixture::new("malformed-metadata");
1902        let scenario_root = fixture.canonical_root.join("metadata_fallback");
1903        let app_root = scenario_root.join("app");
1904        let dep_root = scenario_root.join("dep");
1905
1906        write_lib_crate(&dep_root, "fallback_dep", &[]);
1907        write_bin_crate(&app_root, "fallback_app", &[("fallback_dep", "../dep")]);
1908
1909        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
1910            &app_root,
1911            &fixture.policy(),
1912            |_| Ok("{not-json".to_string()),
1913        )
1914        .expect("resolver should recover using fallback");
1915
1916        let app_root = app_root.canonicalize().expect("canonical app");
1917        let dep_root = dep_root.canonicalize().expect("canonical dep");
1918        assert_eq!(graph.root_packages, vec![app_root.clone()]);
1919        assert_eq!(
1920            graph.edges,
1921            vec![CargoPathDependencyEdge {
1922                from: app_root,
1923                to: dep_root,
1924                dependency_name: "fallback_dep".to_string(),
1925            }]
1926        );
1927    }
1928
1929    #[cfg(unix)]
1930    #[test]
1931    fn malformed_manifest_reports_manifest_parse_failure() {
1932        let fixture = TopologyFixture::new("manifest-error");
1933        let config = MultiRepoFixtureConfig::new(
1934            fixture.canonical_root.clone(),
1935            fixture.alias_root.clone(),
1936            "resolver_manifest_error",
1937        );
1938        let fixtures = reset_multi_repo_fixtures(&config).expect("generate fixture set");
1939        let invalid = fixtures
1940            .fixture("fail_invalid_manifest")
1941            .expect("invalid fixture metadata");
1942
1943        let error = resolve_cargo_path_dependency_graph_with_policy(
1944            &invalid.canonical_entrypoint,
1945            &fixture.policy(),
1946        )
1947        .expect_err("invalid manifest must fail");
1948        assert_eq!(
1949            error.kind(),
1950            &CargoPathDependencyErrorKind::ManifestParseFailure
1951        );
1952    }
1953
1954    #[cfg(unix)]
1955    #[test]
1956    fn missing_path_reports_missing_dependency_kind() {
1957        let fixture = TopologyFixture::new("missing-path");
1958        let config = MultiRepoFixtureConfig::new(
1959            fixture.canonical_root.clone(),
1960            fixture.alias_root.clone(),
1961            "resolver_missing_path",
1962        );
1963        let fixtures = reset_multi_repo_fixtures(&config).expect("generate fixture set");
1964        let missing = fixtures
1965            .fixture("fail_missing_path_dep")
1966            .expect("missing fixture metadata");
1967
1968        let error = resolve_cargo_path_dependency_graph_with_policy(
1969            &missing.canonical_entrypoint,
1970            &fixture.policy(),
1971        )
1972        .expect_err("missing dependency must fail");
1973        assert_eq!(
1974            error.kind(),
1975            &CargoPathDependencyErrorKind::MissingPathDependency
1976        );
1977    }
1978
1979    #[cfg(unix)]
1980    #[test]
1981    fn outside_root_reports_path_policy_violation() {
1982        let fixture = TopologyFixture::new("outside-root");
1983        let config = MultiRepoFixtureConfig::new(
1984            fixture.canonical_root.clone(),
1985            fixture.alias_root.clone(),
1986            "resolver_outside_root",
1987        );
1988        let fixtures = reset_multi_repo_fixtures(&config).expect("generate fixture set");
1989        let outside = fixtures
1990            .fixture("fail_outside_canonical_dep")
1991            .expect("outside fixture metadata");
1992
1993        let error = resolve_cargo_path_dependency_graph_with_policy(
1994            &outside.canonical_entrypoint,
1995            &fixture.policy(),
1996        )
1997        .expect_err("outside root dependency must fail");
1998        assert_eq!(
1999            error.kind(),
2000            &CargoPathDependencyErrorKind::PathPolicyViolation
2001        );
2002    }
2003
2004    #[cfg(unix)]
2005    #[test]
2006    fn resolved_alias_target_is_allowed_for_absolute_dependency_scope() {
2007        let fixture = TopologyFixture::new("resolved-alias-target");
2008        let alias_target_root = fixture
2009            .alias_root
2010            .canonicalize()
2011            .expect("resolve alias target root");
2012        let dependency_candidate = alias_target_root.join("repo/crate_dep");
2013
2014        validate_absolute_dependency_scope(
2015            &dependency_candidate,
2016            &fixture.policy(),
2017            Path::new("/tmp/Cargo.toml"),
2018            "crate_dep",
2019            "metadata dependency path policy violation",
2020        )
2021        .expect("resolved alias target should be accepted");
2022    }
2023
2024    #[cfg(unix)]
2025    #[test]
2026    fn cyclic_path_dependencies_report_cycle_kind() {
2027        let fixture = TopologyFixture::new("cycle");
2028        let scenario_root = fixture.canonical_root.join("cycle");
2029        let crate_a = scenario_root.join("a");
2030        let crate_b = scenario_root.join("b");
2031
2032        write_lib_crate(&crate_a, "cycle_a", &[("cycle_b", "../b")]);
2033        write_lib_crate(&crate_b, "cycle_b", &[("cycle_a", "../a")]);
2034
2035        let error = resolve_cargo_path_dependency_graph_with_policy(&crate_a, &fixture.policy())
2036            .expect_err("cyclic path dependencies must fail");
2037        assert_eq!(
2038            error.kind(),
2039            &CargoPathDependencyErrorKind::CyclicDependency
2040        );
2041        assert!(
2042            error.cycle().len() >= 3,
2043            "cycle path should include repeated terminal node"
2044        );
2045    }
2046
2047    #[cfg(unix)]
2048    #[test]
2049    fn optional_path_dependency_cycle_is_ignored_for_active_closure() {
2050        let fixture = TopologyFixture::new("optional-cycle");
2051        let scenario_root = fixture.canonical_root.join("optional_cycle");
2052        let app_root = scenario_root.join("app");
2053        let real_dep_root = scenario_root.join("real_dep");
2054        let optional_a_root = scenario_root.join("optional_a");
2055        let optional_b_root = scenario_root.join("optional_b");
2056
2057        write_lib_crate(&real_dep_root, "real_dep", &[]);
2058        write_lib_crate(
2059            &optional_a_root,
2060            "optional_a",
2061            &[("optional_b", "../optional_b")],
2062        );
2063        write_lib_crate(
2064            &optional_b_root,
2065            "optional_b",
2066            &[("optional_a", "../optional_a")],
2067        );
2068
2069        fs::create_dir_all(app_root.join("src")).expect("create app src");
2070        fs::write(
2071            app_root.join("Cargo.toml"),
2072            r#"[package]
2073name = "optional_cycle_app"
2074version = "0.1.0"
2075edition = "2024"
2076
2077[dependencies]
2078real_dep = { path = "../real_dep" }
2079optional_a = { path = "../optional_a", optional = true }
2080"#,
2081        )
2082        .expect("write app manifest");
2083        fs::write(app_root.join("src/main.rs"), "fn main() {}\n").expect("write app main");
2084
2085        let graph = resolve_cargo_path_dependency_graph_with_policy(&app_root, &fixture.policy())
2086            .expect("optional cycle should not poison active dependency closure");
2087
2088        let app_root = app_root.canonicalize().expect("canonical app");
2089        let real_dep_root = real_dep_root.canonicalize().expect("canonical real dep");
2090        assert_eq!(graph.root_packages, vec![app_root.clone()]);
2091        assert_eq!(
2092            graph.edges,
2093            vec![CargoPathDependencyEdge {
2094                from: app_root,
2095                to: real_dep_root,
2096                dependency_name: "real_dep".to_string(),
2097            }]
2098        );
2099    }
2100
2101    // ── Error type construction and accessor tests ──
2102
2103    #[test]
2104    fn error_kind_display_all_variants() {
2105        let variants = [
2106            (
2107                CargoPathDependencyErrorKind::ManifestParseFailure,
2108                "manifest parse failure",
2109            ),
2110            (
2111                CargoPathDependencyErrorKind::MissingPathDependency,
2112                "missing path dependency",
2113            ),
2114            (
2115                CargoPathDependencyErrorKind::CyclicDependency,
2116                "cyclic path dependency",
2117            ),
2118            (
2119                CargoPathDependencyErrorKind::PathPolicyViolation,
2120                "path policy violation",
2121            ),
2122            (
2123                CargoPathDependencyErrorKind::MetadataParseFailure,
2124                "metadata parse failure",
2125            ),
2126            (
2127                CargoPathDependencyErrorKind::MetadataInvocationFailure,
2128                "metadata invocation failure",
2129            ),
2130        ];
2131        for (kind, expected) in variants {
2132            assert_eq!(kind.to_string(), expected, "Display for {kind:?}");
2133        }
2134    }
2135
2136    #[test]
2137    fn error_accessors_return_builder_values() {
2138        let error = CargoPathDependencyError::new(
2139            CargoPathDependencyErrorKind::MissingPathDependency,
2140            "test detail",
2141        )
2142        .with_manifest_path("/a/Cargo.toml")
2143        .with_dependency_name("dep_x")
2144        .with_dependency_path("/b/dep_x");
2145
2146        assert_eq!(
2147            error.kind(),
2148            &CargoPathDependencyErrorKind::MissingPathDependency
2149        );
2150        assert_eq!(error.detail(), "test detail");
2151        assert_eq!(error.manifest_path(), Some(Path::new("/a/Cargo.toml")));
2152        assert_eq!(error.dependency_name(), Some("dep_x"));
2153        assert_eq!(error.dependency_path(), Some(Path::new("/b/dep_x")));
2154        assert!(error.cycle().is_empty());
2155        assert!(error.diagnostics().is_empty());
2156    }
2157
2158    #[test]
2159    fn error_display_includes_all_fields() {
2160        let error = CargoPathDependencyError::new(
2161            CargoPathDependencyErrorKind::MissingPathDependency,
2162            "not found",
2163        )
2164        .with_manifest_path("/a/Cargo.toml")
2165        .with_dependency_name("missing_dep")
2166        .with_dependency_path("/b/missing");
2167
2168        let display = error.to_string();
2169        assert!(
2170            display.contains("missing path dependency"),
2171            "should contain kind"
2172        );
2173        assert!(display.contains("not found"), "should contain detail");
2174        assert!(
2175            display.contains("/a/Cargo.toml"),
2176            "should contain manifest path"
2177        );
2178        assert!(
2179            display.contains("missing_dep"),
2180            "should contain dependency name"
2181        );
2182        assert!(
2183            display.contains("/b/missing"),
2184            "should contain dependency path"
2185        );
2186    }
2187
2188    #[test]
2189    fn error_diagnostics_accumulate() {
2190        let error = CargoPathDependencyError::new(
2191            CargoPathDependencyErrorKind::MetadataParseFailure,
2192            "parse error",
2193        )
2194        .with_diagnostic("line 1")
2195        .with_diagnostics(["line 2", "line 3"]);
2196
2197        assert_eq!(error.diagnostics().len(), 3);
2198        assert_eq!(error.diagnostics()[0], "line 1");
2199        assert_eq!(error.diagnostics()[1], "line 2");
2200        assert_eq!(error.diagnostics()[2], "line 3");
2201    }
2202
2203    #[test]
2204    fn error_implements_std_error() {
2205        let error = CargoPathDependencyError::new(
2206            CargoPathDependencyErrorKind::ManifestParseFailure,
2207            "bad toml",
2208        );
2209        let _: &dyn std::error::Error = &error;
2210    }
2211
2212    // ── Internal helper function tests ──
2213
2214    #[test]
2215    fn default_package_name_uses_last_component() {
2216        assert_eq!(default_package_name(Path::new("/a/b/my_crate")), "my_crate");
2217        assert_eq!(default_package_name(Path::new("/single")), "single");
2218    }
2219
2220    #[test]
2221    fn default_package_name_root_path_uses_display() {
2222        let name = default_package_name(Path::new("/"));
2223        assert!(
2224            !name.is_empty(),
2225            "root path should produce a non-empty name"
2226        );
2227    }
2228
2229    #[test]
2230    fn resolve_dependency_candidate_relative_path() {
2231        let result = resolve_dependency_candidate(Path::new("/project/app"), "../lib");
2232        assert_eq!(result, PathBuf::from("/project/app/../lib"));
2233    }
2234
2235    #[test]
2236    fn resolve_dependency_candidate_absolute_path() {
2237        let result = resolve_dependency_candidate(Path::new("/project/app"), "/other/lib");
2238        assert_eq!(result, PathBuf::from("/other/lib"));
2239    }
2240
2241    #[test]
2242    fn resolve_dependency_candidate_strips_cargo_toml_suffix() {
2243        let result = resolve_dependency_candidate(Path::new("/project/app"), "../lib/Cargo.toml");
2244        assert_eq!(result, PathBuf::from("/project/app/../lib"));
2245    }
2246
2247    #[test]
2248    fn resolve_dependency_candidate_preserves_non_cargo_toml() {
2249        let result = resolve_dependency_candidate(Path::new("/project/app"), "../lib/src");
2250        assert_eq!(result, PathBuf::from("/project/app/../lib/src"));
2251    }
2252
2253    // ── Wildcard / glob helper tests ──
2254
2255    #[test]
2256    fn contains_glob_detects_patterns() {
2257        assert!(contains_glob("crates/*"));
2258        assert!(contains_glob("lib?"));
2259        assert!(contains_glob("[abc]"));
2260        assert!(!contains_glob("plain_path"));
2261        assert!(!contains_glob(""));
2262    }
2263
2264    #[test]
2265    fn contains_wildcard_detects_star_and_question() {
2266        assert!(contains_wildcard("*"));
2267        assert!(contains_wildcard("foo*"));
2268        assert!(contains_wildcard("fo?"));
2269        assert!(!contains_wildcard("plain"));
2270        assert!(!contains_wildcard("[abc]"));
2271    }
2272
2273    #[test]
2274    fn wildcard_match_exact() {
2275        assert!(wildcard_match("hello", "hello"));
2276        assert!(!wildcard_match("hello", "world"));
2277    }
2278
2279    #[test]
2280    fn wildcard_match_star_patterns() {
2281        assert!(wildcard_match("*", "anything"));
2282        assert!(wildcard_match("*", ""));
2283        assert!(wildcard_match("he*o", "hello"));
2284        assert!(wildcard_match("he*o", "heo"));
2285        assert!(!wildcard_match("he*o", "hex"));
2286        assert!(wildcard_match("*.*", "file.rs"));
2287        assert!(!wildcard_match("*.*", "nodot"));
2288    }
2289
2290    #[test]
2291    fn wildcard_match_question_mark() {
2292        assert!(wildcard_match("h?llo", "hello"));
2293        assert!(wildcard_match("h?llo", "hallo"));
2294        assert!(!wildcard_match("h?llo", "hllo"));
2295        assert!(!wildcard_match("?", ""));
2296    }
2297
2298    #[test]
2299    fn wildcard_match_combined() {
2300        assert!(wildcard_match("rch-*", "rch-common"));
2301        assert!(wildcard_match("rch-*", "rch-"));
2302        assert!(!wildcard_match("rch-*", "rch"));
2303    }
2304
2305    #[test]
2306    fn wildcard_match_empty_pattern_and_value() {
2307        assert!(wildcard_match("", ""));
2308        assert!(!wildcard_match("", "x"));
2309        assert!(wildcard_match("*", ""));
2310    }
2311
2312    // ── cycle_from_stack tests ──
2313
2314    #[test]
2315    fn cycle_from_stack_extracts_cycle_segment() {
2316        let stack = vec![
2317            PathBuf::from("/a"),
2318            PathBuf::from("/b"),
2319            PathBuf::from("/c"),
2320        ];
2321        let cycle = cycle_from_stack(&stack, Path::new("/b"));
2322        assert_eq!(
2323            cycle,
2324            vec![
2325                PathBuf::from("/b"),
2326                PathBuf::from("/c"),
2327                PathBuf::from("/b"),
2328            ]
2329        );
2330    }
2331
2332    #[test]
2333    fn cycle_from_stack_terminal_not_in_stack() {
2334        let stack = vec![PathBuf::from("/a")];
2335        let cycle = cycle_from_stack(&stack, Path::new("/z"));
2336        assert_eq!(cycle, vec![PathBuf::from("/z")]);
2337    }
2338
2339    #[test]
2340    fn cycle_from_stack_single_node_self_cycle() {
2341        let stack = vec![PathBuf::from("/a")];
2342        let cycle = cycle_from_stack(&stack, Path::new("/a"));
2343        assert_eq!(cycle, vec![PathBuf::from("/a"), PathBuf::from("/a")]);
2344    }
2345
2346    #[test]
2347    fn cycle_from_stack_empty_stack() {
2348        let stack: Vec<PathBuf> = vec![];
2349        let cycle = cycle_from_stack(&stack, Path::new("/a"));
2350        assert_eq!(cycle, vec![PathBuf::from("/a")]);
2351    }
2352
2353    // ── finalize_graph tests ──
2354
2355    #[test]
2356    fn finalize_empty_graph() {
2357        let partial = PartialGraph::default();
2358        let graph =
2359            finalize_graph(PathBuf::from("/fake/Cargo.toml"), partial).expect("should succeed");
2360        assert!(graph.packages.is_empty());
2361        assert!(graph.edges.is_empty());
2362        assert!(graph.root_packages.is_empty());
2363        assert!(graph.workspace_root.is_none());
2364    }
2365
2366    #[test]
2367    fn finalize_graph_single_root_no_edges() {
2368        let mut partial = PartialGraph::default();
2369        partial.add_root(PathBuf::from("/project"));
2370        partial.add_package(
2371            PathBuf::from("/project"),
2372            PathBuf::from("/project/Cargo.toml"),
2373            "my_project".to_string(),
2374            true,
2375        );
2376
2377        let graph =
2378            finalize_graph(PathBuf::from("/project/Cargo.toml"), partial).expect("should succeed");
2379        assert_eq!(graph.packages.len(), 1);
2380        assert_eq!(graph.packages[0].package_name, "my_project");
2381        assert!(graph.packages[0].workspace_member);
2382        assert!(graph.edges.is_empty());
2383        assert_eq!(graph.root_packages, vec![PathBuf::from("/project")]);
2384    }
2385
2386    #[test]
2387    fn finalize_graph_unreachable_packages_excluded() {
2388        let mut partial = PartialGraph::default();
2389        partial.add_root(PathBuf::from("/root"));
2390        partial.add_package(
2391            PathBuf::from("/root"),
2392            PathBuf::from("/root/Cargo.toml"),
2393            "root_pkg".to_string(),
2394            true,
2395        );
2396        // Add an unreachable package (no edge from root)
2397        partial.add_package(
2398            PathBuf::from("/orphan"),
2399            PathBuf::from("/orphan/Cargo.toml"),
2400            "orphan_pkg".to_string(),
2401            false,
2402        );
2403
2404        let graph =
2405            finalize_graph(PathBuf::from("/root/Cargo.toml"), partial).expect("should succeed");
2406        assert_eq!(graph.packages.len(), 1, "orphan should be excluded");
2407        assert_eq!(graph.packages[0].package_name, "root_pkg");
2408    }
2409
2410    #[test]
2411    fn finalize_graph_detects_cycle() {
2412        let mut partial = PartialGraph::default();
2413        partial.add_root(PathBuf::from("/a"));
2414        partial.add_package(
2415            PathBuf::from("/a"),
2416            PathBuf::from("/a/Cargo.toml"),
2417            "a".to_string(),
2418            true,
2419        );
2420        partial.add_package(
2421            PathBuf::from("/b"),
2422            PathBuf::from("/b/Cargo.toml"),
2423            "b".to_string(),
2424            false,
2425        );
2426        partial.add_edge(PathBuf::from("/a"), PathBuf::from("/b"), "b".to_string());
2427        partial.add_edge(PathBuf::from("/b"), PathBuf::from("/a"), "a".to_string());
2428
2429        let error = finalize_graph(PathBuf::from("/a/Cargo.toml"), partial)
2430            .expect_err("should detect cycle");
2431        assert_eq!(
2432            error.kind(),
2433            &CargoPathDependencyErrorKind::CyclicDependency
2434        );
2435        assert!(
2436            error.cycle().len() >= 2,
2437            "cycle should include at least the two nodes"
2438        );
2439    }
2440
2441    #[test]
2442    fn finalize_graph_diamond_reachable() {
2443        // A -> B, A -> C, B -> D, C -> D
2444        let mut partial = PartialGraph::default();
2445        partial.add_root(PathBuf::from("/a"));
2446        for (name, root) in [("a", "/a"), ("b", "/b"), ("c", "/c"), ("d", "/d")] {
2447            partial.add_package(
2448                PathBuf::from(root),
2449                PathBuf::from(format!("{root}/Cargo.toml")),
2450                name.to_string(),
2451                name == "a",
2452            );
2453        }
2454        partial.add_edge(PathBuf::from("/a"), PathBuf::from("/b"), "b".to_string());
2455        partial.add_edge(PathBuf::from("/a"), PathBuf::from("/c"), "c".to_string());
2456        partial.add_edge(PathBuf::from("/b"), PathBuf::from("/d"), "d".to_string());
2457        partial.add_edge(PathBuf::from("/c"), PathBuf::from("/d"), "d".to_string());
2458
2459        let graph =
2460            finalize_graph(PathBuf::from("/a/Cargo.toml"), partial).expect("should succeed");
2461        assert_eq!(graph.packages.len(), 4, "all 4 nodes reachable in diamond");
2462        assert_eq!(graph.edges.len(), 4, "all 4 edges present");
2463        // Packages should be sorted by BTreeSet order
2464        let names: Vec<_> = graph
2465            .packages
2466            .iter()
2467            .map(|p| p.package_name.as_str())
2468            .collect();
2469        assert_eq!(names, vec!["a", "b", "c", "d"]);
2470    }
2471
2472    // ── Graph serialization round-trip ──
2473
2474    #[test]
2475    fn graph_serialization_round_trip() {
2476        let graph = CargoPathDependencyGraph {
2477            entry_manifest_path: PathBuf::from("/project/Cargo.toml"),
2478            workspace_root: Some(PathBuf::from("/project")),
2479            root_packages: vec![PathBuf::from("/project/app")],
2480            packages: vec![
2481                CargoPathDependencyPackage {
2482                    package_root: PathBuf::from("/project/app"),
2483                    manifest_path: PathBuf::from("/project/app/Cargo.toml"),
2484                    package_name: "app".to_string(),
2485                    workspace_member: true,
2486                },
2487                CargoPathDependencyPackage {
2488                    package_root: PathBuf::from("/project/lib"),
2489                    manifest_path: PathBuf::from("/project/lib/Cargo.toml"),
2490                    package_name: "lib".to_string(),
2491                    workspace_member: true,
2492                },
2493            ],
2494            edges: vec![CargoPathDependencyEdge {
2495                from: PathBuf::from("/project/app"),
2496                to: PathBuf::from("/project/lib"),
2497                dependency_name: "lib".to_string(),
2498            }],
2499        };
2500
2501        let json = serde_json::to_string(&graph).expect("serialize");
2502        let deserialized: CargoPathDependencyGraph =
2503            serde_json::from_str(&json).expect("deserialize");
2504        assert_eq!(graph, deserialized);
2505    }
2506
2507    // ── Edge type ordering ──
2508
2509    #[test]
2510    fn edge_ordering_is_deterministic() {
2511        let edge_a = CargoPathDependencyEdge {
2512            from: PathBuf::from("/a"),
2513            to: PathBuf::from("/b"),
2514            dependency_name: "b".to_string(),
2515        };
2516        let edge_b = CargoPathDependencyEdge {
2517            from: PathBuf::from("/a"),
2518            to: PathBuf::from("/c"),
2519            dependency_name: "c".to_string(),
2520        };
2521        let edge_c = CargoPathDependencyEdge {
2522            from: PathBuf::from("/b"),
2523            to: PathBuf::from("/c"),
2524            dependency_name: "c".to_string(),
2525        };
2526
2527        let mut edges = vec![edge_c.clone(), edge_a.clone(), edge_b.clone()];
2528        edges.sort();
2529        assert_eq!(edges, vec![edge_a, edge_b, edge_c]);
2530    }
2531
2532    // ── Metadata provider fallback tests ──
2533
2534    #[cfg(unix)]
2535    #[test]
2536    fn metadata_error_then_fallback_success() {
2537        let fixture = TopologyFixture::new("meta-err-fallback");
2538        let scenario_root = fixture.canonical_root.join("meta_fallback");
2539        let app_root = scenario_root.join("app");
2540        let dep_root = scenario_root.join("dep");
2541
2542        write_lib_crate(&dep_root, "fb_dep", &[]);
2543        write_bin_crate(&app_root, "fb_app", &[("fb_dep", "../dep")]);
2544
2545        // Metadata provider returns an error, fallback should recover
2546        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
2547            &app_root,
2548            &fixture.policy(),
2549            |_| {
2550                Err(CargoPathDependencyError::new(
2551                    CargoPathDependencyErrorKind::MetadataInvocationFailure,
2552                    "simulated cargo metadata failure",
2553                ))
2554            },
2555        )
2556        .expect("should recover via manifest fallback");
2557
2558        let app_root = app_root.canonicalize().expect("canonical app");
2559        let dep_root = dep_root.canonicalize().expect("canonical dep");
2560        assert_eq!(graph.root_packages, vec![app_root.clone()]);
2561        assert_eq!(graph.edges.len(), 1);
2562        assert_eq!(graph.edges[0].from, app_root);
2563        assert_eq!(graph.edges[0].to, dep_root);
2564    }
2565
2566    #[cfg(unix)]
2567    #[test]
2568    fn metadata_provider_with_synthetic_json() {
2569        let fixture = TopologyFixture::new("synthetic-meta");
2570        let scenario_root = fixture.canonical_root.join("synth_meta");
2571        let app_root = scenario_root.join("app");
2572        let dep_root = scenario_root.join("dep");
2573
2574        write_lib_crate(&dep_root, "synth_dep", &[]);
2575        write_bin_crate(&app_root, "synth_app", &[("synth_dep", "../dep")]);
2576
2577        // Provide well-formed synthetic metadata JSON
2578        let app_canonical = app_root.canonicalize().expect("canonical app");
2579        let dep_canonical = dep_root.canonicalize().expect("canonical dep");
2580        let app_manifest = app_canonical.join("Cargo.toml");
2581        let dep_manifest = dep_canonical.join("Cargo.toml");
2582
2583        let metadata_json = format!(
2584            r#"{{
2585                "packages": [
2586                    {{
2587                        "id": "synth_app 0.1.0 (path+file://{})",
2588                        "name": "synth_app",
2589                        "manifest_path": "{}",
2590                        "dependencies": [
2591                            {{"name": "synth_dep", "path": "{}"}}
2592                        ]
2593                    }},
2594                    {{
2595                        "id": "synth_dep 0.1.0 (path+file://{})",
2596                        "name": "synth_dep",
2597                        "manifest_path": "{}",
2598                        "dependencies": []
2599                    }}
2600                ],
2601                "workspace_members": [],
2602                "workspace_root": null,
2603                "resolve": {{
2604                    "root": "synth_app 0.1.0 (path+file://{})"
2605                }}
2606            }}"#,
2607            app_canonical.display(),
2608            app_manifest.display(),
2609            dep_canonical.display(),
2610            dep_canonical.display(),
2611            dep_manifest.display(),
2612            app_canonical.display(),
2613        );
2614
2615        let json_clone = metadata_json.clone();
2616        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
2617            &app_root,
2618            &fixture.policy(),
2619            move |_| Ok(json_clone.clone()),
2620        )
2621        .expect("synthetic metadata should resolve");
2622
2623        assert_eq!(graph.root_packages, vec![app_canonical.clone()]);
2624        assert_eq!(graph.edges.len(), 1);
2625        assert_eq!(graph.edges[0].dependency_name, "synth_dep");
2626    }
2627
2628    #[cfg(unix)]
2629    #[test]
2630    fn metadata_resolve_ignores_pure_dev_path_edges() {
2631        let fixture = TopologyFixture::new("synthetic-meta-dev-filter");
2632        let scenario_root = fixture.canonical_root.join("synth_meta_dev_filter");
2633        let app_root = scenario_root.join("app");
2634        let dep_root = scenario_root.join("dep");
2635        let dev_dep_root = scenario_root.join("dev_dep");
2636
2637        write_lib_crate(&dep_root, "runtime_dep", &[]);
2638        write_lib_crate(&dev_dep_root, "dev_only_dep", &[]);
2639        write_bin_crate(&app_root, "dev_filter_app", &[]);
2640
2641        let app_canonical = app_root.canonicalize().expect("canonical app");
2642        let dep_canonical = dep_root.canonicalize().expect("canonical dep");
2643        let dev_dep_canonical = dev_dep_root.canonicalize().expect("canonical dev dep");
2644        let app_manifest = app_canonical.join("Cargo.toml");
2645        let dep_manifest = dep_canonical.join("Cargo.toml");
2646        let dev_dep_manifest = dev_dep_canonical.join("Cargo.toml");
2647
2648        let metadata_json = format!(
2649            r#"{{
2650                "packages": [
2651                    {{
2652                        "id": "dev_filter_app 0.1.0 (path+file://{app_root})",
2653                        "name": "dev_filter_app",
2654                        "manifest_path": "{app_manifest}",
2655                        "dependencies": [
2656                            {{"name": "runtime_dep", "path": "{dep_root}"}},
2657                            {{"name": "dev_only_dep", "path": "{dev_dep_root}"}}
2658                        ]
2659                    }},
2660                    {{
2661                        "id": "runtime_dep 0.1.0 (path+file://{dep_root})",
2662                        "name": "runtime_dep",
2663                        "manifest_path": "{dep_manifest}",
2664                        "dependencies": []
2665                    }},
2666                    {{
2667                        "id": "dev_only_dep 0.1.0 (path+file://{dev_dep_root})",
2668                        "name": "dev_only_dep",
2669                        "manifest_path": "{dev_dep_manifest}",
2670                        "dependencies": []
2671                    }}
2672                ],
2673                "workspace_members": [],
2674                "workspace_root": null,
2675                "resolve": {{
2676                    "root": "dev_filter_app 0.1.0 (path+file://{app_root})",
2677                    "nodes": [
2678                        {{
2679                            "id": "dev_filter_app 0.1.0 (path+file://{app_root})",
2680                            "deps": [
2681                                {{
2682                                    "name": "runtime_dep",
2683                                    "pkg": "runtime_dep 0.1.0 (path+file://{dep_root})",
2684                                    "dep_kinds": [{{"kind": null, "target": null}}]
2685                                }},
2686                                {{
2687                                    "name": "dev_only_dep",
2688                                    "pkg": "dev_only_dep 0.1.0 (path+file://{dev_dep_root})",
2689                                    "dep_kinds": [{{"kind": "dev", "target": null}}]
2690                                }}
2691                            ]
2692                        }},
2693                        {{
2694                            "id": "runtime_dep 0.1.0 (path+file://{dep_root})",
2695                            "deps": []
2696                        }},
2697                        {{
2698                            "id": "dev_only_dep 0.1.0 (path+file://{dev_dep_root})",
2699                            "deps": []
2700                        }}
2701                    ]
2702                }}
2703            }}"#,
2704            app_root = app_canonical.display(),
2705            app_manifest = app_manifest.display(),
2706            dep_root = dep_canonical.display(),
2707            dep_manifest = dep_manifest.display(),
2708            dev_dep_root = dev_dep_canonical.display(),
2709            dev_dep_manifest = dev_dep_manifest.display(),
2710        );
2711
2712        let json_clone = metadata_json.clone();
2713        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
2714            &app_root,
2715            &fixture.policy(),
2716            move |_| Ok(json_clone.clone()),
2717        )
2718        .expect("synthetic metadata with dev-only edge should resolve");
2719
2720        assert_eq!(graph.root_packages, vec![app_canonical.clone()]);
2721        assert_eq!(
2722            graph.edges.len(),
2723            1,
2724            "pure dev-only path edges must be ignored"
2725        );
2726        assert_eq!(graph.edges[0].dependency_name, "runtime_dep");
2727        assert_eq!(graph.edges[0].from, app_canonical);
2728        assert_eq!(graph.edges[0].to, dep_canonical);
2729        assert!(
2730            graph
2731                .packages
2732                .iter()
2733                .all(|pkg| pkg.package_root != dev_dep_canonical),
2734            "dev-only dependency package should not be pulled into runtime closure"
2735        );
2736    }
2737
2738    #[cfg(unix)]
2739    #[test]
2740    fn metadata_resolve_skips_non_local_packages_and_keeps_local_edges() {
2741        let fixture = TopologyFixture::new("synthetic-meta-nonlocal");
2742        let scenario_root = fixture.canonical_root.join("synth_meta_nonlocal");
2743        let app_root = scenario_root.join("app");
2744        let dep_root = scenario_root.join("dep");
2745        let external_root = fixture.root.join("external_registry/serde");
2746
2747        write_lib_crate(&dep_root, "local_dep", &[]);
2748        write_bin_crate(&app_root, "meta_nonlocal_app", &[]);
2749        write_lib_crate(&external_root, "serde", &[]);
2750
2751        let app_canonical = app_root.canonicalize().expect("canonical app");
2752        let dep_canonical = dep_root.canonicalize().expect("canonical dep");
2753        let external_canonical = external_root
2754            .canonicalize()
2755            .expect("canonical external dep");
2756        let app_manifest = app_canonical.join("Cargo.toml");
2757        let dep_manifest = dep_canonical.join("Cargo.toml");
2758        let external_manifest = external_canonical.join("Cargo.toml");
2759
2760        let metadata_json = format!(
2761            r#"{{
2762                "packages": [
2763                    {{
2764                        "id": "meta_nonlocal_app 0.1.0 (path+file://{app_root})",
2765                        "name": "meta_nonlocal_app",
2766                        "manifest_path": "{app_manifest}",
2767                        "dependencies": [
2768                            {{"name": "local_dep", "path": "{dep_root}"}},
2769                            {{"name": "serde", "path": null}}
2770                        ]
2771                    }},
2772                    {{
2773                        "id": "local_dep 0.1.0 (path+file://{dep_root})",
2774                        "name": "local_dep",
2775                        "manifest_path": "{dep_manifest}",
2776                        "dependencies": []
2777                    }},
2778                    {{
2779                        "id": "serde 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)",
2780                        "name": "serde",
2781                        "manifest_path": "{external_manifest}",
2782                        "dependencies": []
2783                    }}
2784                ],
2785                "workspace_members": [
2786                    "meta_nonlocal_app 0.1.0 (path+file://{app_root})"
2787                ],
2788                "workspace_root": null,
2789                "resolve": {{
2790                    "root": "meta_nonlocal_app 0.1.0 (path+file://{app_root})",
2791                    "nodes": [
2792                        {{
2793                            "id": "meta_nonlocal_app 0.1.0 (path+file://{app_root})",
2794                            "deps": [
2795                                {{
2796                                    "name": "local_dep",
2797                                    "pkg": "local_dep 0.1.0 (path+file://{dep_root})",
2798                                    "dep_kinds": [{{"kind": null, "target": null}}]
2799                                }},
2800                                {{
2801                                    "name": "serde",
2802                                    "pkg": "serde 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)",
2803                                    "dep_kinds": [{{"kind": null, "target": null}}]
2804                                }}
2805                            ]
2806                        }},
2807                        {{
2808                            "id": "local_dep 0.1.0 (path+file://{dep_root})",
2809                            "deps": []
2810                        }},
2811                        {{
2812                            "id": "serde 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)",
2813                            "deps": []
2814                        }}
2815                    ]
2816                }}
2817            }}"#,
2818            app_root = app_canonical.display(),
2819            app_manifest = app_manifest.display(),
2820            dep_root = dep_canonical.display(),
2821            dep_manifest = dep_manifest.display(),
2822            external_manifest = external_manifest.display(),
2823        );
2824
2825        let json_clone = metadata_json.clone();
2826        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
2827            &app_root,
2828            &fixture.policy(),
2829            move |_| Ok(json_clone.clone()),
2830        )
2831        .expect("synthetic metadata with non-local packages should still resolve");
2832
2833        assert_eq!(graph.root_packages, vec![app_canonical.clone()]);
2834        assert_eq!(
2835            graph.edges,
2836            vec![CargoPathDependencyEdge {
2837                from: app_canonical.clone(),
2838                to: dep_canonical.clone(),
2839                dependency_name: "local_dep".to_string(),
2840            }]
2841        );
2842        assert!(
2843            graph
2844                .packages
2845                .iter()
2846                .all(|package| package.package_root != external_canonical),
2847            "non-local registry packages must not be pulled into the sync closure"
2848        );
2849    }
2850
2851    #[cfg(unix)]
2852    #[test]
2853    fn manifest_fallback_ignores_pure_dev_path_edges() {
2854        let fixture = TopologyFixture::new("manifest-fallback-dev-filter");
2855        let scenario_root = fixture.canonical_root.join("manifest_fallback_dev_filter");
2856        let app_root = scenario_root.join("app");
2857        let dep_root = scenario_root.join("dep");
2858        let dev_dep_root = scenario_root.join("dev_dep");
2859
2860        write_lib_crate(&dep_root, "runtime_dep", &[]);
2861        write_lib_crate(&dev_dep_root, "dev_only_dep", &[]);
2862
2863        std::fs::create_dir_all(app_root.join("src")).expect("create app src");
2864        std::fs::write(
2865            app_root.join("Cargo.toml"),
2866            r#"[package]
2867name = "manifest_dev_filter_app"
2868version = "0.1.0"
2869edition = "2024"
2870
2871[dependencies]
2872runtime_dep = { path = "../dep" }
2873
2874[dev-dependencies]
2875dev_only_dep = { path = "../dev_dep" }
2876"#,
2877        )
2878        .expect("write app manifest");
2879        std::fs::write(app_root.join("src/main.rs"), "fn main() {}\n").expect("write app main");
2880
2881        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
2882            &app_root,
2883            &fixture.policy(),
2884            |_| {
2885                Err(CargoPathDependencyError::new(
2886                    CargoPathDependencyErrorKind::MetadataInvocationFailure,
2887                    "force manifest fallback",
2888                ))
2889            },
2890        )
2891        .expect("manifest fallback should ignore pure dev-only path edges");
2892
2893        let app_canonical = app_root.canonicalize().expect("canonical app");
2894        let dep_canonical = dep_root.canonicalize().expect("canonical dep");
2895        let dev_dep_canonical = dev_dep_root.canonicalize().expect("canonical dev dep");
2896
2897        assert_eq!(graph.root_packages, vec![app_canonical.clone()]);
2898        assert_eq!(graph.edges.len(), 1);
2899        assert_eq!(graph.edges[0].dependency_name, "runtime_dep");
2900        assert_eq!(graph.edges[0].from, app_canonical);
2901        assert_eq!(graph.edges[0].to, dep_canonical);
2902        assert!(
2903            graph
2904                .packages
2905                .iter()
2906                .all(|pkg| pkg.package_root != dev_dep_canonical),
2907            "manifest fallback must not pull pure dev-only path deps into runtime closure"
2908        );
2909    }
2910
2911    #[cfg(unix)]
2912    #[test]
2913    fn manifest_fallback_resolves_workspace_shared_path_dependencies() {
2914        let fixture = TopologyFixture::new("manifest-workspace-shared");
2915        let scenario_root = fixture.canonical_root.join("manifest_workspace_shared");
2916        let workspace_root = scenario_root.join("workspace");
2917        let app_root = workspace_root.join("crates/app");
2918        let shared_root = scenario_root.join("shared/shared_dep");
2919
2920        write_lib_crate(&shared_root, "shared_dep", &[]);
2921        std::fs::create_dir_all(app_root.join("src")).expect("create app src");
2922        std::fs::write(
2923            app_root.join("Cargo.toml"),
2924            r#"[package]
2925name = "workspace_shared_app"
2926version = "0.1.0"
2927edition = "2024"
2928
2929[dependencies]
2930shared_dep = { workspace = true }
2931"#,
2932        )
2933        .expect("write app manifest");
2934        std::fs::write(app_root.join("src/main.rs"), "fn main() {}\n").expect("write app main");
2935        std::fs::create_dir_all(&workspace_root).expect("create workspace root");
2936        std::fs::write(
2937            workspace_root.join("Cargo.toml"),
2938            r#"[workspace]
2939members = ["crates/app"]
2940resolver = "3"
2941
2942[workspace.dependencies]
2943shared_dep = { path = "../shared/shared_dep" }
2944"#,
2945        )
2946        .expect("write workspace manifest");
2947
2948        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
2949            &workspace_root,
2950            &fixture.policy(),
2951            |_| {
2952                Err(CargoPathDependencyError::new(
2953                    CargoPathDependencyErrorKind::MetadataInvocationFailure,
2954                    "force manifest fallback",
2955                ))
2956            },
2957        )
2958        .expect("manifest fallback should resolve workspace-shared path dependencies");
2959
2960        let app_canonical = app_root.canonicalize().expect("canonical app");
2961        let shared_canonical = shared_root.canonicalize().expect("canonical shared dep");
2962        assert_eq!(graph.root_packages, vec![app_canonical.clone()]);
2963        assert_eq!(
2964            graph.edges,
2965            vec![CargoPathDependencyEdge {
2966                from: app_canonical,
2967                to: shared_canonical,
2968                dependency_name: "shared_dep".to_string(),
2969            }]
2970        );
2971    }
2972
2973    #[cfg(unix)]
2974    #[test]
2975    fn manifest_fallback_resolves_patch_path_dependencies() {
2976        let fixture = TopologyFixture::new("manifest-patch-shared");
2977        let scenario_root = fixture.canonical_root.join("manifest_patch_shared");
2978        let workspace_root = scenario_root.join("workspace");
2979        let app_root = workspace_root.join("app");
2980        let patched_root = scenario_root.join("patched/patched_dep");
2981
2982        write_lib_crate(&patched_root, "patched_dep", &[]);
2983        std::fs::create_dir_all(app_root.join("src")).expect("create app src");
2984        std::fs::write(
2985            app_root.join("Cargo.toml"),
2986            r#"[package]
2987name = "patched_app"
2988version = "0.1.0"
2989edition = "2024"
2990
2991[dependencies]
2992patched_dep = "0.1"
2993"#,
2994        )
2995        .expect("write app manifest");
2996        std::fs::write(app_root.join("src/main.rs"), "fn main() {}\n").expect("write app main");
2997        std::fs::create_dir_all(&workspace_root).expect("create workspace root");
2998        std::fs::write(
2999            workspace_root.join("Cargo.toml"),
3000            r#"[workspace]
3001members = ["app"]
3002resolver = "3"
3003
3004[patch.crates-io]
3005patched_dep = { path = "../patched/patched_dep" }
3006"#,
3007        )
3008        .expect("write workspace manifest");
3009
3010        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
3011            &workspace_root,
3012            &fixture.policy(),
3013            |_| {
3014                Err(CargoPathDependencyError::new(
3015                    CargoPathDependencyErrorKind::MetadataInvocationFailure,
3016                    "force manifest fallback",
3017                ))
3018            },
3019        )
3020        .expect("manifest fallback should resolve patch path dependencies");
3021
3022        let app_canonical = app_root.canonicalize().expect("canonical app");
3023        let patched_canonical = patched_root.canonicalize().expect("canonical patched dep");
3024        assert_eq!(graph.root_packages, vec![app_canonical.clone()]);
3025        assert_eq!(
3026            graph.edges,
3027            vec![CargoPathDependencyEdge {
3028                from: app_canonical,
3029                to: patched_canonical,
3030                dependency_name: "patched_dep".to_string(),
3031            }]
3032        );
3033    }
3034
3035    #[cfg(unix)]
3036    #[test]
3037    fn standalone_crate_no_dependencies() {
3038        let fixture = TopologyFixture::new("standalone");
3039        let crate_root = fixture.canonical_root.join("standalone_crate");
3040
3041        write_bin_crate(&crate_root, "standalone", &[]);
3042
3043        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
3044            &crate_root,
3045            &fixture.policy(),
3046            |_| Ok("{not-json".to_string()), // force fallback
3047        )
3048        .expect("standalone crate should resolve");
3049
3050        assert_eq!(graph.packages.len(), 1);
3051        assert_eq!(graph.packages[0].package_name, "standalone");
3052        assert!(graph.edges.is_empty());
3053    }
3054
3055    // ── PartialGraph internal behavior ──
3056
3057    #[test]
3058    fn partial_graph_deduplicates_edges() {
3059        let mut partial = PartialGraph::default();
3060        partial.add_edge(PathBuf::from("/a"), PathBuf::from("/b"), "b".to_string());
3061        partial.add_edge(PathBuf::from("/a"), PathBuf::from("/b"), "b".to_string());
3062
3063        let edges = partial.adjacency.get(Path::new("/a")).unwrap();
3064        assert_eq!(
3065            edges.len(),
3066            1,
3067            "duplicate edge should be deduplicated by BTreeSet"
3068        );
3069    }
3070
3071    #[test]
3072    fn partial_graph_add_package_updates_name_from_default() {
3073        let mut partial = PartialGraph::default();
3074        let root = PathBuf::from("/project/my_crate");
3075
3076        // First add with default name
3077        partial.add_package(
3078            root.clone(),
3079            root.join("Cargo.toml"),
3080            default_package_name(&root),
3081            false,
3082        );
3083        assert_eq!(
3084            partial.packages.get(&root).unwrap().package_name,
3085            "my_crate"
3086        );
3087
3088        // Second add with real name should update
3089        partial.add_package(
3090            root.clone(),
3091            root.join("Cargo.toml"),
3092            "real_name".to_string(),
3093            false,
3094        );
3095        assert_eq!(
3096            partial.packages.get(&root).unwrap().package_name,
3097            "real_name"
3098        );
3099    }
3100
3101    #[test]
3102    fn partial_graph_add_package_or_promotes_workspace_member() {
3103        let mut partial = PartialGraph::default();
3104        let root = PathBuf::from("/project");
3105
3106        partial.add_package(
3107            root.clone(),
3108            root.join("Cargo.toml"),
3109            "pkg".to_string(),
3110            false,
3111        );
3112        assert!(!partial.packages.get(&root).unwrap().workspace_member);
3113
3114        // Adding again with workspace_member=true should promote
3115        partial.add_package(
3116            root.clone(),
3117            root.join("Cargo.toml"),
3118            "pkg".to_string(),
3119            true,
3120        );
3121        assert!(partial.packages.get(&root).unwrap().workspace_member);
3122    }
3123
3124    /// Regression: `invoke_cargo_metadata` previously deadlocked when the JSON
3125    /// output exceeded the OS pipe capacity (~64 KB on Linux). The poll loop
3126    /// never read stdout, so the child blocked on `write()` and we always hit
3127    /// the 30s timeout. We now drain stdout/stderr concurrently in background
3128    /// threads. This test forces a large output by pulling in dozens of
3129    /// crates.io dependencies and asserting the call returns successfully
3130    /// well under the timeout.
3131    #[cfg(unix)]
3132    #[test]
3133    fn invoke_cargo_metadata_handles_output_larger_than_pipe_buffer() {
3134        let fixture = TopologyFixture::new("metadata-large");
3135        let project_root = fixture.canonical_root.join("metadata_pipe");
3136        fs::create_dir_all(project_root.join("src")).expect("create src");
3137
3138        // 60+ small dependencies inflate the cargo metadata JSON well past
3139        // the 64KB pipe buffer once each is resolved with version, source,
3140        // checksum, and feature lists.
3141        let mut deps = String::new();
3142        for name in [
3143            "serde",
3144            "serde_json",
3145            "anyhow",
3146            "thiserror",
3147            "tokio",
3148            "futures",
3149            "log",
3150            "tracing",
3151            "regex",
3152            "chrono",
3153            "uuid",
3154            "rand",
3155            "base64",
3156            "hex",
3157            "url",
3158            "bytes",
3159            "clap",
3160            "rusqlite",
3161            "blake3",
3162            "sha2",
3163            "reqwest",
3164            "tempfile",
3165            "directories",
3166            "dirs",
3167            "shellexpand",
3168            "shell-escape",
3169            "which",
3170            "openssh",
3171            "schemars",
3172            "indexmap",
3173            "lazy_static",
3174            "once_cell",
3175            "parking_lot",
3176            "toml",
3177            "shell-words",
3178            "globset",
3179            "walkdir",
3180            "ignore",
3181            "crossbeam-channel",
3182            "rayon",
3183            "memchr",
3184            "smallvec",
3185            "ahash",
3186            "fnv",
3187            "itertools",
3188            "num_cpus",
3189            "humantime",
3190            "humansize",
3191            "ratatui",
3192            "crossterm",
3193        ] {
3194            deps.push_str(&format!("{name} = \"*\"\n"));
3195        }
3196
3197        fs::write(
3198            project_root.join("Cargo.toml"),
3199            format!(
3200                "[package]\nname = \"metadata_pipe\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n{deps}",
3201            ),
3202        )
3203        .expect("write manifest");
3204        fs::write(project_root.join("src/lib.rs"), "").expect("write lib.rs");
3205
3206        let manifest_path = project_root.join("Cargo.toml");
3207        let start = std::time::Instant::now();
3208        match invoke_cargo_metadata(&manifest_path) {
3209            Ok(json) => {
3210                let elapsed = start.elapsed();
3211                // Without the fix the function would either time out at 30s
3212                // or (depending on dependency cache) produce a truncated
3213                // result; both paths fail this assertion.
3214                assert!(
3215                    elapsed < CARGO_METADATA_TIMEOUT,
3216                    "metadata took {elapsed:?}, near the {CARGO_METADATA_TIMEOUT:?} timeout"
3217                );
3218                assert!(
3219                    json.len() > 64 * 1024,
3220                    "test fixture too small to exercise the pipe-buffer path: {} bytes",
3221                    json.len()
3222                );
3223                assert!(
3224                    json.starts_with('{'),
3225                    "metadata stdout should be JSON; got: {:.120}",
3226                    json
3227                );
3228            }
3229            Err(e) => {
3230                // Skip on machines without network access to crates.io or
3231                // without registry caches — we only care about the
3232                // *deadlock* regression, not about resolver outcomes.
3233                let detail = e.detail().to_lowercase();
3234                let offline = detail.contains("network")
3235                    || detail.contains("dns")
3236                    || detail.contains("registry")
3237                    || detail.contains("connection")
3238                    || detail.contains("not found")
3239                    || detail.contains("offline")
3240                    || detail.contains("could not")
3241                    || detail.contains("failed to fetch")
3242                    || detail.contains("no such file");
3243                assert!(
3244                    offline,
3245                    "invoke_cargo_metadata failed for non-network reason: {e}"
3246                );
3247            }
3248        }
3249    }
3250}