Skip to main content

rch_common/
cargo_path_deps.rs

1//! Cargo local path-dependency graph resolver.
2//!
3//! The resolver builds a deterministic active graph of local `path` dependencies
4//! using a two-phase strategy:
5//! 1. `cargo metadata --locked` (primary source of truth when a valid
6//!    `Cargo.lock` is present)
7//! 2. Recursive manifest parsing fallback (for malformed metadata, metadata
8//!    failures, and projects whose lockfile is stale, incompatible, or absent)
9//!
10//! Dependency planning is strictly NON-MUTATING
11//! (bd-rch-pinned-metadata-lock-ytkda): plain `cargo metadata` creates or
12//! rewrites the project's `Cargo.lock` as a side effect, which silently
13//! mutated user checkouts every time the hook planned a sync closure. The
14//! resolver therefore always passes `--locked`; when cargo refuses (stale or
15//! missing lockfile) it falls through to the manifest-only fallback, which
16//! never writes anything. Lockfile bytes and mtimes are preserved in every
17//! case.
18//!
19//! This invariant is about the ambient checkout passed to dependency planning.
20//! Clean-overlay execution materializes a pinned Git archive into a private
21//! remote root and deliberately does not run this ambient dependency planner.
22//! Mutation inside that private archive would be a separate defect and is not
23//! evidence that the ambient checkout changed; conversely, archive-only checks
24//! cannot prove this checkout-level invariant.
25//!
26//! A separate manifest-derived materialization closure covers every local path
27//! that Cargo may need under another feature, target, or test selection. Keeping
28//! that closure separate preserves Cargo's cycle-free active execution graph
29//! while still making inactive optional cycles available to remote workers.
30//!
31//! Every discovered path is normalized through [`PathTopologyPolicy`] to enforce
32//! canonical-root safety and stable path identity.
33
34use serde::{Deserialize, Serialize};
35use std::collections::{BTreeMap, BTreeSet};
36use std::fmt;
37use std::path::{Path, PathBuf};
38use std::process::Command;
39
40use crate::{PathNormalizationErrorKind, PathTopologyPolicy, normalize_project_path_with_policy};
41
42/// Deterministic graph of local Cargo path dependencies.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct CargoPathDependencyGraph {
45    /// Canonical path to the entry manifest used for resolution.
46    pub entry_manifest_path: PathBuf,
47    /// Canonical workspace root when entrypoint is a workspace manifest.
48    pub workspace_root: Option<PathBuf>,
49    /// Canonical root packages used as traversal roots (sorted).
50    pub root_packages: Vec<PathBuf>,
51    /// Reachable local packages in deterministic order.
52    pub packages: Vec<CargoPathDependencyPackage>,
53    /// Reachable path-dependency edges in deterministic order.
54    pub edges: Vec<CargoPathDependencyEdge>,
55}
56
57/// Deterministic, cycle-tolerant closure of local Cargo packages that must be
58/// materialized on a remote worker.
59///
60/// Unlike [`CargoPathDependencyGraph`], this closure includes inactive optional,
61/// development, build, target-specific, workspace-inherited, and path-patched
62/// edges. Its edges are evidence, not an execution DAG, and may contain cycles.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct CargoPathMaterializationClosure {
65    /// Canonical path to the entry manifest used for resolution.
66    pub entry_manifest_path: PathBuf,
67    /// Canonical workspace root when entrypoint is a workspace manifest.
68    pub workspace_root: Option<PathBuf>,
69    /// Canonical root packages used as traversal roots (sorted).
70    pub root_packages: Vec<PathBuf>,
71    /// Every reachable local package in deterministic order.
72    pub packages: Vec<CargoPathDependencyPackage>,
73    /// Every reachable path-dependency edge in deterministic order.
74    /// Cycles are retained and do not make the closure invalid.
75    pub edges: Vec<CargoPathDependencyEdge>,
76}
77
78/// One package node in the resolved path-dependency graph.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct CargoPathDependencyPackage {
81    /// Canonical package root directory.
82    pub package_root: PathBuf,
83    /// Canonical package manifest path.
84    pub manifest_path: PathBuf,
85    /// Package name (best effort, deterministic fallback if missing).
86    pub package_name: String,
87    /// Whether this package is a workspace member/root.
88    pub workspace_member: bool,
89}
90
91/// One directed path-dependency edge.
92#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
93pub struct CargoPathDependencyEdge {
94    /// Canonical source package root.
95    pub from: PathBuf,
96    /// Canonical dependency package root.
97    pub to: PathBuf,
98    /// Dependency key from manifest (`[dependencies] <name> = { path = ... }`).
99    pub dependency_name: String,
100}
101
102/// Taxonomy for local path-dependency resolution failures.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum CargoPathDependencyErrorKind {
105    ManifestParseFailure,
106    MissingPathDependency,
107    CyclicDependency,
108    PathPolicyViolation,
109    MetadataParseFailure,
110    MetadataInvocationFailure,
111}
112
113impl fmt::Display for CargoPathDependencyErrorKind {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        match self {
116            Self::ManifestParseFailure => write!(f, "manifest parse failure"),
117            Self::MissingPathDependency => write!(f, "missing path dependency"),
118            Self::CyclicDependency => write!(f, "cyclic path dependency"),
119            Self::PathPolicyViolation => write!(f, "path policy violation"),
120            Self::MetadataParseFailure => write!(f, "metadata parse failure"),
121            Self::MetadataInvocationFailure => write!(f, "metadata invocation failure"),
122        }
123    }
124}
125
126/// Structured error with explicit diagnostics.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct CargoPathDependencyError {
129    kind: CargoPathDependencyErrorKind,
130    detail: String,
131    manifest_path: Option<Box<PathBuf>>,
132    dependency_name: Option<Box<str>>,
133    dependency_path: Option<Box<PathBuf>>,
134    cycle: Vec<PathBuf>,
135    diagnostics: Vec<String>,
136}
137
138impl CargoPathDependencyError {
139    pub(crate) fn new(kind: CargoPathDependencyErrorKind, detail: impl Into<String>) -> Self {
140        Self {
141            kind,
142            detail: detail.into(),
143            manifest_path: None,
144            dependency_name: None,
145            dependency_path: None,
146            cycle: Vec::new(),
147            diagnostics: Vec::new(),
148        }
149    }
150
151    pub(crate) fn with_manifest_path(mut self, manifest_path: impl Into<PathBuf>) -> Self {
152        self.manifest_path = Some(Box::new(manifest_path.into()));
153        self
154    }
155
156    pub(crate) fn with_dependency_name(mut self, dependency_name: impl Into<String>) -> Self {
157        self.dependency_name = Some(dependency_name.into().into_boxed_str());
158        self
159    }
160
161    pub(crate) fn with_dependency_path(mut self, dependency_path: impl Into<PathBuf>) -> Self {
162        self.dependency_path = Some(Box::new(dependency_path.into()));
163        self
164    }
165
166    fn with_cycle(mut self, cycle: Vec<PathBuf>) -> Self {
167        self.cycle = cycle;
168        self
169    }
170
171    fn with_diagnostic(mut self, diagnostic: impl Into<String>) -> Self {
172        self.diagnostics.push(diagnostic.into());
173        self
174    }
175
176    fn with_diagnostics<I>(mut self, diagnostics: I) -> Self
177    where
178        I: IntoIterator,
179        I::Item: Into<String>,
180    {
181        self.diagnostics
182            .extend(diagnostics.into_iter().map(Into::into));
183        self
184    }
185
186    fn push_diagnostic(&mut self, diagnostic: impl Into<String>) {
187        self.diagnostics.push(diagnostic.into());
188    }
189
190    /// Error category.
191    pub fn kind(&self) -> &CargoPathDependencyErrorKind {
192        &self.kind
193    }
194
195    /// Human-readable detail.
196    pub fn detail(&self) -> &str {
197        &self.detail
198    }
199
200    /// Manifest path associated with the error when available.
201    pub fn manifest_path(&self) -> Option<&Path> {
202        self.manifest_path.as_deref().map(PathBuf::as_path)
203    }
204
205    /// Dependency key associated with the error when available.
206    pub fn dependency_name(&self) -> Option<&str> {
207        self.dependency_name.as_deref()
208    }
209
210    /// Dependency path associated with the error when available.
211    pub fn dependency_path(&self) -> Option<&Path> {
212        self.dependency_path.as_deref().map(PathBuf::as_path)
213    }
214
215    /// Cycle path for cyclic failures (ordered, includes repeated terminal node).
216    pub fn cycle(&self) -> &[PathBuf] {
217        &self.cycle
218    }
219
220    /// Structured diagnostic lines.
221    pub fn diagnostics(&self) -> &[String] {
222        &self.diagnostics
223    }
224}
225
226impl fmt::Display for CargoPathDependencyError {
227    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
228        write!(f, "{}: {}", self.kind, self.detail)?;
229        if let Some(manifest_path) = &self.manifest_path {
230            write!(f, " (manifest: {})", manifest_path.display())?;
231        }
232        if let Some(dependency_name) = &self.dependency_name {
233            write!(f, " (dependency: {dependency_name})")?;
234        }
235        if let Some(dependency_path) = &self.dependency_path {
236            write!(f, " (path: {})", dependency_path.display())?;
237        }
238        Ok(())
239    }
240}
241
242impl std::error::Error for CargoPathDependencyError {}
243
244/// One entry in a complete, repository-relative selected source inventory.
245///
246/// Retain text for every `Cargo.toml` and `.cargo/config[.toml]`; other regular
247/// files need only their presence. Symlink text is its selected target, never a
248/// target read from the ambient filesystem.
249#[derive(Debug, Clone, PartialEq, Eq)]
250pub enum SelectedCargoEntry {
251    File(Option<String>),
252    Symlink(String),
253}
254
255/// Resolve a CLI-selected path without collapsing `..` across symlinks.
256///
257/// Inventory structure and symlinks are validated, but metadata text is not
258/// required: loaders use this before hydrating selected manifests/configs.
259/// Absolute and missing paths are refused without filesystem I/O. Call
260/// [`validate_selected_cargo_tree`] after hydration to validate Cargo references.
261pub fn validate_selected_cargo_path(
262    entries: &BTreeMap<PathBuf, SelectedCargoEntry>,
263    path: &Path,
264) -> Result<PathBuf, CargoPathDependencyError> {
265    let tree = SelectedCargoTree::from_inventory(entries);
266    tree.check_inventory()?;
267    let raw = path.to_str().ok_or_else(|| {
268        CargoPathDependencyError::new(
269            CargoPathDependencyErrorKind::PathPolicyViolation,
270            "selected Cargo path is not UTF-8",
271        )
272        .with_dependency_path(path)
273    })?;
274    tree.resolve(Path::new(""), raw).map_err(|detail| {
275        CargoPathDependencyError::new(CargoPathDependencyErrorKind::PathPolicyViolation, detail)
276            .with_dependency_path(path)
277    })
278}
279
280/// Check an inline Cargo configuration override against selected source bytes.
281///
282/// `base` is its repository-relative resolution directory (empty for the root),
283/// not a fabricated configuration filename. Existing selected configs are
284/// validated separately; no synthetic inventory entry can mask a symlink.
285pub fn validate_selected_cargo_config(
286    entries: &BTreeMap<PathBuf, SelectedCargoEntry>,
287    base: &Path,
288    contents: &str,
289) -> Result<(), CargoPathDependencyError> {
290    validate_selected_cargo_config_at(entries, Path::new("Cargo.toml"), base, contents)
291}
292
293/// Validate an inline override within a combined committed-source inventory.
294///
295/// `primary_manifest` identifies the selected Cargo entrypoint, while `base`
296/// identifies the configuration's resolution directory within the container.
297/// Every selected manifest/config is validated before applying the override.
298pub fn validate_selected_cargo_config_at(
299    entries: &BTreeMap<PathBuf, SelectedCargoEntry>,
300    primary_manifest: &Path,
301    base: &Path,
302    contents: &str,
303) -> Result<(), CargoPathDependencyError> {
304    validate_selected_cargo_tree_at(entries, primary_manifest)?;
305    let resolved_base = validate_selected_cargo_path(
306        entries,
307        if base.as_os_str().is_empty() {
308            Path::new(".")
309        } else {
310            base
311        },
312    )?;
313    let mut tree = SelectedCargoTree::from_inventory(entries);
314    if !tree.directories.contains(&resolved_base) {
315        return Err(CargoPathDependencyError::new(
316            CargoPathDependencyErrorKind::PathPolicyViolation,
317            "Cargo configuration base is not a selected directory",
318        )
319        .with_dependency_path(base));
320    }
321    let document = toml::from_str::<toml::Table>(contents).map_err(|error| {
322        CargoPathDependencyError::new(
323            CargoPathDependencyErrorKind::ManifestParseFailure,
324            format!("invalid inline Cargo configuration: {error}"),
325        )
326    })?;
327    tree.config_at(
328        Path::new("<command-line-config>"),
329        &resolved_base,
330        &document,
331    );
332    if tree.diagnostics.is_empty() {
333        Ok(())
334    } else {
335        Err(CargoPathDependencyError::new(
336            CargoPathDependencyErrorKind::PathPolicyViolation,
337            format!(
338                "inline Cargo configuration is not contained: {}",
339                tree.diagnostics.join("; ")
340            ),
341        )
342        .with_diagnostics(tree.diagnostics))
343    }
344}
345
346/// Refuse Cargo source references outside an explicitly selected source tree.
347///
348/// This is deliberately conservative: every selected manifest/config and every
349/// symlink is checked, including inactive dependencies and fixture manifests.
350/// It performs no filesystem reads or Cargo invocations. It does not inspect
351/// command-line overrides, worker/home configuration, or arbitrary file reads
352/// performed by build scripts; callers must admit those separately. External
353/// roots are refused here, not silently substituted with ambient worker copies.
354pub fn validate_selected_cargo_tree(
355    entries: &BTreeMap<PathBuf, SelectedCargoEntry>,
356) -> Result<(), CargoPathDependencyError> {
357    validate_selected_cargo_tree_at(entries, Path::new("Cargo.toml"))
358}
359
360/// Validate a selected Cargo entrypoint inside a combined source container.
361///
362/// For example, `app/Cargo.toml` may reference `../dep` when `dep/Cargo.toml`
363/// is also selected. The container boundary, not the primary repository's
364/// boundary, limits resolution. All selected metadata and symlinks retain the
365/// same conservative checks as [`validate_selected_cargo_tree`]. The caller
366/// must bind and materialize the complete inventory; this API reads no ambient
367/// files and does not stage external repositories itself.
368pub fn validate_selected_cargo_tree_at(
369    entries: &BTreeMap<PathBuf, SelectedCargoEntry>,
370    primary_manifest: &Path,
371) -> Result<(), CargoPathDependencyError> {
372    let mut tree = SelectedCargoTree::from_inventory(entries);
373    let primary = primary_manifest
374        .to_str()
375        .ok_or_else(|| "primary manifest path is not UTF-8".to_string())
376        .and_then(|raw| {
377            if primary_manifest
378                .file_name()
379                .is_none_or(|name| name != "Cargo.toml")
380            {
381                return Err("primary manifest must be named Cargo.toml".to_string());
382            }
383            let resolved = tree.resolve(Path::new(""), raw)?;
384            if !matches!(
385                entries.get(&resolved),
386                Some(SelectedCargoEntry::File(Some(_)))
387            ) {
388                return Err("selected primary manifest text is unavailable".to_string());
389            }
390            Ok(())
391        });
392    if let Err(error) = primary {
393        tree.diagnostics.push(format!(
394            "selected primary {}: {error}; ambient ancestor discovery is not admitted",
395            primary_manifest.display()
396        ));
397    }
398    for path in entries.keys() {
399        let is_manifest = path.file_name().is_some_and(|name| name == "Cargo.toml");
400        let is_config = path
401            .parent()
402            .is_some_and(|parent| parent.file_name().is_some_and(|name| name == ".cargo"))
403            && path
404                .file_name()
405                .is_some_and(|name| name == "config" || name == "config.toml");
406        if !is_manifest && !is_config {
407            continue;
408        }
409        let contents = tree
410            .resolve(Path::new(""), &path.to_string_lossy())
411            .and_then(|resolved| match entries.get(&resolved) {
412                Some(SelectedCargoEntry::File(Some(text))) => Ok(text),
413                _ => Err("selected manifest/config text is unavailable".to_string()),
414            });
415        match contents.and_then(|text| {
416            toml::from_str::<toml::Table>(text)
417                .map_err(|error| format!("invalid selected TOML: {error}"))
418        }) {
419            Ok(document) => {
420                if is_manifest {
421                    tree.manifest(path, &document);
422                } else {
423                    tree.config(path, &document);
424                }
425            }
426            Err(error) => tree
427                .diagnostics
428                .push(format!("{}: {error}", path.display())),
429        }
430    }
431    // A selected `.cargo` directory symlink has no `.cargo/config` inventory
432    // entry of its own. Cargo still reads that alias, relative to this project,
433    // so validate its resolved text without substituting the target's parent.
434    for (path, entry) in entries {
435        if !matches!(entry, SelectedCargoEntry::Symlink(_))
436            || !path.file_name().is_some_and(|name| name == ".cargo")
437        {
438            continue;
439        }
440        for filename in ["config", "config.toml"] {
441            let logical = path.join(filename);
442            let Ok(resolved) = tree.resolve(Path::new(""), &logical.to_string_lossy()) else {
443                // Missing config is allowed; malformed/dangling links themselves
444                // have already been checked across the entire inventory.
445                continue;
446            };
447            let parsed = match entries.get(&resolved) {
448                Some(SelectedCargoEntry::File(Some(text))) => toml::from_str::<toml::Table>(text)
449                    .map_err(|error| format!("invalid selected TOML: {error}")),
450                _ => Err("selected config alias text is unavailable".to_string()),
451            };
452            match parsed {
453                Ok(document) => tree.config(&logical, &document),
454                Err(error) => tree
455                    .diagnostics
456                    .push(format!("{}: {error}", logical.display())),
457            }
458        }
459    }
460    if tree.diagnostics.is_empty() {
461        Ok(())
462    } else {
463        tree.diagnostics.sort();
464        tree.diagnostics.dedup();
465        Err(CargoPathDependencyError::new(
466            CargoPathDependencyErrorKind::PathPolicyViolation,
467            format!(
468                "selected Cargo source tree is not contained: {}",
469                tree.diagnostics.join("; ")
470            ),
471        )
472        .with_diagnostics(tree.diagnostics))
473    }
474}
475
476struct SelectedCargoTree<'a> {
477    entries: &'a BTreeMap<PathBuf, SelectedCargoEntry>,
478    directories: BTreeSet<PathBuf>,
479    diagnostics: Vec<String>,
480}
481
482#[derive(Clone, Copy)]
483enum SelectedCargoPathKind {
484    Package,
485    File,
486    Directory,
487}
488
489impl<'a> SelectedCargoTree<'a> {
490    fn from_inventory(entries: &'a BTreeMap<PathBuf, SelectedCargoEntry>) -> Self {
491        let mut tree = Self {
492            entries,
493            directories: BTreeSet::from([PathBuf::new()]),
494            diagnostics: Vec::new(),
495        };
496        for path in entries.keys() {
497            // On Unix a colon in a leaf filename is inert (for example a
498            // checked-in source excerpt named `model.rs:6150-6230`). Cargo
499            // references and symlink targets still go through `components`,
500            // which refuses colons. A colon in a directory can affect Cargo's
501            // ancestor search, and Windows cannot materialize these names.
502            let colon_in_directory = path
503                .parent()
504                .is_some_and(|parent| parent.to_string_lossy().contains(':'));
505            if path.as_os_str().is_empty()
506                || path.to_str().is_none()
507                || path
508                    .components()
509                    .any(|part| !matches!(part, std::path::Component::Normal(_)))
510                || path.to_string_lossy().contains('\\')
511                || (path.to_string_lossy().contains(':') && (cfg!(windows) || colon_in_directory))
512            {
513                tree.diagnostics.push(format!(
514                    "invalid selected inventory path: {}",
515                    path.display()
516                ));
517                continue;
518            }
519            for parent in path.ancestors().skip(1) {
520                tree.directories.insert(parent.to_path_buf());
521            }
522        }
523        for (path, entry) in entries {
524            if tree.directories.contains(path) {
525                tree.diagnostics.push(format!(
526                    "selected file also has descendants: {}",
527                    path.display()
528                ));
529            }
530            if matches!(entry, SelectedCargoEntry::Symlink(_))
531                && let Err(error) = tree.resolve(Path::new(""), &path.to_string_lossy())
532            {
533                tree.diagnostics
534                    .push(format!("{}: symlink: {error}", path.display()));
535            }
536        }
537        tree
538    }
539
540    fn check_inventory(&self) -> Result<(), CargoPathDependencyError> {
541        if self.diagnostics.is_empty() {
542            Ok(())
543        } else {
544            Err(CargoPathDependencyError::new(
545                CargoPathDependencyErrorKind::PathPolicyViolation,
546                format!(
547                    "invalid selected inventory: {}",
548                    self.diagnostics.join("; ")
549                ),
550            )
551            .with_diagnostics(self.diagnostics.iter().cloned()))
552        }
553    }
554
555    fn components(raw: &str) -> Result<std::collections::VecDeque<String>, String> {
556        if raw.is_empty()
557            || raw.starts_with('/')
558            || raw.contains(['\\', ':'])
559            || raw.chars().any(char::is_control)
560        {
561            return Err(format!("unsupported or absolute path {raw:?}"));
562        }
563        Ok(raw.split('/').map(ToOwned::to_owned).collect())
564    }
565
566    fn resolve(&self, base: &Path, raw: &str) -> Result<PathBuf, String> {
567        if !self.directories.contains(base) {
568            return Err(format!("non-directory resolution base {}", base.display()));
569        }
570        let mut pending = Self::components(raw)?;
571        let mut resolved = base.to_path_buf();
572        let mut links = 0;
573        while let Some(component) = pending.pop_front() {
574            match component.as_str() {
575                "" | "." => continue,
576                ".." => {
577                    if !resolved.pop() {
578                        return Err(format!("path {raw:?} escapes the selected root"));
579                    }
580                }
581                _ => resolved.push(&component),
582            }
583            match self.entries.get(&resolved) {
584                Some(SelectedCargoEntry::Symlink(target)) => {
585                    links += 1;
586                    if links > 64 {
587                        return Err(format!(
588                            "symlink cycle or expansion limit at {}",
589                            resolved.display()
590                        ));
591                    }
592                    let mut target_components = Self::components(target)?;
593                    target_components.append(&mut pending);
594                    pending = target_components;
595                    resolved.pop();
596                }
597                Some(SelectedCargoEntry::File(_)) if !pending.is_empty() => {
598                    return Err(format!(
599                        "non-directory path component {}",
600                        resolved.display()
601                    ));
602                }
603                Some(SelectedCargoEntry::File(_)) => {}
604                None if self.directories.contains(&resolved) => {}
605                None => return Err(format!("missing selected path {}", resolved.display())),
606            }
607        }
608        Ok(resolved)
609    }
610
611    fn reference(
612        &mut self,
613        declaring: &Path,
614        base: &Path,
615        key: &str,
616        value: &toml::Value,
617        kind: SelectedCargoPathKind,
618    ) {
619        let result = value
620            .as_str()
621            .ok_or_else(|| "path must be a string".to_string())
622            .and_then(|raw| {
623                let resolved = self.resolve(base, raw)?;
624                match kind {
625                    SelectedCargoPathKind::Package => {
626                        let manifest = self.resolve(&resolved, "Cargo.toml")?;
627                        if !matches!(
628                            self.entries.get(&manifest),
629                            Some(SelectedCargoEntry::File(Some(_)))
630                        ) {
631                            return Err(format!(
632                                "selected package manifest text missing: {}",
633                                manifest.display()
634                            ));
635                        }
636                    }
637                    SelectedCargoPathKind::File => {
638                        if !matches!(
639                            self.entries.get(&resolved),
640                            Some(SelectedCargoEntry::File(_))
641                        ) {
642                            return Err(format!(
643                                "selected regular file missing: {}",
644                                resolved.display()
645                            ));
646                        }
647                    }
648                    SelectedCargoPathKind::Directory => {
649                        if !self.directories.contains(&resolved) {
650                            return Err(format!(
651                                "selected directory missing: {}",
652                                resolved.display()
653                            ));
654                        }
655                    }
656                }
657                Ok(())
658            });
659        if let Err(error) = result {
660            self.diagnostics
661                .push(format!("{}: {key}={value}: {error}", declaring.display()));
662        }
663    }
664
665    fn dependencies(&mut self, declaring: &Path, base: &Path, key: &str, table: &toml::Table) {
666        for (name, dependency) in table {
667            if let Some(path) = dependency.as_table().and_then(|value| value.get("path")) {
668                self.reference(
669                    declaring,
670                    base,
671                    &format!("{key}.{name}.path"),
672                    path,
673                    SelectedCargoPathKind::Package,
674                );
675            }
676        }
677    }
678
679    fn dependency_tables(
680        &mut self,
681        declaring: &Path,
682        base: &Path,
683        prefix: &str,
684        table: &toml::Table,
685    ) {
686        for key in ["dependencies", "dev-dependencies", "build-dependencies"] {
687            if let Some(dependencies) = table.get(key).and_then(toml::Value::as_table) {
688                self.dependencies(declaring, base, &format!("{prefix}{key}"), dependencies);
689            }
690        }
691    }
692
693    fn overrides(&mut self, declaring: &Path, base: &Path, table: &toml::Table) {
694        if let Some(patches) = table.get("patch").and_then(toml::Value::as_table) {
695            for (registry, patch) in patches {
696                if let Some(patch) = patch.as_table() {
697                    self.dependencies(declaring, base, &format!("patch.{registry}"), patch);
698                }
699            }
700        }
701        if let Some(replacements) = table.get("replace").and_then(toml::Value::as_table) {
702            self.dependencies(declaring, base, "replace", replacements);
703        }
704    }
705
706    fn members(&mut self, declaring: &Path, base: &Path, key: &str, value: &toml::Value) {
707        let Some(members) = value.as_array() else {
708            self.diagnostics
709                .push(format!("{}: {key} must be an array", declaring.display()));
710            return;
711        };
712        for member in members {
713            let Some(pattern) = member.as_str() else {
714                self.diagnostics.push(format!(
715                    "{}: {key} member must be a string",
716                    declaring.display()
717                ));
718                continue;
719            };
720            if !contains_glob(pattern) {
721                self.reference(declaring, base, key, member, SelectedCargoPathKind::Package);
722                continue;
723            }
724            let result = self.member_pattern(base, pattern);
725            match result {
726                Ok(paths) => {
727                    for path in paths {
728                        self.reference(
729                            declaring,
730                            &path,
731                            key,
732                            &toml::Value::String(".".into()),
733                            SelectedCargoPathKind::Package,
734                        );
735                    }
736                }
737                Err(error) => self.diagnostics.push(format!(
738                    "{}: {key}={pattern:?}: {error}",
739                    declaring.display()
740                )),
741            }
742        }
743    }
744
745    fn member_pattern(&self, base: &Path, pattern: &str) -> Result<BTreeSet<PathBuf>, String> {
746        if pattern.contains('[') || pattern.contains("**") {
747            return Err("unsupported workspace glob; explicit selected members required".into());
748        }
749        let components = Self::components(pattern)?;
750        let mut candidates = BTreeSet::from([base.to_path_buf()]);
751        for component in components {
752            let mut next = BTreeSet::new();
753            for candidate in candidates {
754                if contains_wildcard(&component) {
755                    for path in self.directories.iter().chain(self.entries.keys()) {
756                        if path.parent() == Some(candidate.as_path())
757                            && let Some(name) = path.file_name().and_then(|name| name.to_str())
758                            && wildcard_match(&component, name)
759                        {
760                            next.insert(self.resolve(&candidate, name)?);
761                        }
762                    }
763                } else if component.is_empty() {
764                    next.insert(candidate);
765                } else {
766                    next.insert(self.resolve(&candidate, &component)?);
767                }
768            }
769            candidates = next;
770        }
771        if candidates.is_empty() {
772            return Err("workspace glob matches no selected members".into());
773        }
774        Ok(candidates)
775    }
776
777    fn manifest(&mut self, declaring: &Path, table: &toml::Table) {
778        let base = declaring.parent().unwrap_or(Path::new(""));
779        if Self::inherits_workspace(&toml::Value::Table(table.clone())) {
780            let workspace = self.selected_workspace(declaring, table);
781            if let Err(error) = workspace {
782                self.diagnostics.push(format!(
783                    "{}: workspace inheritance: {error}",
784                    declaring.display()
785                ));
786            }
787        }
788        self.dependency_tables(declaring, base, "", table);
789        self.overrides(declaring, base, table);
790        if let Some(targets) = table.get("target").and_then(toml::Value::as_table) {
791            for (name, target) in targets {
792                if let Some(target) = target.as_table() {
793                    self.dependency_tables(declaring, base, &format!("target.{name}."), target);
794                }
795            }
796        }
797        if let Some(workspace) = table.get("workspace").and_then(toml::Value::as_table) {
798            self.dependency_tables(declaring, base, "workspace.", workspace);
799            for key in ["members", "default-members"] {
800                if let Some(value) = workspace.get(key) {
801                    self.members(declaring, base, &format!("workspace.{key}"), value);
802                }
803            }
804        }
805        if let Some(package) = table.get("package").and_then(toml::Value::as_table) {
806            if let Some(workspace) = package.get("workspace") {
807                self.reference(
808                    declaring,
809                    base,
810                    "package.workspace",
811                    workspace,
812                    SelectedCargoPathKind::Package,
813                );
814            }
815            if let Some(build) = package.get("build") {
816                match build {
817                    toml::Value::Boolean(false) => {}
818                    toml::Value::Boolean(true) => self.reference(
819                        declaring,
820                        base,
821                        "package.build",
822                        &toml::Value::String("build.rs".into()),
823                        SelectedCargoPathKind::File,
824                    ),
825                    _ => self.reference(
826                        declaring,
827                        base,
828                        "package.build",
829                        build,
830                        SelectedCargoPathKind::File,
831                    ),
832                }
833            }
834        }
835        if let Some(path) = table
836            .get("lib")
837            .and_then(toml::Value::as_table)
838            .and_then(|lib| lib.get("path"))
839        {
840            self.reference(
841                declaring,
842                base,
843                "lib.path",
844                path,
845                SelectedCargoPathKind::File,
846            );
847        }
848        for kind in ["bin", "test", "bench", "example"] {
849            if let Some(targets) = table.get(kind).and_then(toml::Value::as_array) {
850                for target in targets {
851                    if let Some(path) = target.as_table().and_then(|target| target.get("path")) {
852                        self.reference(
853                            declaring,
854                            base,
855                            &format!("{kind}.path"),
856                            path,
857                            SelectedCargoPathKind::File,
858                        );
859                    }
860                }
861            }
862        }
863    }
864
865    fn inherits_workspace(value: &toml::Value) -> bool {
866        match value {
867            toml::Value::Table(table) => {
868                table.get("workspace").and_then(toml::Value::as_bool) == Some(true)
869                    || table.values().any(Self::inherits_workspace)
870            }
871            toml::Value::Array(values) => values.iter().any(Self::inherits_workspace),
872            _ => false,
873        }
874    }
875
876    fn selected_workspace(&self, declaring: &Path, table: &toml::Table) -> Result<(), String> {
877        let base = declaring.parent().unwrap_or(Path::new(""));
878        let explicit = table
879            .get("package")
880            .and_then(toml::Value::as_table)
881            .and_then(|package| package.get("workspace"));
882        let candidates = if let Some(explicit) = explicit {
883            let path = explicit
884                .as_str()
885                .ok_or_else(|| "package.workspace must be a path string".to_string())?;
886            vec![self.resolve(base, path)?]
887        } else {
888            base.ancestors().map(Path::to_path_buf).collect()
889        };
890        for candidate in candidates {
891            let manifest = candidate.join("Cargo.toml");
892            if !self.entries.contains_key(&manifest) {
893                continue;
894            }
895            let resolved = self.resolve(&candidate, "Cargo.toml")?;
896            let Some(SelectedCargoEntry::File(Some(text))) = self.entries.get(&resolved) else {
897                return Err(format!(
898                    "selected workspace text missing: {}",
899                    manifest.display()
900                ));
901            };
902            let document = toml::from_str::<toml::Table>(text)
903                .map_err(|error| format!("invalid workspace {}: {error}", manifest.display()))?;
904            if document.get("workspace").is_some_and(toml::Value::is_table) {
905                return Ok(());
906            }
907        }
908        Err("no selected enclosing workspace; ambient ancestor lookup is not admitted".into())
909    }
910
911    fn config(&mut self, declaring: &Path, table: &toml::Table) {
912        let base = declaring
913            .parent()
914            .and_then(Path::parent)
915            .unwrap_or(Path::new(""));
916        self.config_at(declaring, base, table);
917    }
918
919    fn config_at(&mut self, declaring: &Path, base: &Path, table: &toml::Table) {
920        self.overrides(declaring, base, table);
921        if table.contains_key("include") {
922            self.diagnostics.push(format!(
923                "{}: config includes are not admitted in a selected tree",
924                declaring.display()
925            ));
926        }
927        if let Some(paths) = table.get("paths") {
928            if let Some(paths) = paths.as_array() {
929                for path in paths {
930                    self.reference(
931                        declaring,
932                        base,
933                        "paths",
934                        path,
935                        SelectedCargoPathKind::Package,
936                    );
937                }
938            } else {
939                self.diagnostics.push(format!(
940                    "{}: config paths must be an array",
941                    declaring.display()
942                ));
943            }
944        }
945        if let Some(sources) = table.get("source").and_then(toml::Value::as_table) {
946            for (name, source) in sources {
947                for key in ["directory", "local-registry"] {
948                    if let Some(path) = source.as_table().and_then(|source| source.get(key)) {
949                        self.reference(
950                            declaring,
951                            base,
952                            &format!("source.{name}.{key}"),
953                            path,
954                            SelectedCargoPathKind::Directory,
955                        );
956                    }
957                }
958            }
959        }
960    }
961}
962
963/// Resolve local Cargo path dependencies for `entrypoint` using the default topology policy.
964pub fn resolve_cargo_path_dependency_graph(
965    entrypoint: &Path,
966) -> Result<CargoPathDependencyGraph, CargoPathDependencyError> {
967    resolve_cargo_path_dependency_graph_with_policy(entrypoint, &PathTopologyPolicy::default())
968}
969
970/// Resolve local Cargo path dependencies for `entrypoint` with explicit topology policy.
971pub fn resolve_cargo_path_dependency_graph_with_policy(
972    entrypoint: &Path,
973    policy: &PathTopologyPolicy,
974) -> Result<CargoPathDependencyGraph, CargoPathDependencyError> {
975    resolve_cargo_path_dependency_graph_with_policy_and_provider(
976        entrypoint,
977        policy,
978        invoke_cargo_metadata,
979    )
980}
981
982/// Resolve every local Cargo package that may need source materialization using
983/// the default topology policy.
984pub fn resolve_cargo_path_materialization_closure(
985    entrypoint: &Path,
986) -> Result<CargoPathMaterializationClosure, CargoPathDependencyError> {
987    resolve_cargo_path_materialization_closure_with_policy(
988        entrypoint,
989        &PathTopologyPolicy::default(),
990    )
991}
992
993/// Resolve every local Cargo package that may need source materialization using
994/// an explicit topology policy.
995///
996/// Resolution is manifest-only by design: it never imports user-home Cargo
997/// configuration, and it includes inactive edges that `cargo metadata` omits.
998pub fn resolve_cargo_path_materialization_closure_with_policy(
999    entrypoint: &Path,
1000    policy: &PathTopologyPolicy,
1001) -> Result<CargoPathMaterializationClosure, CargoPathDependencyError> {
1002    let entry_manifest = resolve_entry_manifest(entrypoint, policy)?;
1003    resolve_materialization_from_manifests(&entry_manifest, policy)
1004}
1005
1006fn resolve_cargo_path_dependency_graph_with_policy_and_provider<F>(
1007    entrypoint: &Path,
1008    policy: &PathTopologyPolicy,
1009    metadata_provider: F,
1010) -> Result<CargoPathDependencyGraph, CargoPathDependencyError>
1011where
1012    F: Fn(&Path) -> Result<String, CargoPathDependencyError>,
1013{
1014    resolve_cargo_path_dependency_graph_with_cargo_home(
1015        entrypoint,
1016        policy,
1017        metadata_provider,
1018        default_cargo_home().as_deref(),
1019    )
1020}
1021
1022/// The Cargo home `cargo metadata` resolves against: `$CARGO_HOME`, else
1023/// `~/.cargo` (cargo's own default), canonicalized when it exists so it
1024/// compares against canonical package roots.
1025fn default_cargo_home() -> Option<PathBuf> {
1026    let cargo_home = std::env::var_os("CARGO_HOME")
1027        .filter(|value| !value.is_empty())
1028        .map(PathBuf::from)
1029        .or_else(|| dirs::home_dir().map(|home| home.join(".cargo")))?;
1030    Some(cargo_home.canonicalize().unwrap_or(cargo_home))
1031}
1032
1033fn resolve_cargo_path_dependency_graph_with_cargo_home<F>(
1034    entrypoint: &Path,
1035    policy: &PathTopologyPolicy,
1036    metadata_provider: F,
1037    cargo_home: Option<&Path>,
1038) -> Result<CargoPathDependencyGraph, CargoPathDependencyError>
1039where
1040    F: Fn(&Path) -> Result<String, CargoPathDependencyError>,
1041{
1042    let entry_manifest = resolve_entry_manifest(entrypoint, policy)?;
1043
1044    match resolve_from_metadata(&entry_manifest, policy, &metadata_provider, cargo_home) {
1045        Ok(graph) => Ok(graph),
1046        Err(metadata_error) => match resolve_from_manifest_fallback(&entry_manifest, policy) {
1047            Ok(graph) => Ok(graph),
1048            Err(mut error) => {
1049                error.push_diagnostic(format!("metadata phase failure: {metadata_error}"));
1050                if !metadata_error.diagnostics().is_empty() {
1051                    error.push_diagnostic("metadata diagnostics follow".to_string());
1052                    error
1053                        .diagnostics
1054                        .extend(metadata_error.diagnostics().iter().cloned());
1055                }
1056                Err(error)
1057            }
1058        },
1059    }
1060}
1061
1062fn resolve_entry_manifest(
1063    entrypoint: &Path,
1064    policy: &PathTopologyPolicy,
1065) -> Result<PathBuf, CargoPathDependencyError> {
1066    let maybe_manifest = entrypoint
1067        .file_name()
1068        .is_some_and(|name| name == "Cargo.toml");
1069    let root_candidate = if maybe_manifest {
1070        entrypoint.parent().ok_or_else(|| {
1071            CargoPathDependencyError::new(
1072                CargoPathDependencyErrorKind::ManifestParseFailure,
1073                format!("invalid manifest path: {}", entrypoint.display()),
1074            )
1075        })?
1076    } else {
1077        entrypoint
1078    };
1079
1080    let normalized_root = normalize_path_for_policy(
1081        root_candidate,
1082        policy,
1083        None,
1084        None,
1085        "resolve entrypoint root",
1086    )?;
1087    let manifest_path = normalized_root.join("Cargo.toml");
1088    if !manifest_path.is_file() {
1089        return Err(CargoPathDependencyError::new(
1090            CargoPathDependencyErrorKind::ManifestParseFailure,
1091            format!("manifest does not exist: {}", manifest_path.display()),
1092        )
1093        .with_manifest_path(manifest_path));
1094    }
1095
1096    Ok(manifest_path)
1097}
1098
1099/// Maximum time to wait for `cargo metadata` before killing the process.
1100const CARGO_METADATA_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
1101
1102fn invoke_cargo_metadata(manifest_path: &Path) -> Result<String, CargoPathDependencyError> {
1103    use std::io::Read;
1104
1105    // `--locked` is load-bearing (bd-rch-pinned-metadata-lock-ytkda): without
1106    // it, `cargo metadata` creates or rewrites the project's `Cargo.lock` as
1107    // a side effect, mutating the very checkout RCH is only supposed to
1108    // read. With `--locked`, a valid lockfile succeeds byte-for-byte
1109    // untouched, while a stale or missing lockfile makes cargo REFUSE
1110    // without writing — and that refusal flows into the manifest-only
1111    // fallback, which is non-mutating by construction.
1112    let mut child = Command::new("cargo")
1113        .arg("metadata")
1114        .arg("--locked")
1115        .arg("--format-version")
1116        .arg("1")
1117        .arg("--manifest-path")
1118        .arg(manifest_path)
1119        .stdout(std::process::Stdio::piped())
1120        .stderr(std::process::Stdio::piped())
1121        .spawn()
1122        .map_err(|error| {
1123            CargoPathDependencyError::new(
1124                CargoPathDependencyErrorKind::MetadataInvocationFailure,
1125                format!("failed to spawn cargo metadata: {error}"),
1126            )
1127            .with_manifest_path(manifest_path)
1128        })?;
1129
1130    // Drain stdout and stderr concurrently in background threads.
1131    //
1132    // The previous implementation polled `try_wait` without ever reading from
1133    // the pipes, which deadlocked for any project whose `cargo metadata` JSON
1134    // exceeded the OS pipe capacity (~64KB on Linux). The child blocked on
1135    // `write()` to a full pipe, never exited, and we always hit the 30s
1136    // timeout. The RCH workspace itself emits ~2.5MB of metadata, so this
1137    // function timed out for *its own callers*. Concurrent draining keeps
1138    // the pipe flowing so the child can complete normally; on timeout we
1139    // still kill+reap below, after which the drain threads see EOF and exit.
1140    let mut stdout_pipe = child
1141        .stdout
1142        .take()
1143        .expect("child spawned with piped stdout");
1144    let mut stderr_pipe = child
1145        .stderr
1146        .take()
1147        .expect("child spawned with piped stderr");
1148
1149    let stdout_thread = std::thread::spawn(move || -> std::io::Result<Vec<u8>> {
1150        let mut buf = Vec::new();
1151        stdout_pipe.read_to_end(&mut buf)?;
1152        Ok(buf)
1153    });
1154    let stderr_thread = std::thread::spawn(move || -> std::io::Result<Vec<u8>> {
1155        let mut buf = Vec::new();
1156        stderr_pipe.read_to_end(&mut buf)?;
1157        Ok(buf)
1158    });
1159
1160    // Poll `try_wait` with a small sleep. On timeout we kill the child and
1161    // reap it so the process + OS resources don't leak.
1162    let deadline = std::time::Instant::now() + CARGO_METADATA_TIMEOUT;
1163    loop {
1164        match child.try_wait() {
1165            Ok(Some(_)) => break,
1166            Ok(None) => {
1167                if std::time::Instant::now() >= deadline {
1168                    let kill_err = child.kill().err();
1169                    let reap_deadline =
1170                        std::time::Instant::now() + std::time::Duration::from_secs(2);
1171                    let reaped = loop {
1172                        match child.try_wait() {
1173                            Ok(Some(_)) => break true,
1174                            Ok(None) => {
1175                                if std::time::Instant::now() >= reap_deadline {
1176                                    break false;
1177                                }
1178                                std::thread::sleep(std::time::Duration::from_millis(20));
1179                            }
1180                            Err(_) => break false,
1181                        }
1182                    };
1183                    // Reap the drain threads now that the pipes are closed
1184                    // (kill closed them, or EOF arrived). Discard their output
1185                    // — we have a timeout error to return either way.
1186                    let _ = stdout_thread.join();
1187                    let _ = stderr_thread.join();
1188
1189                    let mut err = CargoPathDependencyError::new(
1190                        CargoPathDependencyErrorKind::MetadataInvocationFailure,
1191                        format!(
1192                            "cargo metadata timed out after {}s for {}",
1193                            CARGO_METADATA_TIMEOUT.as_secs(),
1194                            manifest_path.display()
1195                        ),
1196                    )
1197                    .with_manifest_path(manifest_path)
1198                    .with_diagnostic(format!("timeout_secs={}", CARGO_METADATA_TIMEOUT.as_secs()));
1199                    if let Some(ke) = kill_err {
1200                        err = err.with_diagnostic(format!("kill_failed={ke}"));
1201                    }
1202                    if !reaped {
1203                        err = err.with_diagnostic("reap_failed=true".to_string());
1204                    }
1205                    return Err(err);
1206                }
1207                std::thread::sleep(std::time::Duration::from_millis(50));
1208            }
1209            Err(error) => {
1210                let _ = child.kill();
1211                let _ = child.wait();
1212                let _ = stdout_thread.join();
1213                let _ = stderr_thread.join();
1214                return Err(CargoPathDependencyError::new(
1215                    CargoPathDependencyErrorKind::MetadataInvocationFailure,
1216                    format!("failed to poll cargo metadata: {error}"),
1217                )
1218                .with_manifest_path(manifest_path));
1219            }
1220        }
1221    }
1222
1223    let status = child.wait().map_err(|error| {
1224        CargoPathDependencyError::new(
1225            CargoPathDependencyErrorKind::MetadataInvocationFailure,
1226            format!("failed to wait on cargo metadata: {error}"),
1227        )
1228        .with_manifest_path(manifest_path)
1229    })?;
1230
1231    let stdout_bytes = stdout_thread
1232        .join()
1233        .map_err(|_| {
1234            CargoPathDependencyError::new(
1235                CargoPathDependencyErrorKind::MetadataInvocationFailure,
1236                "stdout drain thread panicked".to_string(),
1237            )
1238            .with_manifest_path(manifest_path)
1239        })?
1240        .map_err(|error| {
1241            CargoPathDependencyError::new(
1242                CargoPathDependencyErrorKind::MetadataInvocationFailure,
1243                format!("failed to read cargo metadata stdout: {error}"),
1244            )
1245            .with_manifest_path(manifest_path)
1246        })?;
1247    let stderr_bytes = stderr_thread
1248        .join()
1249        .map_err(|_| {
1250            CargoPathDependencyError::new(
1251                CargoPathDependencyErrorKind::MetadataInvocationFailure,
1252                "stderr drain thread panicked".to_string(),
1253            )
1254            .with_manifest_path(manifest_path)
1255        })?
1256        .map_err(|error| {
1257            CargoPathDependencyError::new(
1258                CargoPathDependencyErrorKind::MetadataInvocationFailure,
1259                format!("failed to read cargo metadata stderr: {error}"),
1260            )
1261            .with_manifest_path(manifest_path)
1262        })?;
1263
1264    if !status.success() {
1265        let stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
1266        let detail = if stderr.trim().is_empty() {
1267            format!("cargo metadata exited with status {status}")
1268        } else {
1269            stderr.trim().to_string()
1270        };
1271        return Err(CargoPathDependencyError::new(
1272            CargoPathDependencyErrorKind::MetadataInvocationFailure,
1273            detail,
1274        )
1275        .with_manifest_path(manifest_path));
1276    }
1277
1278    String::from_utf8(stdout_bytes).map_err(|error| {
1279        CargoPathDependencyError::new(
1280            CargoPathDependencyErrorKind::MetadataParseFailure,
1281            format!("metadata stdout is not valid UTF-8: {error}"),
1282        )
1283        .with_manifest_path(manifest_path)
1284    })
1285}
1286
1287#[derive(Debug, Default)]
1288struct PartialGraph {
1289    workspace_root: Option<PathBuf>,
1290    roots: BTreeSet<PathBuf>,
1291    packages: BTreeMap<PathBuf, PackageRecord>,
1292    adjacency: BTreeMap<PathBuf, BTreeSet<EdgeTail>>,
1293}
1294
1295impl PartialGraph {
1296    fn add_root(&mut self, root: PathBuf) {
1297        self.roots.insert(root);
1298    }
1299
1300    fn add_package(
1301        &mut self,
1302        package_root: PathBuf,
1303        manifest_path: PathBuf,
1304        package_name: String,
1305        workspace_member: bool,
1306    ) {
1307        self.packages
1308            .entry(package_root.clone())
1309            .and_modify(|existing| {
1310                if existing.package_name == default_package_name(&package_root)
1311                    && package_name != existing.package_name
1312                {
1313                    existing.package_name = package_name.clone();
1314                }
1315                existing.workspace_member |= workspace_member;
1316                existing.manifest_path = manifest_path.clone();
1317            })
1318            .or_insert(PackageRecord {
1319                manifest_path,
1320                package_name,
1321                workspace_member,
1322            });
1323        self.adjacency.entry(package_root).or_default();
1324    }
1325
1326    fn add_edge(&mut self, from: PathBuf, to: PathBuf, dependency_name: String) {
1327        self.adjacency.entry(from).or_default().insert(EdgeTail {
1328            to,
1329            dependency_name,
1330        });
1331    }
1332}
1333
1334#[derive(Debug, Clone)]
1335struct PackageRecord {
1336    manifest_path: PathBuf,
1337    package_name: String,
1338    workspace_member: bool,
1339}
1340
1341#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1342struct EdgeTail {
1343    to: PathBuf,
1344    dependency_name: String,
1345}
1346
1347#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1348struct EdgeRecord {
1349    from: PathBuf,
1350    to: PathBuf,
1351    dependency_name: String,
1352}
1353
1354#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1355enum VisitState {
1356    Visiting,
1357    Visited,
1358}
1359
1360fn finalize_graph(
1361    entry_manifest_path: PathBuf,
1362    partial: PartialGraph,
1363) -> Result<CargoPathDependencyGraph, CargoPathDependencyError> {
1364    let mut states: BTreeMap<PathBuf, VisitState> = BTreeMap::new();
1365    let mut stack: Vec<PathBuf> = Vec::new();
1366    let mut reachable_nodes: BTreeSet<PathBuf> = BTreeSet::new();
1367    let mut reachable_edges: BTreeSet<EdgeRecord> = BTreeSet::new();
1368
1369    for root in &partial.roots {
1370        traverse_for_reachable(
1371            root,
1372            &partial.adjacency,
1373            &mut states,
1374            &mut stack,
1375            &mut reachable_nodes,
1376            &mut reachable_edges,
1377        )?;
1378    }
1379
1380    let packages = reachable_nodes
1381        .iter()
1382        .map(|root| {
1383            if let Some(package) = partial.packages.get(root) {
1384                CargoPathDependencyPackage {
1385                    package_root: root.clone(),
1386                    manifest_path: package.manifest_path.clone(),
1387                    package_name: package.package_name.clone(),
1388                    workspace_member: package.workspace_member,
1389                }
1390            } else {
1391                CargoPathDependencyPackage {
1392                    package_root: root.clone(),
1393                    manifest_path: root.join("Cargo.toml"),
1394                    package_name: default_package_name(root),
1395                    workspace_member: partial.roots.contains(root),
1396                }
1397            }
1398        })
1399        .collect::<Vec<_>>();
1400
1401    let edges = reachable_edges
1402        .into_iter()
1403        .map(|edge| CargoPathDependencyEdge {
1404            from: edge.from,
1405            to: edge.to,
1406            dependency_name: edge.dependency_name,
1407        })
1408        .collect::<Vec<_>>();
1409
1410    Ok(CargoPathDependencyGraph {
1411        entry_manifest_path,
1412        workspace_root: partial.workspace_root,
1413        root_packages: partial.roots.into_iter().collect(),
1414        packages,
1415        edges,
1416    })
1417}
1418
1419fn traverse_for_reachable(
1420    node: &Path,
1421    adjacency: &BTreeMap<PathBuf, BTreeSet<EdgeTail>>,
1422    states: &mut BTreeMap<PathBuf, VisitState>,
1423    stack: &mut Vec<PathBuf>,
1424    reachable_nodes: &mut BTreeSet<PathBuf>,
1425    reachable_edges: &mut BTreeSet<EdgeRecord>,
1426) -> Result<(), CargoPathDependencyError> {
1427    match states.get(node).copied() {
1428        Some(VisitState::Visited) => return Ok(()),
1429        Some(VisitState::Visiting) => {
1430            let cycle = cycle_from_stack(stack, node);
1431            return Err(CargoPathDependencyError::new(
1432                CargoPathDependencyErrorKind::CyclicDependency,
1433                "cycle detected while traversing dependency graph",
1434            )
1435            .with_cycle(cycle));
1436        }
1437        None => {}
1438    }
1439
1440    states.insert(node.to_path_buf(), VisitState::Visiting);
1441    stack.push(node.to_path_buf());
1442    reachable_nodes.insert(node.to_path_buf());
1443
1444    if let Some(edges) = adjacency.get(node) {
1445        for edge in edges {
1446            reachable_edges.insert(EdgeRecord {
1447                from: node.to_path_buf(),
1448                to: edge.to.clone(),
1449                dependency_name: edge.dependency_name.clone(),
1450            });
1451            if states.get(&edge.to) == Some(&VisitState::Visiting) {
1452                let cycle = cycle_from_stack(stack, &edge.to);
1453                return Err(CargoPathDependencyError::new(
1454                    CargoPathDependencyErrorKind::CyclicDependency,
1455                    format!(
1456                        "cycle detected between {} and {}",
1457                        node.display(),
1458                        edge.to.display()
1459                    ),
1460                )
1461                .with_cycle(cycle));
1462            }
1463            traverse_for_reachable(
1464                &edge.to,
1465                adjacency,
1466                states,
1467                stack,
1468                reachable_nodes,
1469                reachable_edges,
1470            )?;
1471        }
1472    }
1473
1474    stack.pop();
1475    states.insert(node.to_path_buf(), VisitState::Visited);
1476    Ok(())
1477}
1478
1479fn cycle_from_stack(stack: &[PathBuf], terminal: &Path) -> Vec<PathBuf> {
1480    if let Some(position) = stack.iter().position(|entry| entry == terminal) {
1481        let mut cycle = stack[position..].to_vec();
1482        cycle.push(terminal.to_path_buf());
1483        cycle
1484    } else {
1485        vec![terminal.to_path_buf()]
1486    }
1487}
1488
1489fn default_package_name(root: &Path) -> String {
1490    root.file_name()
1491        .and_then(|segment| segment.to_str())
1492        .map(ToOwned::to_owned)
1493        .unwrap_or_else(|| root.display().to_string())
1494}
1495
1496fn allowed_dependency_roots(policy: &PathTopologyPolicy) -> Vec<PathBuf> {
1497    let mut roots = vec![
1498        policy.canonical_root().to_path_buf(),
1499        policy.alias_root().to_path_buf(),
1500    ];
1501
1502    for candidate in [policy.canonical_root(), policy.alias_root()] {
1503        if let Ok(resolved) = std::fs::canonicalize(candidate)
1504            && !roots.iter().any(|root| root == &resolved)
1505        {
1506            roots.push(resolved);
1507        }
1508    }
1509
1510    roots
1511}
1512
1513fn validate_absolute_dependency_scope(
1514    dependency_candidate: &Path,
1515    policy: &PathTopologyPolicy,
1516    manifest_path: &Path,
1517    dependency_name: &str,
1518    context: &str,
1519) -> Result<(), CargoPathDependencyError> {
1520    if !dependency_candidate.is_absolute() {
1521        return Ok(());
1522    }
1523    if normalize_project_path_with_policy(dependency_candidate, policy).is_ok() {
1524        return Ok(());
1525    }
1526
1527    let allowed_roots = allowed_dependency_roots(policy);
1528    if allowed_roots
1529        .iter()
1530        .any(|root| dependency_candidate.starts_with(root))
1531    {
1532        return Ok(());
1533    }
1534
1535    let mut error = CargoPathDependencyError::new(
1536        CargoPathDependencyErrorKind::PathPolicyViolation,
1537        format!("{context}: {}", dependency_candidate.display()),
1538    )
1539    .with_manifest_path(manifest_path)
1540    .with_dependency_name(dependency_name.to_string())
1541    .with_dependency_path(dependency_candidate.to_path_buf());
1542
1543    for root in &allowed_roots {
1544        error = error.with_diagnostic(format!("allowed root: {}", root.display()));
1545    }
1546
1547    Err(error)
1548}
1549
1550fn normalize_path_for_policy(
1551    path: &Path,
1552    policy: &PathTopologyPolicy,
1553    manifest_path: Option<&Path>,
1554    dependency_name: Option<&str>,
1555    context: &str,
1556) -> Result<PathBuf, CargoPathDependencyError> {
1557    normalize_project_path_with_policy(path, policy)
1558        .map(|normalized| normalized.canonical_path().to_path_buf())
1559        .map_err(|error| {
1560            let mapped_kind = if error.kind() == &PathNormalizationErrorKind::InputResolveFailed {
1561                CargoPathDependencyErrorKind::MissingPathDependency
1562            } else {
1563                CargoPathDependencyErrorKind::PathPolicyViolation
1564            };
1565            let mut mapped = CargoPathDependencyError::new(
1566                mapped_kind,
1567                format!("{context}: {} ({})", error.kind(), error.detail()),
1568            )
1569            .with_diagnostic(format!("normalization_error_kind={}", error.kind()))
1570            .with_diagnostic(format!("normalization_detail={}", error.detail()))
1571            .with_diagnostics(error.decision_trace().iter().map(ToString::to_string));
1572
1573            if let Some(manifest_path) = manifest_path {
1574                mapped = mapped.with_manifest_path(manifest_path);
1575            }
1576            if let Some(dependency_name) = dependency_name {
1577                mapped = mapped.with_dependency_name(dependency_name.to_string());
1578            }
1579            mapped.with_dependency_path(path)
1580        })
1581}
1582
1583#[derive(Debug, Deserialize)]
1584struct MetadataDocument {
1585    #[serde(default)]
1586    packages: Vec<MetadataPackage>,
1587    #[serde(default)]
1588    workspace_members: Vec<String>,
1589    workspace_root: Option<String>,
1590    resolve: Option<MetadataResolve>,
1591}
1592
1593#[derive(Debug, Deserialize)]
1594struct MetadataResolve {
1595    root: Option<String>,
1596    #[serde(default)]
1597    nodes: Vec<MetadataResolveNode>,
1598}
1599
1600#[derive(Debug, Deserialize)]
1601struct MetadataResolveNode {
1602    id: String,
1603    #[serde(default)]
1604    deps: Vec<MetadataResolveDep>,
1605}
1606
1607#[derive(Debug, Deserialize)]
1608struct MetadataResolveDep {
1609    name: String,
1610    pkg: String,
1611    #[serde(default)]
1612    dep_kinds: Vec<MetadataResolveDepKind>,
1613}
1614
1615#[derive(Debug, Deserialize)]
1616struct MetadataResolveDepKind {
1617    kind: Option<String>,
1618}
1619
1620impl MetadataResolveDep {
1621    fn is_runtime_relevant(&self) -> bool {
1622        if self.dep_kinds.is_empty() {
1623            return true;
1624        }
1625
1626        self.dep_kinds
1627            .iter()
1628            .any(|dep_kind| dep_kind.kind.as_deref() != Some("dev"))
1629    }
1630}
1631
1632#[derive(Debug, Deserialize)]
1633struct MetadataPackage {
1634    id: String,
1635    name: String,
1636    manifest_path: String,
1637    /// `null` for local path packages; `registry+…` / `git+…` otherwise.
1638    /// Vendored (`[source] directory`) crates keep their original source id.
1639    #[serde(default)]
1640    source: Option<String>,
1641    #[serde(default)]
1642    dependencies: Vec<MetadataDependency>,
1643}
1644
1645#[derive(Debug, Deserialize)]
1646struct MetadataDependency {
1647    name: String,
1648    path: Option<String>,
1649    #[serde(default)]
1650    optional: bool,
1651}
1652
1653#[derive(Debug)]
1654struct MetadataPackageRecord {
1655    package_id: String,
1656    package_root: PathBuf,
1657    manifest_path: PathBuf,
1658    dependencies: Vec<MetadataDependency>,
1659}
1660
1661fn resolve_from_metadata<F>(
1662    entry_manifest_path: &Path,
1663    policy: &PathTopologyPolicy,
1664    metadata_provider: &F,
1665    cargo_home: Option<&Path>,
1666) -> Result<CargoPathDependencyGraph, CargoPathDependencyError>
1667where
1668    F: Fn(&Path) -> Result<String, CargoPathDependencyError>,
1669{
1670    let raw_metadata = metadata_provider(entry_manifest_path)?;
1671    let metadata = serde_json::from_str::<MetadataDocument>(&raw_metadata).map_err(|error| {
1672        CargoPathDependencyError::new(
1673            CargoPathDependencyErrorKind::MetadataParseFailure,
1674            format!("failed to parse cargo metadata JSON: {error}"),
1675        )
1676        .with_manifest_path(entry_manifest_path)
1677    })?;
1678
1679    let mut partial = PartialGraph::default();
1680    let workspace_member_ids = metadata
1681        .workspace_members
1682        .iter()
1683        .cloned()
1684        .collect::<BTreeSet<_>>();
1685    let mut id_to_root: BTreeMap<String, PathBuf> = BTreeMap::new();
1686    let mut package_records: Vec<MetadataPackageRecord> = Vec::new();
1687
1688    for package in metadata.packages {
1689        let manifest_path = PathBuf::from(&package.manifest_path);
1690        let manifest_dir = manifest_path.parent().ok_or_else(|| {
1691            CargoPathDependencyError::new(
1692                CargoPathDependencyErrorKind::MetadataParseFailure,
1693                format!(
1694                    "metadata package has invalid manifest path: {}",
1695                    manifest_path.display()
1696                ),
1697            )
1698            .with_manifest_path(entry_manifest_path)
1699        })?;
1700        let workspace_member = workspace_member_ids.contains(&package.id);
1701        let package_root = match normalize_path_for_policy(
1702            manifest_dir,
1703            policy,
1704            Some(entry_manifest_path),
1705            None,
1706            "normalize metadata package root",
1707        ) {
1708            Ok(root) => root,
1709            Err(error)
1710                if !workspace_member
1711                    && error.kind() == &CargoPathDependencyErrorKind::PathPolicyViolation =>
1712            {
1713                continue;
1714            }
1715            Err(error) => return Err(error),
1716        };
1717        // Registry and git packages unpacked into Cargo's own caches are not
1718        // local inputs: the worker resolves them through its own isolated
1719        // CARGO_HOME. They only look local when CARGO_HOME happens to sit under
1720        // the topology root (e.g. `canonical_root = $HOME`), so exclude them
1721        // explicitly (issue #77). Sourced packages elsewhere — vendored
1722        // `[source] directory` trees — stay in the closure.
1723        if !workspace_member
1724            && package.source.is_some()
1725            && cargo_home.is_some_and(|cargo_home| package_root.starts_with(cargo_home))
1726        {
1727            continue;
1728        }
1729        let canonical_manifest_path = package_root.join("Cargo.toml");
1730
1731        partial.add_package(
1732            package_root.clone(),
1733            canonical_manifest_path.clone(),
1734            package.name.clone(),
1735            workspace_member,
1736        );
1737
1738        id_to_root.insert(package.id.clone(), package_root.clone());
1739        package_records.push(MetadataPackageRecord {
1740            package_id: package.id,
1741            package_root,
1742            manifest_path: canonical_manifest_path,
1743            dependencies: package.dependencies,
1744        });
1745    }
1746
1747    let resolve_nodes = metadata
1748        .resolve
1749        .as_ref()
1750        .map(|resolve| {
1751            resolve
1752                .nodes
1753                .iter()
1754                .map(|node| (node.id.clone(), node))
1755                .collect::<BTreeMap<_, _>>()
1756        })
1757        .unwrap_or_default();
1758
1759    for package in &package_records {
1760        if let Some(node) = resolve_nodes.get(&package.package_id) {
1761            for dependency in &node.deps {
1762                if !dependency.is_runtime_relevant() {
1763                    continue;
1764                }
1765                let Some(dependency_root) = id_to_root.get(&dependency.pkg) else {
1766                    continue;
1767                };
1768                let dependency_manifest = dependency_root.join("Cargo.toml");
1769                partial.add_package(
1770                    dependency_root.clone(),
1771                    dependency_manifest,
1772                    dependency.name.clone(),
1773                    false,
1774                );
1775                partial.add_edge(
1776                    package.package_root.clone(),
1777                    dependency_root.clone(),
1778                    dependency.name.clone(),
1779                );
1780            }
1781            continue;
1782        }
1783
1784        for dependency in &package.dependencies {
1785            if dependency.optional {
1786                continue;
1787            }
1788            let Some(raw_path) = dependency.path.as_deref() else {
1789                continue;
1790            };
1791
1792            let dependency_candidate =
1793                resolve_dependency_candidate(&package.package_root, raw_path);
1794            validate_absolute_dependency_scope(
1795                &dependency_candidate,
1796                policy,
1797                &package.manifest_path,
1798                &dependency.name,
1799                "metadata dependency path policy violation",
1800            )?;
1801            if !dependency_candidate.exists() {
1802                return Err(CargoPathDependencyError::new(
1803                    CargoPathDependencyErrorKind::MissingPathDependency,
1804                    format!(
1805                        "metadata dependency path does not exist: {}",
1806                        dependency_candidate.display()
1807                    ),
1808                )
1809                .with_manifest_path(package.manifest_path.clone())
1810                .with_dependency_name(dependency.name.clone())
1811                .with_dependency_path(dependency_candidate));
1812            }
1813
1814            let dependency_root = normalize_path_for_policy(
1815                &dependency_candidate,
1816                policy,
1817                Some(&package.manifest_path),
1818                Some(&dependency.name),
1819                "normalize metadata dependency path",
1820            )?;
1821            let dependency_manifest = dependency_root.join("Cargo.toml");
1822            if !dependency_manifest.is_file() {
1823                return Err(CargoPathDependencyError::new(
1824                    CargoPathDependencyErrorKind::MissingPathDependency,
1825                    format!(
1826                        "dependency manifest is missing: {}",
1827                        dependency_manifest.display()
1828                    ),
1829                )
1830                .with_manifest_path(package.manifest_path.clone())
1831                .with_dependency_name(dependency.name.clone())
1832                .with_dependency_path(dependency_manifest));
1833            }
1834
1835            partial.add_package(
1836                dependency_root.clone(),
1837                dependency_manifest,
1838                dependency.name.clone(),
1839                false,
1840            );
1841            partial.add_edge(
1842                package.package_root.clone(),
1843                dependency_root,
1844                dependency.name.clone(),
1845            );
1846        }
1847    }
1848
1849    if let Some(workspace_root) = metadata.workspace_root {
1850        let workspace_root = normalize_path_for_policy(
1851            Path::new(&workspace_root),
1852            policy,
1853            Some(entry_manifest_path),
1854            None,
1855            "normalize metadata workspace root",
1856        )?;
1857        partial.workspace_root = Some(workspace_root);
1858    }
1859
1860    if !workspace_member_ids.is_empty() {
1861        for workspace_id in &workspace_member_ids {
1862            let root = id_to_root.get(workspace_id).ok_or_else(|| {
1863                CargoPathDependencyError::new(
1864                    CargoPathDependencyErrorKind::MetadataParseFailure,
1865                    format!("workspace member id missing from package list: {workspace_id}"),
1866                )
1867                .with_manifest_path(entry_manifest_path)
1868            })?;
1869            partial.add_root(root.clone());
1870            if let Some(package) = partial.packages.get_mut(root) {
1871                package.workspace_member = true;
1872            }
1873        }
1874    } else if let Some(resolve_root) = metadata.resolve.and_then(|resolve| resolve.root) {
1875        let root = id_to_root.get(&resolve_root).ok_or_else(|| {
1876            CargoPathDependencyError::new(
1877                CargoPathDependencyErrorKind::MetadataParseFailure,
1878                format!("resolve root id missing from package list: {resolve_root}"),
1879            )
1880            .with_manifest_path(entry_manifest_path)
1881        })?;
1882        partial.add_root(root.clone());
1883    } else {
1884        let entry_root = entry_manifest_path.parent().ok_or_else(|| {
1885            CargoPathDependencyError::new(
1886                CargoPathDependencyErrorKind::MetadataParseFailure,
1887                format!(
1888                    "entry manifest has no parent: {}",
1889                    entry_manifest_path.display()
1890                ),
1891            )
1892            .with_manifest_path(entry_manifest_path)
1893        })?;
1894        partial.add_root(entry_root.to_path_buf());
1895    }
1896
1897    for root in partial.roots.clone() {
1898        partial
1899            .packages
1900            .entry(root.clone())
1901            .or_insert(PackageRecord {
1902                manifest_path: root.join("Cargo.toml"),
1903                package_name: default_package_name(&root),
1904                workspace_member: true,
1905            });
1906    }
1907
1908    finalize_graph(entry_manifest_path.to_path_buf(), partial)
1909}
1910
1911#[derive(Debug, Clone)]
1912struct ManifestDocument {
1913    package_name: Option<String>,
1914    has_workspace: bool,
1915    workspace_members: Vec<String>,
1916    workspace_dependency_names: BTreeSet<String>,
1917    workspace_path_dependencies: BTreeMap<String, String>,
1918    patch_path_dependencies: BTreeMap<String, String>,
1919    path_dependencies: Vec<ManifestDependency>,
1920    materialization_path_dependencies: Vec<ManifestDependency>,
1921}
1922
1923#[derive(Debug, Clone)]
1924struct ManifestDependency {
1925    dependency_name: String,
1926    dependency_path: Option<String>,
1927    uses_workspace_inheritance: bool,
1928}
1929
1930fn resolve_from_manifest_fallback(
1931    entry_manifest_path: &Path,
1932    policy: &PathTopologyPolicy,
1933) -> Result<CargoPathDependencyGraph, CargoPathDependencyError> {
1934    let entry_manifest = read_manifest_document(entry_manifest_path)?;
1935    let entry_root = entry_manifest_path.parent().ok_or_else(|| {
1936        CargoPathDependencyError::new(
1937            CargoPathDependencyErrorKind::ManifestParseFailure,
1938            format!(
1939                "entry manifest has no parent: {}",
1940                entry_manifest_path.display()
1941            ),
1942        )
1943        .with_manifest_path(entry_manifest_path)
1944    })?;
1945    let entry_root = normalize_path_for_policy(
1946        entry_root,
1947        policy,
1948        Some(entry_manifest_path),
1949        None,
1950        "normalize fallback entry root",
1951    )?;
1952
1953    let mut partial = PartialGraph::default();
1954    if entry_manifest.has_workspace {
1955        partial.workspace_root = Some(entry_root.clone());
1956    }
1957    let workspace_path_dependencies = entry_manifest.workspace_path_dependencies.clone();
1958    let patch_path_dependencies = entry_manifest.patch_path_dependencies.clone();
1959
1960    let workspace_member_manifests = expand_workspace_members(
1961        &entry_root,
1962        &entry_manifest.workspace_members,
1963        entry_manifest_path,
1964    )?;
1965    let workspace_member_set = workspace_member_manifests
1966        .iter()
1967        .cloned()
1968        .collect::<BTreeSet<_>>();
1969    let include_entry_manifest =
1970        entry_manifest.package_name.is_some() || workspace_member_set.is_empty();
1971
1972    let mut manifest_cache: BTreeMap<PathBuf, ManifestDocument> = BTreeMap::new();
1973    let mut states: BTreeMap<PathBuf, VisitState> = BTreeMap::new();
1974    let mut stack: Vec<PathBuf> = Vec::new();
1975
1976    for manifest_path in &workspace_member_set {
1977        visit_manifest_recursive(
1978            manifest_path,
1979            policy,
1980            &entry_root,
1981            &workspace_member_set,
1982            &workspace_path_dependencies,
1983            &patch_path_dependencies,
1984            true,
1985            &mut partial,
1986            &mut manifest_cache,
1987            &mut states,
1988            &mut stack,
1989        )?;
1990    }
1991    if include_entry_manifest {
1992        visit_manifest_recursive(
1993            entry_manifest_path,
1994            policy,
1995            &entry_root,
1996            &workspace_member_set,
1997            &workspace_path_dependencies,
1998            &patch_path_dependencies,
1999            true,
2000            &mut partial,
2001            &mut manifest_cache,
2002            &mut states,
2003            &mut stack,
2004        )?;
2005    }
2006
2007    finalize_graph(entry_manifest_path.to_path_buf(), partial)
2008}
2009
2010fn resolve_materialization_from_manifests(
2011    entry_manifest_path: &Path,
2012    policy: &PathTopologyPolicy,
2013) -> Result<CargoPathMaterializationClosure, CargoPathDependencyError> {
2014    let entry_manifest = read_manifest_document(entry_manifest_path)?;
2015    let entry_root = entry_manifest_path.parent().ok_or_else(|| {
2016        CargoPathDependencyError::new(
2017            CargoPathDependencyErrorKind::ManifestParseFailure,
2018            format!(
2019                "entry manifest has no parent: {}",
2020                entry_manifest_path.display()
2021            ),
2022        )
2023        .with_manifest_path(entry_manifest_path)
2024    })?;
2025    let entry_root = normalize_path_for_policy(
2026        entry_root,
2027        policy,
2028        Some(entry_manifest_path),
2029        None,
2030        "normalize materialization entry root",
2031    )?;
2032
2033    let mut partial = PartialGraph::default();
2034    if entry_manifest.has_workspace {
2035        partial.workspace_root = Some(entry_root.clone());
2036    }
2037    let workspace_path_dependencies = entry_manifest.workspace_path_dependencies.clone();
2038    let patch_path_dependencies = entry_manifest.patch_path_dependencies.clone();
2039    let workspace_member_manifests = expand_workspace_members(
2040        &entry_root,
2041        &entry_manifest.workspace_members,
2042        entry_manifest_path,
2043    )?;
2044    let workspace_member_set = workspace_member_manifests
2045        .iter()
2046        .cloned()
2047        .collect::<BTreeSet<_>>();
2048    let include_entry_manifest =
2049        entry_manifest.package_name.is_some() || workspace_member_set.is_empty();
2050
2051    let mut manifest_cache = BTreeMap::from([(entry_manifest_path.to_path_buf(), entry_manifest)]);
2052    let mut visited = BTreeSet::new();
2053
2054    for manifest_path in &workspace_member_set {
2055        visit_manifest_for_materialization(
2056            manifest_path,
2057            policy,
2058            &entry_root,
2059            &workspace_member_set,
2060            &workspace_path_dependencies,
2061            &patch_path_dependencies,
2062            true,
2063            &mut partial,
2064            &mut manifest_cache,
2065            &mut visited,
2066        )?;
2067    }
2068    if include_entry_manifest {
2069        visit_manifest_for_materialization(
2070            entry_manifest_path,
2071            policy,
2072            &entry_root,
2073            &workspace_member_set,
2074            &workspace_path_dependencies,
2075            &patch_path_dependencies,
2076            true,
2077            &mut partial,
2078            &mut manifest_cache,
2079            &mut visited,
2080        )?;
2081    }
2082
2083    for (dependency_name, raw_dependency_path) in workspace_path_dependencies
2084        .iter()
2085        .chain(patch_path_dependencies.iter())
2086    {
2087        let dependency_candidate = resolve_dependency_candidate(&entry_root, raw_dependency_path);
2088        let (_, dependency_manifest) = resolve_materialization_dependency_manifest(
2089            &dependency_candidate,
2090            policy,
2091            entry_manifest_path,
2092            dependency_name,
2093        )?;
2094        visit_manifest_for_materialization(
2095            &dependency_manifest,
2096            policy,
2097            &entry_root,
2098            &workspace_member_set,
2099            &workspace_path_dependencies,
2100            &patch_path_dependencies,
2101            false,
2102            &mut partial,
2103            &mut manifest_cache,
2104            &mut visited,
2105        )?;
2106    }
2107
2108    Ok(finalize_materialization_closure(
2109        entry_manifest_path.to_path_buf(),
2110        partial,
2111    ))
2112}
2113
2114#[allow(clippy::too_many_arguments)]
2115fn visit_manifest_for_materialization(
2116    manifest_path: &Path,
2117    policy: &PathTopologyPolicy,
2118    workspace_root: &Path,
2119    workspace_member_manifests: &BTreeSet<PathBuf>,
2120    workspace_path_dependencies: &BTreeMap<String, String>,
2121    patch_path_dependencies: &BTreeMap<String, String>,
2122    mark_workspace_member: bool,
2123    partial: &mut PartialGraph,
2124    manifest_cache: &mut BTreeMap<PathBuf, ManifestDocument>,
2125    visited: &mut BTreeSet<PathBuf>,
2126) -> Result<(), CargoPathDependencyError> {
2127    let manifest_root = manifest_path.parent().ok_or_else(|| {
2128        CargoPathDependencyError::new(
2129            CargoPathDependencyErrorKind::ManifestParseFailure,
2130            format!("manifest has no parent: {}", manifest_path.display()),
2131        )
2132        .with_manifest_path(manifest_path)
2133    })?;
2134    let package_root = normalize_path_for_policy(
2135        manifest_root,
2136        policy,
2137        Some(manifest_path),
2138        None,
2139        "normalize materialization package root",
2140    )?;
2141    let canonical_manifest = package_root.join("Cargo.toml");
2142    if !canonical_manifest.is_file() {
2143        return Err(CargoPathDependencyError::new(
2144            CargoPathDependencyErrorKind::ManifestParseFailure,
2145            format!("manifest file missing: {}", canonical_manifest.display()),
2146        )
2147        .with_manifest_path(canonical_manifest));
2148    }
2149
2150    if !visited.insert(package_root.clone()) {
2151        if mark_workspace_member {
2152            partial.add_root(package_root.clone());
2153            if let Some(package) = partial.packages.get_mut(&package_root) {
2154                package.workspace_member = true;
2155            }
2156        }
2157        return Ok(());
2158    }
2159
2160    let manifest = if let Some(cached) = manifest_cache.get(&canonical_manifest) {
2161        cached.clone()
2162    } else {
2163        let parsed = read_manifest_document(&canonical_manifest)?;
2164        manifest_cache.insert(canonical_manifest.clone(), parsed.clone());
2165        parsed
2166    };
2167
2168    let workspace_member =
2169        mark_workspace_member || workspace_member_manifests.contains(&canonical_manifest);
2170    partial.add_package(
2171        package_root.clone(),
2172        canonical_manifest.clone(),
2173        manifest
2174            .package_name
2175            .clone()
2176            .unwrap_or_else(|| default_package_name(&package_root)),
2177        workspace_member,
2178    );
2179    if workspace_member {
2180        partial.add_root(package_root.clone());
2181    }
2182
2183    for dependency in &manifest.materialization_path_dependencies {
2184        let Some(dependency_candidate) = resolve_manifest_dependency_candidate(
2185            &package_root,
2186            workspace_root,
2187            dependency,
2188            workspace_path_dependencies,
2189            patch_path_dependencies,
2190            &canonical_manifest,
2191        )?
2192        else {
2193            continue;
2194        };
2195        let (dependency_root, dependency_manifest) = resolve_materialization_dependency_manifest(
2196            &dependency_candidate,
2197            policy,
2198            &canonical_manifest,
2199            &dependency.dependency_name,
2200        )?;
2201
2202        partial.add_edge(
2203            package_root.clone(),
2204            dependency_root,
2205            dependency.dependency_name.clone(),
2206        );
2207        visit_manifest_for_materialization(
2208            &dependency_manifest,
2209            policy,
2210            workspace_root,
2211            workspace_member_manifests,
2212            workspace_path_dependencies,
2213            patch_path_dependencies,
2214            false,
2215            partial,
2216            manifest_cache,
2217            visited,
2218        )?;
2219    }
2220
2221    Ok(())
2222}
2223
2224fn resolve_materialization_dependency_manifest(
2225    dependency_candidate: &Path,
2226    policy: &PathTopologyPolicy,
2227    declaring_manifest: &Path,
2228    dependency_name: &str,
2229) -> Result<(PathBuf, PathBuf), CargoPathDependencyError> {
2230    validate_absolute_dependency_scope(
2231        dependency_candidate,
2232        policy,
2233        declaring_manifest,
2234        dependency_name,
2235        "materialization dependency path policy violation",
2236    )?;
2237    if !dependency_candidate.exists() {
2238        return Err(CargoPathDependencyError::new(
2239            CargoPathDependencyErrorKind::MissingPathDependency,
2240            format!(
2241                "materialization dependency path does not exist: {}",
2242                dependency_candidate.display()
2243            ),
2244        )
2245        .with_manifest_path(declaring_manifest)
2246        .with_dependency_name(dependency_name.to_string())
2247        .with_dependency_path(dependency_candidate));
2248    }
2249
2250    let dependency_root = normalize_path_for_policy(
2251        dependency_candidate,
2252        policy,
2253        Some(declaring_manifest),
2254        Some(dependency_name),
2255        "normalize materialization dependency path",
2256    )?;
2257    let dependency_manifest = dependency_root.join("Cargo.toml");
2258    if !dependency_manifest.is_file() {
2259        return Err(CargoPathDependencyError::new(
2260            CargoPathDependencyErrorKind::MissingPathDependency,
2261            format!(
2262                "materialization dependency manifest missing: {}",
2263                dependency_manifest.display()
2264            ),
2265        )
2266        .with_manifest_path(declaring_manifest)
2267        .with_dependency_name(dependency_name.to_string())
2268        .with_dependency_path(dependency_manifest.clone()));
2269    }
2270
2271    Ok((dependency_root, dependency_manifest))
2272}
2273
2274fn finalize_materialization_closure(
2275    entry_manifest_path: PathBuf,
2276    partial: PartialGraph,
2277) -> CargoPathMaterializationClosure {
2278    let packages = partial
2279        .packages
2280        .iter()
2281        .map(|(root, package)| CargoPathDependencyPackage {
2282            package_root: root.clone(),
2283            manifest_path: package.manifest_path.clone(),
2284            package_name: package.package_name.clone(),
2285            workspace_member: package.workspace_member,
2286        })
2287        .collect();
2288    let edges = partial
2289        .adjacency
2290        .iter()
2291        .flat_map(|(from, tails)| {
2292            tails.iter().map(|tail| CargoPathDependencyEdge {
2293                from: from.clone(),
2294                to: tail.to.clone(),
2295                dependency_name: tail.dependency_name.clone(),
2296            })
2297        })
2298        .collect();
2299
2300    CargoPathMaterializationClosure {
2301        entry_manifest_path,
2302        workspace_root: partial.workspace_root,
2303        root_packages: partial.roots.into_iter().collect(),
2304        packages,
2305        edges,
2306    }
2307}
2308
2309#[allow(clippy::too_many_arguments)]
2310fn visit_manifest_recursive(
2311    manifest_path: &Path,
2312    policy: &PathTopologyPolicy,
2313    workspace_root: &Path,
2314    workspace_member_manifests: &BTreeSet<PathBuf>,
2315    workspace_path_dependencies: &BTreeMap<String, String>,
2316    patch_path_dependencies: &BTreeMap<String, String>,
2317    mark_workspace_member: bool,
2318    partial: &mut PartialGraph,
2319    manifest_cache: &mut BTreeMap<PathBuf, ManifestDocument>,
2320    states: &mut BTreeMap<PathBuf, VisitState>,
2321    stack: &mut Vec<PathBuf>,
2322) -> Result<PathBuf, CargoPathDependencyError> {
2323    let manifest_root = manifest_path.parent().ok_or_else(|| {
2324        CargoPathDependencyError::new(
2325            CargoPathDependencyErrorKind::ManifestParseFailure,
2326            format!("manifest has no parent: {}", manifest_path.display()),
2327        )
2328        .with_manifest_path(manifest_path)
2329    })?;
2330    let package_root = normalize_path_for_policy(
2331        manifest_root,
2332        policy,
2333        Some(manifest_path),
2334        None,
2335        "normalize manifest package root",
2336    )?;
2337    let canonical_manifest = package_root.join("Cargo.toml");
2338    if !canonical_manifest.is_file() {
2339        return Err(CargoPathDependencyError::new(
2340            CargoPathDependencyErrorKind::ManifestParseFailure,
2341            format!("manifest file missing: {}", canonical_manifest.display()),
2342        )
2343        .with_manifest_path(canonical_manifest));
2344    }
2345
2346    if states.get(&package_root) == Some(&VisitState::Visiting) {
2347        let cycle = cycle_from_stack(stack, &package_root);
2348        return Err(CargoPathDependencyError::new(
2349            CargoPathDependencyErrorKind::CyclicDependency,
2350            format!(
2351                "cyclic path dependency detected at {}",
2352                package_root.display()
2353            ),
2354        )
2355        .with_cycle(cycle));
2356    }
2357    if states.get(&package_root) == Some(&VisitState::Visited) {
2358        if mark_workspace_member {
2359            partial.add_root(package_root.clone());
2360            if let Some(package) = partial.packages.get_mut(&package_root) {
2361                package.workspace_member = true;
2362            }
2363        }
2364        return Ok(package_root);
2365    }
2366
2367    states.insert(package_root.clone(), VisitState::Visiting);
2368    stack.push(package_root.clone());
2369
2370    let manifest = if let Some(cached) = manifest_cache.get(&canonical_manifest) {
2371        cached.clone()
2372    } else {
2373        let parsed = read_manifest_document(&canonical_manifest)?;
2374        manifest_cache.insert(canonical_manifest.clone(), parsed.clone());
2375        parsed
2376    };
2377
2378    let workspace_member =
2379        mark_workspace_member || workspace_member_manifests.contains(&canonical_manifest);
2380    partial.add_package(
2381        package_root.clone(),
2382        canonical_manifest.clone(),
2383        manifest
2384            .package_name
2385            .clone()
2386            .unwrap_or_else(|| default_package_name(&package_root)),
2387        workspace_member,
2388    );
2389    if workspace_member {
2390        partial.add_root(package_root.clone());
2391    }
2392
2393    for dependency in &manifest.path_dependencies {
2394        let Some(dependency_candidate) = resolve_manifest_dependency_candidate(
2395            &package_root,
2396            workspace_root,
2397            dependency,
2398            workspace_path_dependencies,
2399            patch_path_dependencies,
2400            &canonical_manifest,
2401        )?
2402        else {
2403            continue;
2404        };
2405        validate_absolute_dependency_scope(
2406            &dependency_candidate,
2407            policy,
2408            &canonical_manifest,
2409            &dependency.dependency_name,
2410            "manifest dependency path policy violation",
2411        )?;
2412        if !dependency_candidate.exists() {
2413            return Err(CargoPathDependencyError::new(
2414                CargoPathDependencyErrorKind::MissingPathDependency,
2415                format!(
2416                    "dependency path does not exist: {}",
2417                    dependency_candidate.display()
2418                ),
2419            )
2420            .with_manifest_path(canonical_manifest.clone())
2421            .with_dependency_name(dependency.dependency_name.clone())
2422            .with_dependency_path(dependency_candidate));
2423        }
2424
2425        let dependency_root = normalize_path_for_policy(
2426            &dependency_candidate,
2427            policy,
2428            Some(&canonical_manifest),
2429            Some(&dependency.dependency_name),
2430            "normalize manifest dependency path",
2431        )?;
2432        let dependency_manifest = dependency_root.join("Cargo.toml");
2433        if !dependency_manifest.is_file() {
2434            return Err(CargoPathDependencyError::new(
2435                CargoPathDependencyErrorKind::MissingPathDependency,
2436                format!(
2437                    "dependency manifest missing: {}",
2438                    dependency_manifest.display()
2439                ),
2440            )
2441            .with_manifest_path(canonical_manifest.clone())
2442            .with_dependency_name(dependency.dependency_name.clone())
2443            .with_dependency_path(dependency_manifest));
2444        }
2445
2446        partial.add_edge(
2447            package_root.clone(),
2448            dependency_root.clone(),
2449            dependency.dependency_name.clone(),
2450        );
2451        visit_manifest_recursive(
2452            &dependency_manifest,
2453            policy,
2454            workspace_root,
2455            workspace_member_manifests,
2456            workspace_path_dependencies,
2457            patch_path_dependencies,
2458            false,
2459            partial,
2460            manifest_cache,
2461            states,
2462            stack,
2463        )?;
2464    }
2465
2466    stack.pop();
2467    states.insert(package_root, VisitState::Visited);
2468    Ok(canonical_manifest
2469        .parent()
2470        .unwrap_or_else(|| Path::new("/"))
2471        .to_path_buf())
2472}
2473
2474fn resolve_dependency_candidate(base_root: &Path, raw_dependency_path: &str) -> PathBuf {
2475    let raw = PathBuf::from(raw_dependency_path);
2476    let resolved = if raw.is_absolute() {
2477        raw
2478    } else {
2479        base_root.join(raw)
2480    };
2481    if resolved
2482        .file_name()
2483        .is_some_and(|file_name| file_name == "Cargo.toml")
2484    {
2485        resolved.parent().map(Path::to_path_buf).unwrap_or(resolved)
2486    } else {
2487        resolved
2488    }
2489}
2490
2491fn resolve_manifest_dependency_candidate(
2492    package_root: &Path,
2493    workspace_root: &Path,
2494    dependency: &ManifestDependency,
2495    workspace_path_dependencies: &BTreeMap<String, String>,
2496    patch_path_dependencies: &BTreeMap<String, String>,
2497    declaring_manifest: &Path,
2498) -> Result<Option<PathBuf>, CargoPathDependencyError> {
2499    if let Some(raw_dependency_path) = dependency.dependency_path.as_deref() {
2500        return Ok(Some(resolve_dependency_candidate(
2501            package_root,
2502            raw_dependency_path,
2503        )));
2504    }
2505
2506    if dependency.uses_workspace_inheritance {
2507        if let Some(raw_dependency_path) =
2508            workspace_path_dependencies.get(&dependency.dependency_name)
2509        {
2510            return Ok(Some(resolve_dependency_candidate(
2511                workspace_root,
2512                raw_dependency_path,
2513            )));
2514        }
2515
2516        // A direct path dependency can itself be a member of a different
2517        // workspace. When cargo metadata is unavailable, its `{ workspace =
2518        // true }` dependencies must be resolved against that enclosing
2519        // workspace, not the entry package's workspace. Otherwise the fallback
2520        // stops at the directly named member and omits active transitive local
2521        // crates (for example fastapi -> fastapi-core).
2522        for ancestor in package_root.ancestors() {
2523            let candidate_manifest = ancestor.join("Cargo.toml");
2524            if candidate_manifest == declaring_manifest || !candidate_manifest.is_file() {
2525                continue;
2526            }
2527            let candidate = read_manifest_document(&candidate_manifest)?;
2528            if !candidate.has_workspace {
2529                continue;
2530            }
2531            let Some(raw_dependency_path) = candidate
2532                .workspace_path_dependencies
2533                .get(&dependency.dependency_name)
2534            else {
2535                if candidate
2536                    .workspace_dependency_names
2537                    .contains(&dependency.dependency_name)
2538                {
2539                    if let Some(raw_dependency_path) =
2540                        patch_path_dependencies.get(&dependency.dependency_name)
2541                    {
2542                        return Ok(Some(resolve_dependency_candidate(
2543                            workspace_root,
2544                            raw_dependency_path,
2545                        )));
2546                    }
2547                    // The dependency is inherited from the enclosing
2548                    // workspace, but resolves from a registry or Git rather
2549                    // than a local path. It is outside the source-sync closure.
2550                    return Ok(None);
2551                }
2552                return Err(CargoPathDependencyError::new(
2553                    CargoPathDependencyErrorKind::ManifestParseFailure,
2554                    format!(
2555                        "workspace-inherited dependency '{}' is not defined by {}",
2556                        dependency.dependency_name,
2557                        candidate_manifest.display()
2558                    ),
2559                )
2560                .with_manifest_path(declaring_manifest)
2561                .with_dependency_name(dependency.dependency_name.clone()));
2562            };
2563            return Ok(Some(resolve_dependency_candidate(
2564                ancestor,
2565                raw_dependency_path,
2566            )));
2567        }
2568        return Err(CargoPathDependencyError::new(
2569            CargoPathDependencyErrorKind::ManifestParseFailure,
2570            format!(
2571                "workspace-inherited dependency '{}' has no enclosing workspace",
2572                dependency.dependency_name
2573            ),
2574        )
2575        .with_manifest_path(declaring_manifest)
2576        .with_dependency_name(dependency.dependency_name.clone()));
2577    }
2578
2579    Ok(patch_path_dependencies
2580        .get(&dependency.dependency_name)
2581        .map(|raw_dependency_path| {
2582            resolve_dependency_candidate(workspace_root, raw_dependency_path)
2583        }))
2584}
2585
2586fn read_manifest_document(
2587    manifest_path: &Path,
2588) -> Result<ManifestDocument, CargoPathDependencyError> {
2589    let contents = std::fs::read_to_string(manifest_path).map_err(|error| {
2590        CargoPathDependencyError::new(
2591            CargoPathDependencyErrorKind::ManifestParseFailure,
2592            format!(
2593                "failed to read manifest {}: {error}",
2594                manifest_path.display()
2595            ),
2596        )
2597        .with_manifest_path(manifest_path)
2598    })?;
2599    let table = toml::from_str::<toml::Table>(&contents).map_err(|error| {
2600        CargoPathDependencyError::new(
2601            CargoPathDependencyErrorKind::ManifestParseFailure,
2602            format!(
2603                "failed to parse manifest {}: {error}",
2604                manifest_path.display()
2605            ),
2606        )
2607        .with_manifest_path(manifest_path)
2608    })?;
2609
2610    let package_name = table
2611        .get("package")
2612        .and_then(toml::Value::as_table)
2613        .and_then(|package| package.get("name"))
2614        .and_then(toml::Value::as_str)
2615        .map(ToOwned::to_owned);
2616    let has_workspace = table.contains_key("workspace");
2617    let workspace_members = table
2618        .get("workspace")
2619        .and_then(toml::Value::as_table)
2620        .and_then(|workspace| workspace.get("members"))
2621        .and_then(toml::Value::as_array)
2622        .map(|members| {
2623            members
2624                .iter()
2625                .filter_map(toml::Value::as_str)
2626                .map(ToOwned::to_owned)
2627                .collect::<Vec<_>>()
2628        })
2629        .unwrap_or_default();
2630    let workspace_dependencies = table
2631        .get("workspace")
2632        .and_then(toml::Value::as_table)
2633        .and_then(|workspace| workspace.get("dependencies"));
2634    let workspace_dependency_names = workspace_dependencies
2635        .and_then(toml::Value::as_table)
2636        .map(|dependencies| dependencies.keys().cloned().collect())
2637        .unwrap_or_default();
2638    let workspace_path_dependencies = workspace_dependencies
2639        .map(collect_named_path_dependencies)
2640        .unwrap_or_default();
2641    let patch_path_dependencies = table
2642        .get("patch")
2643        .and_then(toml::Value::as_table)
2644        .map(collect_patch_path_dependencies)
2645        .unwrap_or_default();
2646
2647    let mut path_dependencies = Vec::new();
2648    collect_dependency_specs(table.get("dependencies"), &mut path_dependencies, false);
2649    collect_dependency_specs(
2650        table.get("build-dependencies"),
2651        &mut path_dependencies,
2652        false,
2653    );
2654
2655    if let Some(targets) = table.get("target").and_then(toml::Value::as_table) {
2656        for target_config in targets.values() {
2657            if let Some(target_table) = target_config.as_table() {
2658                collect_dependency_specs(
2659                    target_table.get("dependencies"),
2660                    &mut path_dependencies,
2661                    false,
2662                );
2663                collect_dependency_specs(
2664                    target_table.get("build-dependencies"),
2665                    &mut path_dependencies,
2666                    false,
2667                );
2668            }
2669        }
2670    }
2671
2672    let mut materialization_path_dependencies = Vec::new();
2673    for dependency_table in ["dependencies", "build-dependencies", "dev-dependencies"] {
2674        collect_dependency_specs(
2675            table.get(dependency_table),
2676            &mut materialization_path_dependencies,
2677            true,
2678        );
2679    }
2680    if let Some(targets) = table.get("target").and_then(toml::Value::as_table) {
2681        for target_config in targets.values() {
2682            if let Some(target_table) = target_config.as_table() {
2683                for dependency_table in ["dependencies", "build-dependencies", "dev-dependencies"] {
2684                    collect_dependency_specs(
2685                        target_table.get(dependency_table),
2686                        &mut materialization_path_dependencies,
2687                        true,
2688                    );
2689                }
2690            }
2691        }
2692    }
2693
2694    Ok(ManifestDocument {
2695        package_name,
2696        has_workspace,
2697        workspace_members,
2698        workspace_dependency_names,
2699        workspace_path_dependencies,
2700        patch_path_dependencies,
2701        path_dependencies,
2702        materialization_path_dependencies,
2703    })
2704}
2705
2706fn collect_dependency_specs(
2707    maybe_table_value: Option<&toml::Value>,
2708    collector: &mut Vec<ManifestDependency>,
2709    include_optional: bool,
2710) {
2711    let Some(table) = maybe_table_value.and_then(toml::Value::as_table) else {
2712        return;
2713    };
2714    for (dependency_name, dependency_value) in table {
2715        match dependency_value {
2716            toml::Value::String(_) => {
2717                collector.push(ManifestDependency {
2718                    dependency_name: dependency_name.clone(),
2719                    dependency_path: None,
2720                    uses_workspace_inheritance: false,
2721                });
2722            }
2723            toml::Value::Table(dependency_table) => {
2724                if !include_optional
2725                    && dependency_table
2726                        .get("optional")
2727                        .and_then(toml::Value::as_bool)
2728                        .unwrap_or(false)
2729                {
2730                    continue;
2731                }
2732                collector.push(ManifestDependency {
2733                    dependency_name: dependency_name.clone(),
2734                    dependency_path: dependency_table
2735                        .get("path")
2736                        .and_then(toml::Value::as_str)
2737                        .map(ToOwned::to_owned),
2738                    uses_workspace_inheritance: dependency_table
2739                        .get("workspace")
2740                        .and_then(toml::Value::as_bool)
2741                        .unwrap_or(false),
2742                });
2743            }
2744            _ => {}
2745        }
2746    }
2747}
2748
2749fn collect_named_path_dependencies(maybe_table_value: &toml::Value) -> BTreeMap<String, String> {
2750    let Some(table) = maybe_table_value.as_table() else {
2751        return BTreeMap::new();
2752    };
2753
2754    let mut dependencies = BTreeMap::new();
2755    for (dependency_name, dependency_value) in table {
2756        let Some(dependency_table) = dependency_value.as_table() else {
2757            continue;
2758        };
2759        let Some(path) = dependency_table.get("path").and_then(toml::Value::as_str) else {
2760            continue;
2761        };
2762        dependencies.insert(dependency_name.clone(), path.to_string());
2763    }
2764    dependencies
2765}
2766
2767fn collect_patch_path_dependencies(patch_table: &toml::value::Table) -> BTreeMap<String, String> {
2768    let mut dependencies = BTreeMap::new();
2769    for patch_source in patch_table.values() {
2770        dependencies.extend(collect_named_path_dependencies(patch_source));
2771    }
2772    dependencies
2773}
2774
2775fn expand_workspace_members(
2776    workspace_root: &Path,
2777    members: &[String],
2778    manifest_path: &Path,
2779) -> Result<Vec<PathBuf>, CargoPathDependencyError> {
2780    let mut manifests = BTreeSet::new();
2781    for member in members {
2782        let expanded_paths = expand_member_pattern(workspace_root, member).map_err(|error| {
2783            CargoPathDependencyError::new(
2784                CargoPathDependencyErrorKind::ManifestParseFailure,
2785                format!("failed to expand workspace member '{member}': {error}"),
2786            )
2787            .with_manifest_path(manifest_path)
2788        })?;
2789        for candidate in expanded_paths {
2790            let manifest_candidate = if candidate
2791                .file_name()
2792                .is_some_and(|file_name| file_name == "Cargo.toml")
2793            {
2794                candidate
2795            } else {
2796                candidate.join("Cargo.toml")
2797            };
2798            if !manifest_candidate.is_file() {
2799                return Err(CargoPathDependencyError::new(
2800                    CargoPathDependencyErrorKind::MissingPathDependency,
2801                    format!(
2802                        "workspace member manifest missing: {}",
2803                        manifest_candidate.display()
2804                    ),
2805                )
2806                .with_manifest_path(manifest_path)
2807                .with_dependency_name(member.clone())
2808                .with_dependency_path(manifest_candidate));
2809            }
2810            manifests.insert(manifest_candidate);
2811        }
2812    }
2813    Ok(manifests.into_iter().collect())
2814}
2815
2816fn expand_member_pattern(base: &Path, pattern: &str) -> Result<Vec<PathBuf>, std::io::Error> {
2817    if !contains_glob(pattern) {
2818        return Ok(vec![base.join(pattern)]);
2819    }
2820
2821    let mut candidates = vec![base.to_path_buf()];
2822    let normalized_pattern = pattern.replace('\\', "/");
2823    for segment in normalized_pattern.split('/') {
2824        if segment.is_empty() || segment == "." {
2825            continue;
2826        }
2827        if segment == ".." {
2828            candidates = candidates
2829                .into_iter()
2830                .map(|candidate| {
2831                    candidate
2832                        .parent()
2833                        .unwrap_or_else(|| Path::new("/"))
2834                        .to_path_buf()
2835                })
2836                .collect();
2837            continue;
2838        }
2839
2840        let wildcard_segment = contains_wildcard(segment);
2841        let mut next_candidates = Vec::new();
2842        for candidate in &candidates {
2843            if wildcard_segment {
2844                if !candidate.is_dir() {
2845                    continue;
2846                }
2847                for entry in std::fs::read_dir(candidate)? {
2848                    let entry = entry?;
2849                    let file_name = entry.file_name();
2850                    let Some(file_name) = file_name.to_str() else {
2851                        continue;
2852                    };
2853                    if wildcard_match(segment, file_name) {
2854                        next_candidates.push(entry.path());
2855                    }
2856                }
2857            } else {
2858                next_candidates.push(candidate.join(segment));
2859            }
2860        }
2861        candidates = next_candidates;
2862    }
2863
2864    Ok(candidates)
2865}
2866
2867fn contains_glob(pattern: &str) -> bool {
2868    pattern.chars().any(|ch| matches!(ch, '*' | '?' | '['))
2869}
2870
2871fn contains_wildcard(segment: &str) -> bool {
2872    segment.contains('*') || segment.contains('?')
2873}
2874
2875fn wildcard_match(pattern: &str, value: &str) -> bool {
2876    wildcard_match_bytes(pattern.as_bytes(), value.as_bytes())
2877}
2878
2879fn wildcard_match_bytes(pattern: &[u8], value: &[u8]) -> bool {
2880    if pattern.is_empty() {
2881        return value.is_empty();
2882    }
2883    if pattern[0] == b'*' {
2884        for index in 0..=value.len() {
2885            if wildcard_match_bytes(&pattern[1..], &value[index..]) {
2886                return true;
2887            }
2888        }
2889        return false;
2890    }
2891    if value.is_empty() {
2892        return false;
2893    }
2894    if pattern[0] == b'?' || pattern[0] == value[0] {
2895        return wildcard_match_bytes(&pattern[1..], &value[1..]);
2896    }
2897    false
2898}
2899
2900#[cfg(test)]
2901mod tests {
2902    use super::*;
2903    use crate::e2e::{MultiRepoFixtureConfig, reset_multi_repo_fixtures};
2904    use std::fs;
2905    use std::sync::atomic::{AtomicU64, Ordering};
2906
2907    #[cfg(unix)]
2908    use std::os::unix::fs::symlink;
2909
2910    static FIXTURE_COUNTER: AtomicU64 = AtomicU64::new(0);
2911
2912    fn selected_tree(files: &[(&str, &str)]) -> BTreeMap<PathBuf, SelectedCargoEntry> {
2913        files
2914            .iter()
2915            .map(|(path, text)| {
2916                (
2917                    PathBuf::from(path),
2918                    SelectedCargoEntry::File(Some((*text).into())),
2919                )
2920            })
2921            .collect()
2922    }
2923
2924    #[test]
2925    fn selected_cargo_combined_tree_accepts_only_selected_sibling_roots() {
2926        let mut entries = selected_tree(&[
2927            (
2928                "app/Cargo.toml",
2929                "[package]\nname='app'\n[dependencies]\ndep={path='../dep'}",
2930            ),
2931            ("dep/Cargo.toml", "[package]\nname='dep'"),
2932        ]);
2933        validate_selected_cargo_tree_at(&entries, Path::new("app/Cargo.toml")).unwrap();
2934        // The original API still requires an actual selected root manifest.
2935        assert!(validate_selected_cargo_tree(&entries).is_err());
2936        entries.insert(
2937            PathBuf::from("app/Cargo.toml"),
2938            SelectedCargoEntry::File(Some("[dependencies]\ndep={path='../../outside'}".into())),
2939        );
2940        let error =
2941            validate_selected_cargo_tree_at(&entries, Path::new("app/Cargo.toml")).unwrap_err();
2942        assert!(error.detail().contains("../../outside"), "{error}");
2943        assert!(error.detail().contains("escapes"), "{error}");
2944    }
2945
2946    #[test]
2947    fn selected_cargo_combined_tree_requires_entrypoint_and_checks_unrelated_metadata() {
2948        let mut entries = selected_tree(&[
2949            ("app/Cargo.toml", "[workspace]"),
2950            (
2951                "other/Cargo.toml",
2952                "[dependencies]\nstale={path='../../stale'}",
2953            ),
2954        ]);
2955        let error =
2956            validate_selected_cargo_tree_at(&entries, Path::new("app/Cargo.toml")).unwrap_err();
2957        assert!(error.detail().contains("other/Cargo.toml"), "{error}");
2958        entries.insert(
2959            PathBuf::from("other/Cargo.toml"),
2960            SelectedCargoEntry::File(Some("[workspace]".into())),
2961        );
2962        for primary in [
2963            "missing/Cargo.toml",
2964            "../app/Cargo.toml",
2965            "/app/Cargo.toml",
2966            "app",
2967        ] {
2968            assert!(
2969                validate_selected_cargo_tree_at(&entries, Path::new(primary)).is_err(),
2970                "{primary}"
2971            );
2972        }
2973        entries.insert(
2974            PathBuf::from("app/Cargo.toml"),
2975            SelectedCargoEntry::File(None),
2976        );
2977        assert!(
2978            validate_selected_cargo_tree_at(&entries, Path::new("app/Cargo.toml"))
2979                .unwrap_err()
2980                .detail()
2981                .contains("primary manifest text")
2982        );
2983    }
2984
2985    #[test]
2986    fn selected_cargo_combined_config_resolves_from_its_selected_base() {
2987        let entries = selected_tree(&[
2988            ("app/Cargo.toml", "[workspace]"),
2989            ("dep/Cargo.toml", "[package]\nname='dep'"),
2990        ]);
2991        let primary = Path::new("app/Cargo.toml");
2992        validate_selected_cargo_config_at(&entries, primary, Path::new("app"), "paths=['../dep']")
2993            .unwrap();
2994        validate_selected_cargo_config_at(&entries, primary, Path::new(""), "paths=['dep']")
2995            .unwrap();
2996        assert!(
2997            validate_selected_cargo_config_at(
2998                &entries,
2999                primary,
3000                Path::new("app"),
3001                "paths=['../../outside']"
3002            )
3003            .is_err()
3004        );
3005        assert!(
3006            validate_selected_cargo_config_at(
3007                &entries,
3008                Path::new("missing/Cargo.toml"),
3009                Path::new("app"),
3010                "[build]\njobs=1"
3011            )
3012            .is_err()
3013        );
3014        assert!(
3015            validate_selected_cargo_config_at(
3016                &entries,
3017                primary,
3018                Path::new("app/Cargo.toml"),
3019                "[build]\njobs=1"
3020            )
3021            .is_err()
3022        );
3023    }
3024
3025    #[test]
3026    fn selected_cargo_tree_allows_internal_parent_dependencies_and_workspace_globs() {
3027        let entries = selected_tree(&[
3028            (
3029                "Cargo.toml",
3030                "[workspace]\nmembers=['crates/*']\n[workspace.dependencies]\nshared={path='crates/dep'}\n",
3031            ),
3032            (
3033                "crates/app/Cargo.toml",
3034                "[package]\nname='app'\nworkspace='../..'\n[dependencies]\nshared={workspace=true}\n[dev-dependencies]\ndep={path='../dep'}\n[lib]\npath='src/lib.rs'\n",
3035            ),
3036            ("crates/app/src/lib.rs", ""),
3037            ("crates/dep/Cargo.toml", "[package]\nname='dep'\n"),
3038        ]);
3039        validate_selected_cargo_tree(&entries).unwrap();
3040        assert_eq!(
3041            validate_selected_cargo_path(&entries, Path::new("crates/app/../dep/Cargo.toml"))
3042                .unwrap(),
3043            Path::new("crates/dep/Cargo.toml")
3044        );
3045    }
3046
3047    #[test]
3048    fn selected_cargo_tree_aggregates_external_roots_and_declaring_manifests() {
3049        let entries = selected_tree(&[
3050            (
3051                "Cargo.toml",
3052                "[dependencies]\none={path='../one'}\n[patch.crates-io]\ntwo={path='../two'}\n",
3053            ),
3054            (
3055                "nested/Cargo.toml",
3056                "[target.'cfg(windows)'.build-dependencies]\nthree={path='../../three',optional=true}\n",
3057            ),
3058        ]);
3059        let error = validate_selected_cargo_tree(&entries).unwrap_err();
3060        assert_eq!(
3061            error.kind(),
3062            &CargoPathDependencyErrorKind::PathPolicyViolation
3063        );
3064        for expected in [
3065            "../one",
3066            "../two",
3067            "../../three",
3068            "nested/Cargo.toml",
3069            "target.cfg(windows).build-dependencies.three.path",
3070        ] {
3071            assert!(error.detail().contains(expected), "{error}");
3072        }
3073        assert_eq!(error.diagnostics().len(), 3);
3074    }
3075
3076    #[test]
3077    fn selected_cargo_tree_checks_replace_workspace_and_compiler_source_paths() {
3078        let invalid = [
3079            "[replace]\n'old:1.0.0'={path='../external'}\n",
3080            "[package]\nworkspace='../external'\n",
3081            "[workspace]\nmembers=['../external']\n",
3082            "[workspace]\ndefault-members=['../external']\n",
3083            "[workspace.dependencies]\nexternal={path='../external'}\n",
3084            "[lib]\npath='../external.rs'\n",
3085            "[[bin]]\npath='../external.rs'\n",
3086            "[[test]]\npath='../external.rs'\n",
3087            "[[example]]\npath='../external.rs'\n",
3088            "[[bench]]\npath='../external.rs'\n",
3089            "[package]\nbuild='../external.rs'\n",
3090            "[dependencies]\nexternal={path='/existing/on/worker'}\n",
3091            "[dependencies]\nexternal={path='C:\\existing'}\n",
3092        ];
3093        for manifest in invalid {
3094            let error = validate_selected_cargo_tree(&selected_tree(&[("Cargo.toml", manifest)]))
3095                .unwrap_err();
3096            assert_eq!(
3097                error.kind(),
3098                &CargoPathDependencyErrorKind::PathPolicyViolation,
3099                "{manifest}"
3100            );
3101            assert!(error.detail().contains("Cargo.toml"), "{error}");
3102        }
3103        validate_selected_cargo_tree(&selected_tree(&[
3104            (
3105                "Cargo.toml",
3106                "[package]\nbuild=true\n[replace]\n'old:1.0.0'={path='dep'}\n",
3107            ),
3108            ("build.rs", ""),
3109            ("dep/Cargo.toml", "[package]\nname='dep'\n"),
3110        ]))
3111        .unwrap();
3112    }
3113
3114    #[test]
3115    fn selected_cargo_tree_resolves_symlinks_before_parent_components() {
3116        let mut entries = selected_tree(&[
3117            ("Cargo.toml", "[dependencies]\ndep={path='alias/..'}\n"),
3118            ("a/Cargo.toml", "[package]\nname='a'\n"),
3119            ("a/nested/anchor", ""),
3120        ]);
3121        entries.insert(
3122            PathBuf::from("alias"),
3123            SelectedCargoEntry::Symlink("a/nested".into()),
3124        );
3125        validate_selected_cargo_tree(&entries).unwrap();
3126        assert_eq!(
3127            validate_selected_cargo_path(&entries, Path::new("alias/../Cargo.toml")).unwrap(),
3128            Path::new("a/Cargo.toml")
3129        );
3130        entries.insert(
3131            PathBuf::from("alias"),
3132            SelectedCargoEntry::Symlink("../outside".into()),
3133        );
3134        assert!(
3135            validate_selected_cargo_tree(&entries)
3136                .unwrap_err()
3137                .detail()
3138                .contains("escapes")
3139        );
3140        entries.insert(
3141            PathBuf::from("alias"),
3142            SelectedCargoEntry::Symlink("other".into()),
3143        );
3144        entries.insert(
3145            PathBuf::from("other"),
3146            SelectedCargoEntry::Symlink("alias".into()),
3147        );
3148        assert!(
3149            validate_selected_cargo_tree(&entries)
3150                .unwrap_err()
3151                .detail()
3152                .contains("cycle")
3153        );
3154    }
3155
3156    #[test]
3157    fn selected_cargo_tree_checks_selected_config_overrides_and_refuses_includes() {
3158        let invalid = [
3159            "paths=['../external']",
3160            "[patch.crates-io]\nexternal={path='../external'}",
3161            "[replace]\n'old:1.0.0'={path='../external'}",
3162            "[source.local]\ndirectory='../external'",
3163            "[source.local]\nlocal-registry='../external'",
3164            "include=['extra.toml']",
3165        ];
3166        for config in invalid {
3167            for name in [".cargo/config", ".cargo/config.toml"] {
3168                let entries = selected_tree(&[("Cargo.toml", "[workspace]"), (name, config)]);
3169                let error = validate_selected_cargo_tree(&entries).unwrap_err();
3170                assert!(error.detail().contains(name), "{error}");
3171            }
3172        }
3173        validate_selected_cargo_tree(&selected_tree(&[
3174            ("Cargo.toml", "[workspace]"),
3175            (".cargo/config.toml", "paths=['dep']\n[source.local]\ndirectory='vendor'\n[build]\nbuild-dir='/managed/cache'"),
3176            ("dep/Cargo.toml", "[package]\nname='dep'"),
3177            ("vendor/entry", ""),
3178        ])).unwrap();
3179        let entries = selected_tree(&[
3180            ("Cargo.toml", "[workspace]"),
3181            ("dep/Cargo.toml", "[package]\nname='dep'"),
3182            ("nested/anchor", ""),
3183        ]);
3184        validate_selected_cargo_config(&entries, Path::new(""), "paths=['dep']").unwrap();
3185        validate_selected_cargo_config(&entries, Path::new("nested"), "paths=['../dep']").unwrap();
3186        assert!(
3187            validate_selected_cargo_config(&entries, Path::new(""), "paths=['../dep']").is_err()
3188        );
3189        assert!(
3190            validate_selected_cargo_config(&entries, Path::new("nested/anchor"), "[build]\njobs=1")
3191                .is_err()
3192        );
3193        let mut alias = selected_tree(&[
3194            ("Cargo.toml", "[workspace]"),
3195            ("settings/config.toml", "paths=['../external']"),
3196        ]);
3197        alias.insert(
3198            PathBuf::from(".cargo"),
3199            SelectedCargoEntry::Symlink("settings".into()),
3200        );
3201        assert!(
3202            validate_selected_cargo_tree(&alias)
3203                .unwrap_err()
3204                .detail()
3205                .contains(".cargo/config.toml")
3206        );
3207        alias.insert(
3208            PathBuf::from("settings/config.toml"),
3209            SelectedCargoEntry::File(None),
3210        );
3211        assert!(
3212            validate_selected_cargo_tree(&alias)
3213                .unwrap_err()
3214                .detail()
3215                .contains("alias text")
3216        );
3217    }
3218
3219    #[test]
3220    fn selected_cargo_tree_refuses_missing_text_paths_and_invalid_inventory() {
3221        assert!(validate_selected_cargo_tree(&BTreeMap::new()).is_err());
3222        let mut unloaded = BTreeMap::from([
3223            (PathBuf::from("Cargo.toml"), SelectedCargoEntry::File(None)),
3224            (
3225                PathBuf::from("settings/config.toml"),
3226                SelectedCargoEntry::File(None),
3227            ),
3228            (
3229                PathBuf::from(".cargo"),
3230                SelectedCargoEntry::Symlink("settings".into()),
3231            ),
3232        ]);
3233        assert_eq!(
3234            validate_selected_cargo_path(&unloaded, Path::new("Cargo.toml")).unwrap(),
3235            Path::new("Cargo.toml")
3236        );
3237        let config_target =
3238            validate_selected_cargo_path(&unloaded, Path::new(".cargo/config.toml")).unwrap();
3239        assert_eq!(config_target, Path::new("settings/config.toml"));
3240        assert!(
3241            validate_selected_cargo_tree(&unloaded)
3242                .unwrap_err()
3243                .detail()
3244                .contains("text")
3245        );
3246        unloaded.insert(
3247            PathBuf::from("Cargo.toml"),
3248            SelectedCargoEntry::File(Some("[workspace]".into())),
3249        );
3250        unloaded.insert(
3251            config_target,
3252            SelectedCargoEntry::File(Some("[build]\njobs=1".into())),
3253        );
3254        validate_selected_cargo_tree(&unloaded).unwrap();
3255        let mut entries = selected_tree(&[("Cargo.toml", "[dependencies]\ndep={path='dep'}")]);
3256        // A real ambient dep directory cannot satisfy a selected inventory.
3257        assert!(
3258            validate_selected_cargo_tree(&entries)
3259                .unwrap_err()
3260                .detail()
3261                .contains("missing selected path dep")
3262        );
3263        entries.insert(
3264            PathBuf::from("dep/Cargo.toml"),
3265            SelectedCargoEntry::File(None),
3266        );
3267        assert!(
3268            validate_selected_cargo_tree(&entries)
3269                .unwrap_err()
3270                .detail()
3271                .contains("text")
3272        );
3273        let mut entries = selected_tree(&[("Cargo.toml", "[workspace]")]);
3274        entries.insert(PathBuf::from("file"), SelectedCargoEntry::File(None));
3275        assert!(validate_selected_cargo_path(&entries, Path::new("file/../Cargo.toml")).is_err());
3276        assert!(validate_selected_cargo_path(&entries, Path::new("../Cargo.toml")).is_err());
3277        entries.insert(PathBuf::from("file/child"), SelectedCargoEntry::File(None));
3278        assert!(
3279            validate_selected_cargo_tree(&entries)
3280                .unwrap_err()
3281                .detail()
3282                .contains("descendants")
3283        );
3284        entries.insert(PathBuf::from("../outside"), SelectedCargoEntry::File(None));
3285        assert!(validate_selected_cargo_path(&entries, Path::new("Cargo.toml")).is_err());
3286        assert!(
3287            validate_selected_cargo_tree(&entries)
3288                .unwrap_err()
3289                .detail()
3290                .contains("invalid selected inventory")
3291        );
3292    }
3293
3294    #[test]
3295    fn selected_cargo_tree_accepts_inert_unix_colon_leaf_but_not_colon_paths() {
3296        let mut entries = selected_tree(&[("Cargo.toml", "[workspace]")]);
3297        entries.insert(
3298            PathBuf::from("crates/fs-evidence/src/vv/model.rs:6150-6230"),
3299            SelectedCargoEntry::File(None),
3300        );
3301        if cfg!(unix) {
3302            validate_selected_cargo_tree(&entries).unwrap();
3303        } else {
3304            assert!(validate_selected_cargo_tree(&entries).is_err());
3305        }
3306        entries.insert(
3307            PathBuf::from("crates/fs-evidence:outside/src/lib.rs"),
3308            SelectedCargoEntry::File(None),
3309        );
3310        assert!(
3311            validate_selected_cargo_tree(&entries)
3312                .unwrap_err()
3313                .detail()
3314                .contains("invalid selected inventory path")
3315        );
3316        let referenced = selected_tree(&[(
3317            "Cargo.toml",
3318            "[package]\nname='root'\nversion='0.1.0'\n[dependencies]\ndep={path='dep:elsewhere'}",
3319        )]);
3320        assert!(
3321            validate_selected_cargo_tree(&referenced)
3322                .unwrap_err()
3323                .detail()
3324                .contains("unsupported or absolute path")
3325        );
3326        let mut linked = selected_tree(&[("Cargo.toml", "[workspace]")]);
3327        linked.insert(
3328            PathBuf::from("alias"),
3329            SelectedCargoEntry::Symlink("target:elsewhere".into()),
3330        );
3331        assert!(
3332            validate_selected_cargo_tree(&linked)
3333                .unwrap_err()
3334                .detail()
3335                .contains("unsupported or absolute path")
3336        );
3337    }
3338
3339    #[test]
3340    fn selected_cargo_tree_does_not_skip_nested_inactive_manifests_or_bad_globs() {
3341        let entries = selected_tree(&[
3342            ("Cargo.toml", "[workspace]\nmembers=['app']"),
3343            ("app/Cargo.toml", "[package]\nname='app'"),
3344            (
3345                "fixtures/unused/Cargo.toml",
3346                "[dev-dependencies]\nexternal={path='../../../external'}",
3347            ),
3348        ]);
3349        assert!(
3350            validate_selected_cargo_tree(&entries)
3351                .unwrap_err()
3352                .detail()
3353                .contains("fixtures/unused/Cargo.toml")
3354        );
3355        for pattern in ["crates/**", "crates/[ab]", "missing/*"] {
3356            let manifest = format!("[workspace]\nmembers=['{pattern}']");
3357            assert!(
3358                validate_selected_cargo_tree(&selected_tree(&[("Cargo.toml", &manifest)])).is_err()
3359            );
3360        }
3361        let inherited = selected_tree(&[
3362            ("Cargo.toml", "[package]\nname='root'"),
3363            (
3364                "nested/Cargo.toml",
3365                "[package]\nversion.workspace=true\n[dependencies]\nshared.workspace=true",
3366            ),
3367        ]);
3368        assert!(
3369            validate_selected_cargo_tree(&inherited)
3370                .unwrap_err()
3371                .detail()
3372                .contains("no selected enclosing workspace")
3373        );
3374        let selected_workspace = selected_tree(&[
3375            (
3376                "Cargo.toml",
3377                "[workspace]\n[workspace.dependencies]\nshared={path='dep'}",
3378            ),
3379            ("dep/Cargo.toml", "[package]\nname='dep'"),
3380            (
3381                "nested/Cargo.toml",
3382                "[package]\nversion.workspace=true\n[dependencies]\nshared.workspace=true",
3383            ),
3384        ]);
3385        validate_selected_cargo_tree(&selected_workspace).unwrap();
3386    }
3387
3388    #[cfg(unix)]
3389    struct TopologyFixture {
3390        root: PathBuf,
3391        canonical_root: PathBuf,
3392        alias_root: PathBuf,
3393    }
3394
3395    #[cfg(unix)]
3396    impl TopologyFixture {
3397        fn new(prefix: &str) -> Self {
3398            let id = FIXTURE_COUNTER.fetch_add(1, Ordering::SeqCst);
3399            let root = std::env::temp_dir().join(format!(
3400                "rch-cargo-path-deps-{}-{}-{}",
3401                prefix,
3402                std::process::id(),
3403                id
3404            ));
3405            let canonical_root = root.join("data/projects");
3406            let alias_root = root.join("dp");
3407            fs::create_dir_all(&canonical_root).expect("create canonical root");
3408            symlink(&canonical_root, &alias_root).expect("create alias symlink");
3409
3410            Self {
3411                root,
3412                canonical_root,
3413                alias_root,
3414            }
3415        }
3416
3417        fn policy(&self) -> PathTopologyPolicy {
3418            PathTopologyPolicy::new(self.canonical_root.clone(), self.alias_root.clone())
3419        }
3420    }
3421
3422    #[cfg(unix)]
3423    impl Drop for TopologyFixture {
3424        fn drop(&mut self) {
3425            let _ = fs::remove_dir_all(&self.root);
3426        }
3427    }
3428
3429    #[cfg(unix)]
3430    fn write_lib_crate(root: &Path, crate_name: &str, deps: &[(&str, &str)]) {
3431        fs::create_dir_all(root.join("src")).expect("create crate src");
3432        fs::write(root.join("Cargo.toml"), crate_manifest(crate_name, deps))
3433            .expect("write manifest");
3434        fs::write(
3435            root.join("src/lib.rs"),
3436            format!(
3437                "pub fn {}() -> &'static str {{ \"{}\" }}\n",
3438                crate_name, crate_name
3439            ),
3440        )
3441        .expect("write lib.rs");
3442    }
3443
3444    #[cfg(unix)]
3445    fn write_bin_crate(root: &Path, crate_name: &str, deps: &[(&str, &str)]) {
3446        fs::create_dir_all(root.join("src")).expect("create crate src");
3447        fs::write(root.join("Cargo.toml"), crate_manifest(crate_name, deps))
3448            .expect("write manifest");
3449        fs::write(
3450            root.join("src/main.rs"),
3451            format!("fn main() {{ println!(\"{}\"); }}\n", crate_name),
3452        )
3453        .expect("write main.rs");
3454    }
3455
3456    #[cfg(unix)]
3457    fn crate_manifest(crate_name: &str, deps: &[(&str, &str)]) -> String {
3458        let mut dependencies = String::new();
3459        for (name, path) in deps {
3460            dependencies.push_str(&format!("{name} = {{ path = \"{path}\" }}\n"));
3461        }
3462        format!(
3463            "[package]\nname = \"{crate_name}\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\n{dependencies}"
3464        )
3465    }
3466
3467    #[cfg(unix)]
3468    struct LockPlanningFixture {
3469        _fixture: TopologyFixture,
3470        workspace_root: PathBuf,
3471        app_root: PathBuf,
3472        late_dep_root: PathBuf,
3473        cargo_home: PathBuf,
3474        expected_roots: BTreeSet<PathBuf>,
3475        policy: PathTopologyPolicy,
3476    }
3477
3478    #[cfg(unix)]
3479    impl LockPlanningFixture {
3480        fn new(prefix: &str) -> Self {
3481            let fixture = TopologyFixture::new(prefix);
3482            let scenario_root = fixture.canonical_root.join(prefix);
3483            let workspace_root = scenario_root.join("ambient_checkout");
3484            let app_root = workspace_root.join("app");
3485            let normal_dep_root = scenario_root.join("normal_dep");
3486            let build_dep_root = scenario_root.join("build_dep");
3487            let dev_dep_root = scenario_root.join("dev_dep");
3488            let optional_dep_root = scenario_root.join("optional_dep");
3489            let target_dep_root = scenario_root.join("target_dep");
3490            let transitive_dep_root = scenario_root.join("transitive_dep");
3491            let workspace_dep_root = scenario_root.join("workspace_dep");
3492            let patch_only_dep_root = scenario_root.join("patch_only_dep");
3493            let late_dep_root = scenario_root.join("late_dep");
3494            let cargo_home = scenario_root.join("cargo_home");
3495
3496            write_lib_crate(&normal_dep_root, "normal_dep", &[]);
3497            write_lib_crate(&build_dep_root, "build_dep", &[]);
3498            write_lib_crate(&dev_dep_root, "dev_dep", &[]);
3499            write_lib_crate(
3500                &optional_dep_root,
3501                "optional_dep",
3502                &[("transitive_dep", "../transitive_dep")],
3503            );
3504            write_lib_crate(&target_dep_root, "target_dep", &[]);
3505            write_lib_crate(&transitive_dep_root, "transitive_dep", &[]);
3506            write_lib_crate(&workspace_dep_root, "workspace_dep", &[]);
3507            write_lib_crate(&patch_only_dep_root, "patch_only_dep", &[]);
3508            fs::create_dir_all(app_root.join("src")).expect("create app source root");
3509            fs::write(app_root.join("src/main.rs"), "fn main() {}\n").expect("write app main");
3510            Self::write_app_manifest(&app_root, false);
3511            fs::write(
3512                workspace_root.join("Cargo.toml"),
3513                r#"[workspace]
3514members = ["app"]
3515resolver = "3"
3516
3517[workspace.dependencies]
3518workspace_dep = { path = "../workspace_dep" }
3519
3520[patch.crates-io]
3521patch_only_dep = { path = "../patch_only_dep" }
3522"#,
3523            )
3524            .expect("write workspace manifest");
3525            fs::create_dir_all(&cargo_home).expect("create isolated Cargo home");
3526
3527            let expected_roots = [
3528                &app_root,
3529                &normal_dep_root,
3530                &build_dep_root,
3531                &dev_dep_root,
3532                &optional_dep_root,
3533                &target_dep_root,
3534                &transitive_dep_root,
3535                &workspace_dep_root,
3536                &patch_only_dep_root,
3537            ]
3538            .into_iter()
3539            .map(|root| root.canonicalize().expect("canonical fixture package"))
3540            .collect();
3541            let policy = fixture.policy();
3542            Self {
3543                _fixture: fixture,
3544                workspace_root,
3545                app_root,
3546                late_dep_root,
3547                cargo_home,
3548                expected_roots,
3549                policy,
3550            }
3551        }
3552
3553        fn write_app_manifest(app_root: &Path, include_late_dependency: bool) {
3554            let late_dependency = if include_late_dependency {
3555                "late_dep = { path = \"../../late_dep\" }\n"
3556            } else {
3557                ""
3558            };
3559            fs::write(
3560                app_root.join("Cargo.toml"),
3561                format!(
3562                    r#"[package]
3563name = "lock_invariant_app"
3564version = "0.1.0"
3565edition = "2024"
3566
3567[dependencies]
3568normal_dep = {{ path = "../../normal_dep" }}
3569optional_dep = {{ path = "../../optional_dep", optional = true }}
3570workspace_dep = {{ workspace = true, optional = true }}
3571{late_dependency}
3572[build-dependencies]
3573build_dep = {{ path = "../../build_dep" }}
3574
3575[dev-dependencies]
3576dev_dep = {{ path = "../../dev_dep" }}
3577
3578[target.'cfg(target_os = "linux")'.dependencies]
3579target_dep = {{ path = "../../target_dep" }}
3580"#
3581                ),
3582            )
3583            .expect("write app manifest");
3584        }
3585
3586        fn lock_path(&self) -> PathBuf {
3587            self.workspace_root.join("Cargo.lock")
3588        }
3589
3590        fn manifest_path(&self) -> PathBuf {
3591            self.workspace_root.join("Cargo.toml")
3592        }
3593
3594        fn generate_valid_lockfile(&self) {
3595            let output = Command::new("cargo")
3596                .arg("generate-lockfile")
3597                .arg("--offline")
3598                .arg("--manifest-path")
3599                .arg(self.manifest_path())
3600                .env("CARGO_HOME", &self.cargo_home)
3601                .output()
3602                .expect("run real cargo generate-lockfile");
3603            assert!(
3604                output.status.success(),
3605                "cargo generate-lockfile failed: {}",
3606                String::from_utf8_lossy(&output.stderr)
3607            );
3608            assert!(self.lock_path().is_file(), "Cargo generated the lockfile");
3609        }
3610
3611        fn make_generated_lock_stale(&mut self) {
3612            write_lib_crate(&self.late_dep_root, "late_dep", &[]);
3613            self.expected_roots.insert(
3614                self.late_dep_root
3615                    .canonicalize()
3616                    .expect("canonical late dependency"),
3617            );
3618            Self::write_app_manifest(&self.app_root, true);
3619        }
3620
3621        fn resolve_planned_roots(&self) -> BTreeSet<PathBuf> {
3622            let plan = crate::build_dependency_closure_plan_with_policy(
3623                &self.workspace_root,
3624                &self.policy,
3625            );
3626            assert!(
3627                plan.is_ready(),
3628                "dependency plan must be ready; reason={:?}, issues={:?}",
3629                plan.fail_open_reason,
3630                plan.issues
3631            );
3632            plan.sync_order
3633                .iter()
3634                .map(|action| action.package_root.clone())
3635                .collect()
3636        }
3637
3638        fn assert_discovers_every_path_root(&self) {
3639            assert_eq!(
3640                self.resolve_planned_roots(),
3641                self.expected_roots,
3642                "locked-metadata success or manifest fallback must preserve the complete path-root closure"
3643            );
3644        }
3645    }
3646
3647    #[cfg(unix)]
3648    struct LockfileSnapshot {
3649        bytes: Vec<u8>,
3650        modified: std::time::SystemTime,
3651    }
3652
3653    #[cfg(unix)]
3654    impl LockfileSnapshot {
3655        fn capture(path: &Path) -> Self {
3656            Self {
3657                bytes: fs::read(path).expect("read lockfile snapshot"),
3658                modified: fs::metadata(path)
3659                    .and_then(|metadata| metadata.modified())
3660                    .expect("read lockfile mtime"),
3661            }
3662        }
3663
3664        fn arm_mtime_detection() {
3665            // Keep the pre-plan mtime outside common one-second timestamp
3666            // granularity. A byte-identical rewrite must still fail the mtime
3667            // assertion below instead of disappearing inside one clock tick.
3668            std::thread::sleep(std::time::Duration::from_millis(1100));
3669        }
3670
3671        fn assert_unchanged(&self, path: &Path, scenario: &str) {
3672            assert_eq!(
3673                fs::read(path).expect("read lockfile after planning"),
3674                self.bytes,
3675                "{scenario}: dependency planning changed Cargo.lock bytes"
3676            );
3677            assert_eq!(
3678                fs::metadata(path)
3679                    .and_then(|metadata| metadata.modified())
3680                    .expect("read lockfile mtime after planning"),
3681                self.modified,
3682                "{scenario}: dependency planning changed Cargo.lock mtime"
3683            );
3684        }
3685    }
3686
3687    /// bd-rch-pinned-metadata-lock-ytkda: with a VALID lockfile, dependency
3688    /// planning uses `cargo metadata --locked` and leaves the lockfile
3689    /// byte-for-byte and mtime-for-mtime untouched.
3690    #[cfg(unix)]
3691    #[test]
3692    fn valid_lockfile_planning_discovers_roots_without_mutating_the_lock() {
3693        let fixture = LockPlanningFixture::new("lock-valid");
3694        let lock_path = fixture.lock_path();
3695        fixture.generate_valid_lockfile();
3696        let snapshot = LockfileSnapshot::capture(&lock_path);
3697        LockfileSnapshot::arm_mtime_detection();
3698
3699        invoke_cargo_metadata(&fixture.manifest_path())
3700            .expect("real cargo metadata --locked accepts the generated valid lock");
3701        fixture.assert_discovers_every_path_root();
3702
3703        snapshot.assert_unchanged(&lock_path, "valid lockfile");
3704    }
3705
3706    /// bd-rch-pinned-metadata-lock-ytkda: a STALE lockfile makes
3707    /// `cargo metadata --locked` refuse; planning falls back to manifest-only
3708    /// discovery, still finds the path roots, and never rewrites the stale
3709    /// lock (bytes and mtime preserved).
3710    #[cfg(unix)]
3711    #[test]
3712    fn stale_lockfile_planning_falls_back_without_rewriting_the_lock() {
3713        let mut fixture = LockPlanningFixture::new("lock-stale");
3714        fixture.generate_valid_lockfile();
3715        let lock_path = fixture.lock_path();
3716        fixture.make_generated_lock_stale();
3717        let snapshot = LockfileSnapshot::capture(&lock_path);
3718        LockfileSnapshot::arm_mtime_detection();
3719
3720        let refusal = invoke_cargo_metadata(&fixture.manifest_path())
3721            .expect_err("real cargo metadata --locked must refuse a generated stale lock");
3722        assert_eq!(
3723            refusal.kind(),
3724            &CargoPathDependencyErrorKind::MetadataInvocationFailure
3725        );
3726        snapshot.assert_unchanged(&lock_path, "locked metadata refusal");
3727
3728        fixture.assert_discovers_every_path_root();
3729        snapshot.assert_unchanged(&lock_path, "stale-lock fallback planning");
3730    }
3731
3732    /// bd-rch-pinned-metadata-lock-ytkda: with NO lockfile, planning stays
3733    /// discoverable through the manifest-only fallback and must not create a
3734    /// `Cargo.lock` as a side effect (the historical mutation bug).
3735    #[cfg(unix)]
3736    #[test]
3737    fn missing_lockfile_planning_discovers_roots_without_creating_a_lock() {
3738        let fixture = LockPlanningFixture::new("lock-missing");
3739        let lock_path = fixture.lock_path();
3740        assert!(!lock_path.exists(), "fixture starts without a lockfile");
3741
3742        let refusal = invoke_cargo_metadata(&fixture.manifest_path())
3743            .expect_err("real cargo metadata --locked must refuse an absent lock");
3744        assert_eq!(
3745            refusal.kind(),
3746            &CargoPathDependencyErrorKind::MetadataInvocationFailure
3747        );
3748        assert!(
3749            !lock_path.exists(),
3750            "locked metadata refusal must not create Cargo.lock"
3751        );
3752
3753        fixture.assert_discovers_every_path_root();
3754
3755        assert!(
3756            !lock_path.exists(),
3757            "dependency planning must never create a Cargo.lock in the user checkout"
3758        );
3759    }
3760
3761    #[cfg(unix)]
3762    #[test]
3763    fn resolves_workspace_transitive_path_dependencies() {
3764        let fixture = TopologyFixture::new("workspace");
3765        let scenario_root = fixture.canonical_root.join("workspace_transitive");
3766        let workspace_root = scenario_root.join("workspace");
3767        let shared_root = scenario_root.join("shared/shared_lib");
3768        let util_root = workspace_root.join("crates/util");
3769        let app_root = workspace_root.join("crates/app");
3770
3771        write_lib_crate(&shared_root, "workspace_shared", &[]);
3772        write_lib_crate(
3773            &util_root,
3774            "workspace_util",
3775            &[("workspace_shared", "../../../shared/shared_lib")],
3776        );
3777        write_bin_crate(&app_root, "workspace_app", &[("workspace_util", "../util")]);
3778        fs::create_dir_all(&workspace_root).expect("create workspace root");
3779        fs::write(
3780            workspace_root.join("Cargo.toml"),
3781            "[workspace]\nmembers = [\"crates/app\", \"crates/util\"]\nresolver = \"3\"\n",
3782        )
3783        .expect("write workspace manifest");
3784
3785        let graph =
3786            resolve_cargo_path_dependency_graph_with_policy(&workspace_root, &fixture.policy())
3787                .expect("resolve workspace graph");
3788
3789        let app_root = app_root.canonicalize().expect("canonical app root");
3790        let util_root = util_root.canonicalize().expect("canonical util root");
3791        let shared_root = shared_root.canonicalize().expect("canonical shared root");
3792        assert_eq!(
3793            graph.root_packages,
3794            vec![app_root.clone(), util_root.clone()]
3795        );
3796        let package_roots = graph
3797            .packages
3798            .iter()
3799            .map(|package| package.package_root.clone())
3800            .collect::<Vec<_>>();
3801        assert!(
3802            package_roots
3803                .windows(2)
3804                .all(|window| window[0] <= window[1]),
3805            "packages should be deterministically sorted"
3806        );
3807        assert_eq!(
3808            package_roots.into_iter().collect::<BTreeSet<_>>(),
3809            BTreeSet::from([app_root.clone(), shared_root.clone(), util_root.clone()])
3810        );
3811        assert_eq!(
3812            graph.edges,
3813            vec![
3814                CargoPathDependencyEdge {
3815                    from: app_root,
3816                    to: util_root.clone(),
3817                    dependency_name: "workspace_util".to_string(),
3818                },
3819                CargoPathDependencyEdge {
3820                    from: util_root,
3821                    to: shared_root,
3822                    dependency_name: "workspace_shared".to_string(),
3823                },
3824            ]
3825        );
3826    }
3827
3828    #[cfg(unix)]
3829    #[test]
3830    fn resolves_virtual_workspace_members_from_alias_path() {
3831        let fixture = TopologyFixture::new("virtual-workspace");
3832        let scenario_root = fixture.canonical_root.join("virtual_workspace");
3833        let workspace_root = scenario_root.join("ws");
3834        let member_a = workspace_root.join("members/a");
3835        let member_b = workspace_root.join("members/b");
3836
3837        write_lib_crate(&member_b, "virtual_b", &[]);
3838        write_lib_crate(&member_a, "virtual_a", &[("virtual_b", "../b")]);
3839        fs::create_dir_all(&workspace_root).expect("create workspace root");
3840        fs::write(
3841            workspace_root.join("Cargo.toml"),
3842            "[workspace]\nmembers = [\"members/a\", \"members/b\"]\nresolver = \"3\"\n",
3843        )
3844        .expect("write workspace manifest");
3845
3846        let relative = workspace_root
3847            .strip_prefix(&fixture.canonical_root)
3848            .expect("workspace under canonical root");
3849        let alias_workspace = fixture.alias_root.join(relative);
3850
3851        let graph =
3852            resolve_cargo_path_dependency_graph_with_policy(&alias_workspace, &fixture.policy())
3853                .expect("resolve virtual workspace graph");
3854
3855        let member_a = member_a.canonicalize().expect("canonical member a");
3856        let member_b = member_b.canonicalize().expect("canonical member b");
3857        assert_eq!(
3858            graph.workspace_root,
3859            Some(
3860                workspace_root
3861                    .canonicalize()
3862                    .expect("canonical workspace root")
3863            )
3864        );
3865        assert_eq!(
3866            graph.root_packages,
3867            vec![member_a.clone(), member_b.clone()]
3868        );
3869        assert_eq!(
3870            graph.edges,
3871            vec![CargoPathDependencyEdge {
3872                from: member_a,
3873                to: member_b,
3874                dependency_name: "virtual_b".to_string(),
3875            }]
3876        );
3877    }
3878
3879    #[cfg(unix)]
3880    #[test]
3881    fn resolves_nested_manifest_transitive_closure() {
3882        let fixture = TopologyFixture::new("nested");
3883        let scenario_root = fixture.canonical_root.join("nested_manifests");
3884        let app_root = scenario_root.join("app");
3885        let util_root = scenario_root.join("libs/util");
3886        let core_root = scenario_root.join("libs/core");
3887
3888        write_lib_crate(&core_root, "nested_core", &[]);
3889        write_lib_crate(&util_root, "nested_util", &[("nested_core", "../core")]);
3890        write_bin_crate(&app_root, "nested_app", &[("nested_util", "../libs/util")]);
3891
3892        let graph = resolve_cargo_path_dependency_graph_with_policy(
3893            &app_root.join("Cargo.toml"),
3894            &fixture.policy(),
3895        )
3896        .expect("resolve nested manifest graph");
3897
3898        let app_root = app_root.canonicalize().expect("canonical app");
3899        let util_root = util_root.canonicalize().expect("canonical util");
3900        let core_root = core_root.canonicalize().expect("canonical core");
3901        assert_eq!(graph.root_packages, vec![app_root.clone()]);
3902        assert_eq!(
3903            graph.edges,
3904            vec![
3905                CargoPathDependencyEdge {
3906                    from: app_root,
3907                    to: util_root.clone(),
3908                    dependency_name: "nested_util".to_string(),
3909                },
3910                CargoPathDependencyEdge {
3911                    from: util_root,
3912                    to: core_root,
3913                    dependency_name: "nested_core".to_string(),
3914                },
3915            ]
3916        );
3917    }
3918
3919    #[cfg(unix)]
3920    #[test]
3921    fn malformed_metadata_uses_manifest_fallback() {
3922        let fixture = TopologyFixture::new("malformed-metadata");
3923        let scenario_root = fixture.canonical_root.join("metadata_fallback");
3924        let app_root = scenario_root.join("app");
3925        let dep_root = scenario_root.join("dep");
3926
3927        write_lib_crate(&dep_root, "fallback_dep", &[]);
3928        write_bin_crate(&app_root, "fallback_app", &[("fallback_dep", "../dep")]);
3929
3930        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
3931            &app_root,
3932            &fixture.policy(),
3933            |_| Ok("{not-json".to_string()),
3934        )
3935        .expect("resolver should recover using fallback");
3936
3937        let app_root = app_root.canonicalize().expect("canonical app");
3938        let dep_root = dep_root.canonicalize().expect("canonical dep");
3939        assert_eq!(graph.root_packages, vec![app_root.clone()]);
3940        assert_eq!(
3941            graph.edges,
3942            vec![CargoPathDependencyEdge {
3943                from: app_root,
3944                to: dep_root,
3945                dependency_name: "fallback_dep".to_string(),
3946            }]
3947        );
3948    }
3949
3950    #[cfg(unix)]
3951    #[test]
3952    fn malformed_manifest_reports_manifest_parse_failure() {
3953        let fixture = TopologyFixture::new("manifest-error");
3954        let config = MultiRepoFixtureConfig::new(
3955            fixture.canonical_root.clone(),
3956            fixture.alias_root.clone(),
3957            "resolver_manifest_error",
3958        );
3959        let fixtures = reset_multi_repo_fixtures(&config).expect("generate fixture set");
3960        let invalid = fixtures
3961            .fixture("fail_invalid_manifest")
3962            .expect("invalid fixture metadata");
3963
3964        let error = resolve_cargo_path_dependency_graph_with_policy(
3965            &invalid.canonical_entrypoint,
3966            &fixture.policy(),
3967        )
3968        .expect_err("invalid manifest must fail");
3969        assert_eq!(
3970            error.kind(),
3971            &CargoPathDependencyErrorKind::ManifestParseFailure
3972        );
3973    }
3974
3975    #[cfg(unix)]
3976    #[test]
3977    fn missing_path_reports_missing_dependency_kind() {
3978        let fixture = TopologyFixture::new("missing-path");
3979        let config = MultiRepoFixtureConfig::new(
3980            fixture.canonical_root.clone(),
3981            fixture.alias_root.clone(),
3982            "resolver_missing_path",
3983        );
3984        let fixtures = reset_multi_repo_fixtures(&config).expect("generate fixture set");
3985        let missing = fixtures
3986            .fixture("fail_missing_path_dep")
3987            .expect("missing fixture metadata");
3988
3989        let error = resolve_cargo_path_dependency_graph_with_policy(
3990            &missing.canonical_entrypoint,
3991            &fixture.policy(),
3992        )
3993        .expect_err("missing dependency must fail");
3994        assert_eq!(
3995            error.kind(),
3996            &CargoPathDependencyErrorKind::MissingPathDependency
3997        );
3998    }
3999
4000    #[cfg(unix)]
4001    #[test]
4002    fn outside_root_reports_path_policy_violation() {
4003        let fixture = TopologyFixture::new("outside-root");
4004        let config = MultiRepoFixtureConfig::new(
4005            fixture.canonical_root.clone(),
4006            fixture.alias_root.clone(),
4007            "resolver_outside_root",
4008        );
4009        let fixtures = reset_multi_repo_fixtures(&config).expect("generate fixture set");
4010        let outside = fixtures
4011            .fixture("fail_outside_canonical_dep")
4012            .expect("outside fixture metadata");
4013
4014        let error = resolve_cargo_path_dependency_graph_with_policy(
4015            &outside.canonical_entrypoint,
4016            &fixture.policy(),
4017        )
4018        .expect_err("outside root dependency must fail");
4019        assert_eq!(
4020            error.kind(),
4021            &CargoPathDependencyErrorKind::PathPolicyViolation
4022        );
4023    }
4024
4025    #[cfg(unix)]
4026    #[test]
4027    fn resolved_alias_target_is_allowed_for_absolute_dependency_scope() {
4028        let fixture = TopologyFixture::new("resolved-alias-target");
4029        let alias_target_root = fixture
4030            .alias_root
4031            .canonicalize()
4032            .expect("resolve alias target root");
4033        let dependency_candidate = alias_target_root.join("repo/crate_dep");
4034
4035        validate_absolute_dependency_scope(
4036            &dependency_candidate,
4037            &fixture.policy(),
4038            Path::new("/tmp/Cargo.toml"),
4039            "crate_dep",
4040            "metadata dependency path policy violation",
4041        )
4042        .expect("resolved alias target should be accepted");
4043    }
4044
4045    #[cfg(unix)]
4046    #[test]
4047    fn cyclic_path_dependencies_report_cycle_kind() {
4048        let fixture = TopologyFixture::new("cycle");
4049        let scenario_root = fixture.canonical_root.join("cycle");
4050        let crate_a = scenario_root.join("a");
4051        let crate_b = scenario_root.join("b");
4052
4053        write_lib_crate(&crate_a, "cycle_a", &[("cycle_b", "../b")]);
4054        write_lib_crate(&crate_b, "cycle_b", &[("cycle_a", "../a")]);
4055
4056        let error = resolve_cargo_path_dependency_graph_with_policy(&crate_a, &fixture.policy())
4057            .expect_err("cyclic path dependencies must fail");
4058        assert_eq!(
4059            error.kind(),
4060            &CargoPathDependencyErrorKind::CyclicDependency
4061        );
4062        assert!(
4063            error.cycle().len() >= 3,
4064            "cycle path should include repeated terminal node"
4065        );
4066    }
4067
4068    #[cfg(unix)]
4069    #[test]
4070    fn optional_path_dependency_cycle_is_ignored_for_active_closure() {
4071        let fixture = TopologyFixture::new("optional-cycle");
4072        let scenario_root = fixture.canonical_root.join("optional_cycle");
4073        let app_root = scenario_root.join("app");
4074        let real_dep_root = scenario_root.join("real_dep");
4075        let optional_a_root = scenario_root.join("optional_a");
4076        let optional_b_root = scenario_root.join("optional_b");
4077
4078        write_lib_crate(&real_dep_root, "real_dep", &[]);
4079        write_lib_crate(
4080            &optional_a_root,
4081            "optional_a",
4082            &[("optional_b", "../optional_b")],
4083        );
4084        write_lib_crate(
4085            &optional_b_root,
4086            "optional_b",
4087            &[("optional_a", "../optional_a")],
4088        );
4089
4090        fs::create_dir_all(app_root.join("src")).expect("create app src");
4091        fs::write(
4092            app_root.join("Cargo.toml"),
4093            r#"[package]
4094name = "optional_cycle_app"
4095version = "0.1.0"
4096edition = "2024"
4097
4098[dependencies]
4099real_dep = { path = "../real_dep" }
4100optional_a = { path = "../optional_a", optional = true }
4101"#,
4102        )
4103        .expect("write app manifest");
4104        fs::write(app_root.join("src/main.rs"), "fn main() {}\n").expect("write app main");
4105
4106        let graph = resolve_cargo_path_dependency_graph_with_policy(&app_root, &fixture.policy())
4107            .expect("optional cycle should not poison active dependency closure");
4108        let materialization =
4109            resolve_cargo_path_materialization_closure_with_policy(&app_root, &fixture.policy())
4110                .expect("optional cycle should terminate in materialization closure");
4111
4112        let app_root = app_root.canonicalize().expect("canonical app");
4113        let real_dep_root = real_dep_root.canonicalize().expect("canonical real dep");
4114        let optional_a_root = optional_a_root
4115            .canonicalize()
4116            .expect("canonical optional a");
4117        let optional_b_root = optional_b_root
4118            .canonicalize()
4119            .expect("canonical optional b");
4120        assert_eq!(graph.root_packages, vec![app_root.clone()]);
4121        assert_eq!(
4122            graph.edges,
4123            vec![CargoPathDependencyEdge {
4124                from: app_root.clone(),
4125                to: real_dep_root.clone(),
4126                dependency_name: "real_dep".to_string(),
4127            }]
4128        );
4129
4130        let materialized_roots = materialization
4131            .packages
4132            .iter()
4133            .map(|package| package.package_root.clone())
4134            .collect::<BTreeSet<_>>();
4135        assert_eq!(
4136            materialized_roots,
4137            BTreeSet::from([
4138                app_root.clone(),
4139                real_dep_root.clone(),
4140                optional_a_root.clone(),
4141                optional_b_root.clone(),
4142            ]),
4143            "inactive optional packages must still be available for remote materialization"
4144        );
4145        assert_eq!(
4146            materialization
4147                .edges
4148                .iter()
4149                .cloned()
4150                .collect::<BTreeSet<_>>(),
4151            BTreeSet::from([
4152                CargoPathDependencyEdge {
4153                    from: app_root.clone(),
4154                    to: real_dep_root,
4155                    dependency_name: "real_dep".to_string(),
4156                },
4157                CargoPathDependencyEdge {
4158                    from: optional_a_root.clone(),
4159                    to: optional_b_root.clone(),
4160                    dependency_name: "optional_b".to_string(),
4161                },
4162                CargoPathDependencyEdge {
4163                    from: optional_b_root,
4164                    to: optional_a_root.clone(),
4165                    dependency_name: "optional_a".to_string(),
4166                },
4167                CargoPathDependencyEdge {
4168                    from: app_root,
4169                    to: optional_a_root,
4170                    dependency_name: "optional_a".to_string(),
4171                },
4172            ]),
4173            "materialization evidence should retain the optional cycle without traversing forever"
4174        );
4175    }
4176
4177    #[cfg(unix)]
4178    #[test]
4179    fn materialization_closure_covers_every_manifest_path_dependency_class() {
4180        let fixture = TopologyFixture::new("materialization-all-kinds");
4181        let scenario_root = fixture
4182            .canonical_root
4183            .join("materialization_all_dependency_kinds");
4184        let workspace_root = scenario_root.join("workspace");
4185        let app_root = workspace_root.join("app");
4186        let build_dep_root = scenario_root.join("build_dep");
4187        let dev_dep_root = scenario_root.join("dev_dep");
4188        let optional_dep_root = scenario_root.join("optional_dep");
4189        let patched_dep_root = scenario_root.join("patched_dep");
4190        let target_dep_root = scenario_root.join("target_dep");
4191        let transitive_dep_root = scenario_root.join("transitive_dep");
4192        let unused_patch_dep_root = scenario_root.join("unused_patch_dep");
4193        let workspace_dep_root = scenario_root.join("workspace_dep");
4194
4195        write_lib_crate(&build_dep_root, "build_dep", &[]);
4196        write_lib_crate(&dev_dep_root, "dev_dep", &[]);
4197        write_lib_crate(
4198            &optional_dep_root,
4199            "optional_dep",
4200            &[("transitive_dep", "../transitive_dep")],
4201        );
4202        write_lib_crate(&patched_dep_root, "patched_dep", &[]);
4203        write_lib_crate(&target_dep_root, "target_dep", &[]);
4204        write_lib_crate(&transitive_dep_root, "transitive_dep", &[]);
4205        write_lib_crate(&unused_patch_dep_root, "unused_patch_dep", &[]);
4206        write_lib_crate(&workspace_dep_root, "workspace_dep", &[]);
4207
4208        fs::create_dir_all(app_root.join("src")).expect("create app src");
4209        fs::write(
4210            app_root.join("Cargo.toml"),
4211            r#"[package]
4212name = "materialization_app"
4213version = "0.1.0"
4214edition = "2024"
4215
4216[dependencies]
4217optional_dep = { path = "../../optional_dep", optional = true }
4218patched_dep = "0.1"
4219workspace_dep = { workspace = true, optional = true }
4220
4221[build-dependencies]
4222build_dep = { path = "../../build_dep" }
4223
4224[dev-dependencies]
4225dev_dep = { path = "../../dev_dep" }
4226
4227[target.'cfg(target_os = "linux")'.dependencies]
4228target_dep = { path = "../../target_dep" }
4229"#,
4230        )
4231        .expect("write app manifest");
4232        fs::write(app_root.join("src/lib.rs"), "pub fn app() {}\n").expect("write app lib");
4233        fs::create_dir_all(&workspace_root).expect("create workspace root");
4234        fs::write(
4235            workspace_root.join("Cargo.toml"),
4236            r#"[workspace]
4237members = ["app"]
4238resolver = "3"
4239
4240[workspace.dependencies]
4241workspace_dep = { path = "../workspace_dep" }
4242
4243[patch.crates-io]
4244patched_dep = { path = "../patched_dep" }
4245unused_patch_dep = { path = "../unused_patch_dep" }
4246"#,
4247        )
4248        .expect("write workspace manifest");
4249
4250        let first = resolve_cargo_path_materialization_closure_with_policy(
4251            &workspace_root,
4252            &fixture.policy(),
4253        )
4254        .expect("resolve complete materialization closure");
4255        let second = resolve_cargo_path_materialization_closure_with_policy(
4256            &workspace_root,
4257            &fixture.policy(),
4258        )
4259        .expect("repeat complete materialization closure");
4260        assert_eq!(first, second, "materialization must be deterministic");
4261
4262        let expected_roots = [
4263            &app_root,
4264            &build_dep_root,
4265            &dev_dep_root,
4266            &optional_dep_root,
4267            &patched_dep_root,
4268            &target_dep_root,
4269            &transitive_dep_root,
4270            &unused_patch_dep_root,
4271            &workspace_dep_root,
4272        ]
4273        .into_iter()
4274        .map(|root| root.canonicalize().expect("canonical fixture package"))
4275        .collect::<BTreeSet<_>>();
4276        let observed_roots = first
4277            .packages
4278            .iter()
4279            .map(|package| package.package_root.clone())
4280            .collect::<BTreeSet<_>>();
4281        assert_eq!(observed_roots, expected_roots);
4282
4283        let dependency_names = first
4284            .edges
4285            .iter()
4286            .map(|edge| edge.dependency_name.as_str())
4287            .collect::<BTreeSet<_>>();
4288        assert_eq!(
4289            dependency_names,
4290            BTreeSet::from([
4291                "build_dep",
4292                "dev_dep",
4293                "optional_dep",
4294                "patched_dep",
4295                "target_dep",
4296                "transitive_dep",
4297                "workspace_dep",
4298            ]),
4299            "normal, build, dev, target, optional, workspace, patch, and transitive paths must all materialize"
4300        );
4301    }
4302
4303    // ── Error type construction and accessor tests ──
4304
4305    #[test]
4306    fn error_kind_display_all_variants() {
4307        let variants = [
4308            (
4309                CargoPathDependencyErrorKind::ManifestParseFailure,
4310                "manifest parse failure",
4311            ),
4312            (
4313                CargoPathDependencyErrorKind::MissingPathDependency,
4314                "missing path dependency",
4315            ),
4316            (
4317                CargoPathDependencyErrorKind::CyclicDependency,
4318                "cyclic path dependency",
4319            ),
4320            (
4321                CargoPathDependencyErrorKind::PathPolicyViolation,
4322                "path policy violation",
4323            ),
4324            (
4325                CargoPathDependencyErrorKind::MetadataParseFailure,
4326                "metadata parse failure",
4327            ),
4328            (
4329                CargoPathDependencyErrorKind::MetadataInvocationFailure,
4330                "metadata invocation failure",
4331            ),
4332        ];
4333        for (kind, expected) in variants {
4334            assert_eq!(kind.to_string(), expected, "Display for {kind:?}");
4335        }
4336    }
4337
4338    #[test]
4339    fn error_accessors_return_builder_values() {
4340        let error = CargoPathDependencyError::new(
4341            CargoPathDependencyErrorKind::MissingPathDependency,
4342            "test detail",
4343        )
4344        .with_manifest_path("/a/Cargo.toml")
4345        .with_dependency_name("dep_x")
4346        .with_dependency_path("/b/dep_x");
4347
4348        assert_eq!(
4349            error.kind(),
4350            &CargoPathDependencyErrorKind::MissingPathDependency
4351        );
4352        assert_eq!(error.detail(), "test detail");
4353        assert_eq!(error.manifest_path(), Some(Path::new("/a/Cargo.toml")));
4354        assert_eq!(error.dependency_name(), Some("dep_x"));
4355        assert_eq!(error.dependency_path(), Some(Path::new("/b/dep_x")));
4356        assert!(error.cycle().is_empty());
4357        assert!(error.diagnostics().is_empty());
4358    }
4359
4360    #[test]
4361    fn error_display_includes_all_fields() {
4362        let error = CargoPathDependencyError::new(
4363            CargoPathDependencyErrorKind::MissingPathDependency,
4364            "not found",
4365        )
4366        .with_manifest_path("/a/Cargo.toml")
4367        .with_dependency_name("missing_dep")
4368        .with_dependency_path("/b/missing");
4369
4370        let display = error.to_string();
4371        assert!(
4372            display.contains("missing path dependency"),
4373            "should contain kind"
4374        );
4375        assert!(display.contains("not found"), "should contain detail");
4376        assert!(
4377            display.contains("/a/Cargo.toml"),
4378            "should contain manifest path"
4379        );
4380        assert!(
4381            display.contains("missing_dep"),
4382            "should contain dependency name"
4383        );
4384        assert!(
4385            display.contains("/b/missing"),
4386            "should contain dependency path"
4387        );
4388    }
4389
4390    #[test]
4391    fn error_diagnostics_accumulate() {
4392        let error = CargoPathDependencyError::new(
4393            CargoPathDependencyErrorKind::MetadataParseFailure,
4394            "parse error",
4395        )
4396        .with_diagnostic("line 1")
4397        .with_diagnostics(["line 2", "line 3"]);
4398
4399        assert_eq!(error.diagnostics().len(), 3);
4400        assert_eq!(error.diagnostics()[0], "line 1");
4401        assert_eq!(error.diagnostics()[1], "line 2");
4402        assert_eq!(error.diagnostics()[2], "line 3");
4403    }
4404
4405    #[test]
4406    fn error_implements_std_error() {
4407        let error = CargoPathDependencyError::new(
4408            CargoPathDependencyErrorKind::ManifestParseFailure,
4409            "bad toml",
4410        );
4411        let _: &dyn std::error::Error = &error;
4412    }
4413
4414    // ── Internal helper function tests ──
4415
4416    #[test]
4417    fn default_package_name_uses_last_component() {
4418        assert_eq!(default_package_name(Path::new("/a/b/my_crate")), "my_crate");
4419        assert_eq!(default_package_name(Path::new("/single")), "single");
4420    }
4421
4422    #[test]
4423    fn default_package_name_root_path_uses_display() {
4424        let name = default_package_name(Path::new("/"));
4425        assert!(
4426            !name.is_empty(),
4427            "root path should produce a non-empty name"
4428        );
4429    }
4430
4431    #[test]
4432    fn resolve_dependency_candidate_relative_path() {
4433        let result = resolve_dependency_candidate(Path::new("/project/app"), "../lib");
4434        assert_eq!(result, PathBuf::from("/project/app/../lib"));
4435    }
4436
4437    #[test]
4438    fn resolve_dependency_candidate_absolute_path() {
4439        let result = resolve_dependency_candidate(Path::new("/project/app"), "/other/lib");
4440        assert_eq!(result, PathBuf::from("/other/lib"));
4441    }
4442
4443    #[test]
4444    fn resolve_dependency_candidate_strips_cargo_toml_suffix() {
4445        let result = resolve_dependency_candidate(Path::new("/project/app"), "../lib/Cargo.toml");
4446        assert_eq!(result, PathBuf::from("/project/app/../lib"));
4447    }
4448
4449    #[test]
4450    fn resolve_dependency_candidate_preserves_non_cargo_toml() {
4451        let result = resolve_dependency_candidate(Path::new("/project/app"), "../lib/src");
4452        assert_eq!(result, PathBuf::from("/project/app/../lib/src"));
4453    }
4454
4455    // ── Wildcard / glob helper tests ──
4456
4457    #[test]
4458    fn contains_glob_detects_patterns() {
4459        assert!(contains_glob("crates/*"));
4460        assert!(contains_glob("lib?"));
4461        assert!(contains_glob("[abc]"));
4462        assert!(!contains_glob("plain_path"));
4463        assert!(!contains_glob(""));
4464    }
4465
4466    #[test]
4467    fn contains_wildcard_detects_star_and_question() {
4468        assert!(contains_wildcard("*"));
4469        assert!(contains_wildcard("foo*"));
4470        assert!(contains_wildcard("fo?"));
4471        assert!(!contains_wildcard("plain"));
4472        assert!(!contains_wildcard("[abc]"));
4473    }
4474
4475    #[test]
4476    fn wildcard_match_exact() {
4477        assert!(wildcard_match("hello", "hello"));
4478        assert!(!wildcard_match("hello", "world"));
4479    }
4480
4481    #[test]
4482    fn wildcard_match_star_patterns() {
4483        assert!(wildcard_match("*", "anything"));
4484        assert!(wildcard_match("*", ""));
4485        assert!(wildcard_match("he*o", "hello"));
4486        assert!(wildcard_match("he*o", "heo"));
4487        assert!(!wildcard_match("he*o", "hex"));
4488        assert!(wildcard_match("*.*", "file.rs"));
4489        assert!(!wildcard_match("*.*", "nodot"));
4490    }
4491
4492    #[test]
4493    fn wildcard_match_question_mark() {
4494        assert!(wildcard_match("h?llo", "hello"));
4495        assert!(wildcard_match("h?llo", "hallo"));
4496        assert!(!wildcard_match("h?llo", "hllo"));
4497        assert!(!wildcard_match("?", ""));
4498    }
4499
4500    #[test]
4501    fn wildcard_match_combined() {
4502        assert!(wildcard_match("rch-*", "rch-common"));
4503        assert!(wildcard_match("rch-*", "rch-"));
4504        assert!(!wildcard_match("rch-*", "rch"));
4505    }
4506
4507    #[test]
4508    fn wildcard_match_empty_pattern_and_value() {
4509        assert!(wildcard_match("", ""));
4510        assert!(!wildcard_match("", "x"));
4511        assert!(wildcard_match("*", ""));
4512    }
4513
4514    // ── cycle_from_stack tests ──
4515
4516    #[test]
4517    fn cycle_from_stack_extracts_cycle_segment() {
4518        let stack = vec![
4519            PathBuf::from("/a"),
4520            PathBuf::from("/b"),
4521            PathBuf::from("/c"),
4522        ];
4523        let cycle = cycle_from_stack(&stack, Path::new("/b"));
4524        assert_eq!(
4525            cycle,
4526            vec![
4527                PathBuf::from("/b"),
4528                PathBuf::from("/c"),
4529                PathBuf::from("/b"),
4530            ]
4531        );
4532    }
4533
4534    #[test]
4535    fn cycle_from_stack_terminal_not_in_stack() {
4536        let stack = vec![PathBuf::from("/a")];
4537        let cycle = cycle_from_stack(&stack, Path::new("/z"));
4538        assert_eq!(cycle, vec![PathBuf::from("/z")]);
4539    }
4540
4541    #[test]
4542    fn cycle_from_stack_single_node_self_cycle() {
4543        let stack = vec![PathBuf::from("/a")];
4544        let cycle = cycle_from_stack(&stack, Path::new("/a"));
4545        assert_eq!(cycle, vec![PathBuf::from("/a"), PathBuf::from("/a")]);
4546    }
4547
4548    #[test]
4549    fn cycle_from_stack_empty_stack() {
4550        let stack: Vec<PathBuf> = vec![];
4551        let cycle = cycle_from_stack(&stack, Path::new("/a"));
4552        assert_eq!(cycle, vec![PathBuf::from("/a")]);
4553    }
4554
4555    // ── finalize_graph tests ──
4556
4557    #[test]
4558    fn finalize_empty_graph() {
4559        let partial = PartialGraph::default();
4560        let graph =
4561            finalize_graph(PathBuf::from("/fake/Cargo.toml"), partial).expect("should succeed");
4562        assert!(graph.packages.is_empty());
4563        assert!(graph.edges.is_empty());
4564        assert!(graph.root_packages.is_empty());
4565        assert!(graph.workspace_root.is_none());
4566    }
4567
4568    #[test]
4569    fn finalize_graph_single_root_no_edges() {
4570        let mut partial = PartialGraph::default();
4571        partial.add_root(PathBuf::from("/project"));
4572        partial.add_package(
4573            PathBuf::from("/project"),
4574            PathBuf::from("/project/Cargo.toml"),
4575            "my_project".to_string(),
4576            true,
4577        );
4578
4579        let graph =
4580            finalize_graph(PathBuf::from("/project/Cargo.toml"), partial).expect("should succeed");
4581        assert_eq!(graph.packages.len(), 1);
4582        assert_eq!(graph.packages[0].package_name, "my_project");
4583        assert!(graph.packages[0].workspace_member);
4584        assert!(graph.edges.is_empty());
4585        assert_eq!(graph.root_packages, vec![PathBuf::from("/project")]);
4586    }
4587
4588    #[test]
4589    fn finalize_graph_unreachable_packages_excluded() {
4590        let mut partial = PartialGraph::default();
4591        partial.add_root(PathBuf::from("/root"));
4592        partial.add_package(
4593            PathBuf::from("/root"),
4594            PathBuf::from("/root/Cargo.toml"),
4595            "root_pkg".to_string(),
4596            true,
4597        );
4598        // Add an unreachable package (no edge from root)
4599        partial.add_package(
4600            PathBuf::from("/orphan"),
4601            PathBuf::from("/orphan/Cargo.toml"),
4602            "orphan_pkg".to_string(),
4603            false,
4604        );
4605
4606        let graph =
4607            finalize_graph(PathBuf::from("/root/Cargo.toml"), partial).expect("should succeed");
4608        assert_eq!(graph.packages.len(), 1, "orphan should be excluded");
4609        assert_eq!(graph.packages[0].package_name, "root_pkg");
4610    }
4611
4612    #[test]
4613    fn finalize_graph_detects_cycle() {
4614        let mut partial = PartialGraph::default();
4615        partial.add_root(PathBuf::from("/a"));
4616        partial.add_package(
4617            PathBuf::from("/a"),
4618            PathBuf::from("/a/Cargo.toml"),
4619            "a".to_string(),
4620            true,
4621        );
4622        partial.add_package(
4623            PathBuf::from("/b"),
4624            PathBuf::from("/b/Cargo.toml"),
4625            "b".to_string(),
4626            false,
4627        );
4628        partial.add_edge(PathBuf::from("/a"), PathBuf::from("/b"), "b".to_string());
4629        partial.add_edge(PathBuf::from("/b"), PathBuf::from("/a"), "a".to_string());
4630
4631        let error = finalize_graph(PathBuf::from("/a/Cargo.toml"), partial)
4632            .expect_err("should detect cycle");
4633        assert_eq!(
4634            error.kind(),
4635            &CargoPathDependencyErrorKind::CyclicDependency
4636        );
4637        assert!(
4638            error.cycle().len() >= 2,
4639            "cycle should include at least the two nodes"
4640        );
4641    }
4642
4643    #[test]
4644    fn finalize_graph_diamond_reachable() {
4645        // A -> B, A -> C, B -> D, C -> D
4646        let mut partial = PartialGraph::default();
4647        partial.add_root(PathBuf::from("/a"));
4648        for (name, root) in [("a", "/a"), ("b", "/b"), ("c", "/c"), ("d", "/d")] {
4649            partial.add_package(
4650                PathBuf::from(root),
4651                PathBuf::from(format!("{root}/Cargo.toml")),
4652                name.to_string(),
4653                name == "a",
4654            );
4655        }
4656        partial.add_edge(PathBuf::from("/a"), PathBuf::from("/b"), "b".to_string());
4657        partial.add_edge(PathBuf::from("/a"), PathBuf::from("/c"), "c".to_string());
4658        partial.add_edge(PathBuf::from("/b"), PathBuf::from("/d"), "d".to_string());
4659        partial.add_edge(PathBuf::from("/c"), PathBuf::from("/d"), "d".to_string());
4660
4661        let graph =
4662            finalize_graph(PathBuf::from("/a/Cargo.toml"), partial).expect("should succeed");
4663        assert_eq!(graph.packages.len(), 4, "all 4 nodes reachable in diamond");
4664        assert_eq!(graph.edges.len(), 4, "all 4 edges present");
4665        // Packages should be sorted by BTreeSet order
4666        let names: Vec<_> = graph
4667            .packages
4668            .iter()
4669            .map(|p| p.package_name.as_str())
4670            .collect();
4671        assert_eq!(names, vec!["a", "b", "c", "d"]);
4672    }
4673
4674    // ── Graph serialization round-trip ──
4675
4676    #[test]
4677    fn graph_serialization_round_trip() {
4678        let graph = CargoPathDependencyGraph {
4679            entry_manifest_path: PathBuf::from("/project/Cargo.toml"),
4680            workspace_root: Some(PathBuf::from("/project")),
4681            root_packages: vec![PathBuf::from("/project/app")],
4682            packages: vec![
4683                CargoPathDependencyPackage {
4684                    package_root: PathBuf::from("/project/app"),
4685                    manifest_path: PathBuf::from("/project/app/Cargo.toml"),
4686                    package_name: "app".to_string(),
4687                    workspace_member: true,
4688                },
4689                CargoPathDependencyPackage {
4690                    package_root: PathBuf::from("/project/lib"),
4691                    manifest_path: PathBuf::from("/project/lib/Cargo.toml"),
4692                    package_name: "lib".to_string(),
4693                    workspace_member: true,
4694                },
4695            ],
4696            edges: vec![CargoPathDependencyEdge {
4697                from: PathBuf::from("/project/app"),
4698                to: PathBuf::from("/project/lib"),
4699                dependency_name: "lib".to_string(),
4700            }],
4701        };
4702
4703        let json = serde_json::to_string(&graph).expect("serialize");
4704        let deserialized: CargoPathDependencyGraph =
4705            serde_json::from_str(&json).expect("deserialize");
4706        assert_eq!(graph, deserialized);
4707    }
4708
4709    // ── Edge type ordering ──
4710
4711    #[test]
4712    fn edge_ordering_is_deterministic() {
4713        let edge_a = CargoPathDependencyEdge {
4714            from: PathBuf::from("/a"),
4715            to: PathBuf::from("/b"),
4716            dependency_name: "b".to_string(),
4717        };
4718        let edge_b = CargoPathDependencyEdge {
4719            from: PathBuf::from("/a"),
4720            to: PathBuf::from("/c"),
4721            dependency_name: "c".to_string(),
4722        };
4723        let edge_c = CargoPathDependencyEdge {
4724            from: PathBuf::from("/b"),
4725            to: PathBuf::from("/c"),
4726            dependency_name: "c".to_string(),
4727        };
4728
4729        let mut edges = vec![edge_c.clone(), edge_a.clone(), edge_b.clone()];
4730        edges.sort();
4731        assert_eq!(edges, vec![edge_a, edge_b, edge_c]);
4732    }
4733
4734    // ── Metadata provider fallback tests ──
4735
4736    #[cfg(unix)]
4737    #[test]
4738    fn metadata_error_then_fallback_success() {
4739        let fixture = TopologyFixture::new("meta-err-fallback");
4740        let scenario_root = fixture.canonical_root.join("meta_fallback");
4741        let app_root = scenario_root.join("app");
4742        let dep_root = scenario_root.join("dep");
4743
4744        write_lib_crate(&dep_root, "fb_dep", &[]);
4745        write_bin_crate(&app_root, "fb_app", &[("fb_dep", "../dep")]);
4746
4747        // Metadata provider returns an error, fallback should recover
4748        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
4749            &app_root,
4750            &fixture.policy(),
4751            |_| {
4752                Err(CargoPathDependencyError::new(
4753                    CargoPathDependencyErrorKind::MetadataInvocationFailure,
4754                    "simulated cargo metadata failure",
4755                ))
4756            },
4757        )
4758        .expect("should recover via manifest fallback");
4759
4760        let app_root = app_root.canonicalize().expect("canonical app");
4761        let dep_root = dep_root.canonicalize().expect("canonical dep");
4762        assert_eq!(graph.root_packages, vec![app_root.clone()]);
4763        assert_eq!(graph.edges.len(), 1);
4764        assert_eq!(graph.edges[0].from, app_root);
4765        assert_eq!(graph.edges[0].to, dep_root);
4766    }
4767
4768    #[cfg(unix)]
4769    #[test]
4770    fn metadata_provider_with_synthetic_json() {
4771        let fixture = TopologyFixture::new("synthetic-meta");
4772        let scenario_root = fixture.canonical_root.join("synth_meta");
4773        let app_root = scenario_root.join("app");
4774        let dep_root = scenario_root.join("dep");
4775
4776        write_lib_crate(&dep_root, "synth_dep", &[]);
4777        write_bin_crate(&app_root, "synth_app", &[("synth_dep", "../dep")]);
4778
4779        // Provide well-formed synthetic metadata JSON
4780        let app_canonical = app_root.canonicalize().expect("canonical app");
4781        let dep_canonical = dep_root.canonicalize().expect("canonical dep");
4782        let app_manifest = app_canonical.join("Cargo.toml");
4783        let dep_manifest = dep_canonical.join("Cargo.toml");
4784
4785        let metadata_json = format!(
4786            r#"{{
4787                "packages": [
4788                    {{
4789                        "id": "synth_app 0.1.0 (path+file://{})",
4790                        "name": "synth_app",
4791                        "manifest_path": "{}",
4792                        "dependencies": [
4793                            {{"name": "synth_dep", "path": "{}"}}
4794                        ]
4795                    }},
4796                    {{
4797                        "id": "synth_dep 0.1.0 (path+file://{})",
4798                        "name": "synth_dep",
4799                        "manifest_path": "{}",
4800                        "dependencies": []
4801                    }}
4802                ],
4803                "workspace_members": [],
4804                "workspace_root": null,
4805                "resolve": {{
4806                    "root": "synth_app 0.1.0 (path+file://{})"
4807                }}
4808            }}"#,
4809            app_canonical.display(),
4810            app_manifest.display(),
4811            dep_canonical.display(),
4812            dep_canonical.display(),
4813            dep_manifest.display(),
4814            app_canonical.display(),
4815        );
4816
4817        let json_clone = metadata_json.clone();
4818        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
4819            &app_root,
4820            &fixture.policy(),
4821            move |_| Ok(json_clone.clone()),
4822        )
4823        .expect("synthetic metadata should resolve");
4824
4825        assert_eq!(graph.root_packages, vec![app_canonical.clone()]);
4826        assert_eq!(graph.edges.len(), 1);
4827        assert_eq!(graph.edges[0].dependency_name, "synth_dep");
4828    }
4829
4830    #[cfg(unix)]
4831    #[test]
4832    fn metadata_resolve_ignores_pure_dev_path_edges() {
4833        let fixture = TopologyFixture::new("synthetic-meta-dev-filter");
4834        let scenario_root = fixture.canonical_root.join("synth_meta_dev_filter");
4835        let app_root = scenario_root.join("app");
4836        let dep_root = scenario_root.join("dep");
4837        let dev_dep_root = scenario_root.join("dev_dep");
4838
4839        write_lib_crate(&dep_root, "runtime_dep", &[]);
4840        write_lib_crate(&dev_dep_root, "dev_only_dep", &[]);
4841        write_bin_crate(&app_root, "dev_filter_app", &[]);
4842
4843        let app_canonical = app_root.canonicalize().expect("canonical app");
4844        let dep_canonical = dep_root.canonicalize().expect("canonical dep");
4845        let dev_dep_canonical = dev_dep_root.canonicalize().expect("canonical dev dep");
4846        let app_manifest = app_canonical.join("Cargo.toml");
4847        let dep_manifest = dep_canonical.join("Cargo.toml");
4848        let dev_dep_manifest = dev_dep_canonical.join("Cargo.toml");
4849
4850        let metadata_json = format!(
4851            r#"{{
4852                "packages": [
4853                    {{
4854                        "id": "dev_filter_app 0.1.0 (path+file://{app_root})",
4855                        "name": "dev_filter_app",
4856                        "manifest_path": "{app_manifest}",
4857                        "dependencies": [
4858                            {{"name": "runtime_dep", "path": "{dep_root}"}},
4859                            {{"name": "dev_only_dep", "path": "{dev_dep_root}"}}
4860                        ]
4861                    }},
4862                    {{
4863                        "id": "runtime_dep 0.1.0 (path+file://{dep_root})",
4864                        "name": "runtime_dep",
4865                        "manifest_path": "{dep_manifest}",
4866                        "dependencies": []
4867                    }},
4868                    {{
4869                        "id": "dev_only_dep 0.1.0 (path+file://{dev_dep_root})",
4870                        "name": "dev_only_dep",
4871                        "manifest_path": "{dev_dep_manifest}",
4872                        "dependencies": []
4873                    }}
4874                ],
4875                "workspace_members": [],
4876                "workspace_root": null,
4877                "resolve": {{
4878                    "root": "dev_filter_app 0.1.0 (path+file://{app_root})",
4879                    "nodes": [
4880                        {{
4881                            "id": "dev_filter_app 0.1.0 (path+file://{app_root})",
4882                            "deps": [
4883                                {{
4884                                    "name": "runtime_dep",
4885                                    "pkg": "runtime_dep 0.1.0 (path+file://{dep_root})",
4886                                    "dep_kinds": [{{"kind": null, "target": null}}]
4887                                }},
4888                                {{
4889                                    "name": "dev_only_dep",
4890                                    "pkg": "dev_only_dep 0.1.0 (path+file://{dev_dep_root})",
4891                                    "dep_kinds": [{{"kind": "dev", "target": null}}]
4892                                }}
4893                            ]
4894                        }},
4895                        {{
4896                            "id": "runtime_dep 0.1.0 (path+file://{dep_root})",
4897                            "deps": []
4898                        }},
4899                        {{
4900                            "id": "dev_only_dep 0.1.0 (path+file://{dev_dep_root})",
4901                            "deps": []
4902                        }}
4903                    ]
4904                }}
4905            }}"#,
4906            app_root = app_canonical.display(),
4907            app_manifest = app_manifest.display(),
4908            dep_root = dep_canonical.display(),
4909            dep_manifest = dep_manifest.display(),
4910            dev_dep_root = dev_dep_canonical.display(),
4911            dev_dep_manifest = dev_dep_manifest.display(),
4912        );
4913
4914        let json_clone = metadata_json.clone();
4915        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
4916            &app_root,
4917            &fixture.policy(),
4918            move |_| Ok(json_clone.clone()),
4919        )
4920        .expect("synthetic metadata with dev-only edge should resolve");
4921
4922        assert_eq!(graph.root_packages, vec![app_canonical.clone()]);
4923        assert_eq!(
4924            graph.edges.len(),
4925            1,
4926            "pure dev-only path edges must be ignored"
4927        );
4928        assert_eq!(graph.edges[0].dependency_name, "runtime_dep");
4929        assert_eq!(graph.edges[0].from, app_canonical);
4930        assert_eq!(graph.edges[0].to, dep_canonical);
4931        assert!(
4932            graph
4933                .packages
4934                .iter()
4935                .all(|pkg| pkg.package_root != dev_dep_canonical),
4936            "dev-only dependency package should not be pulled into runtime closure"
4937        );
4938    }
4939
4940    #[cfg(unix)]
4941    #[test]
4942    fn metadata_resolve_excludes_cargo_home_packages_inside_topology_root() {
4943        // Issue #77: with CARGO_HOME under canonical_root, registry and git
4944        // packages from Cargo's caches must not become closure packages. A
4945        // vendored (`[source] directory`) crate keeps its registry source id
4946        // but is a real local input, so it stays.
4947        let fixture = TopologyFixture::new("synthetic-meta-cargo-home");
4948        let scenario_root = fixture.canonical_root.join("synth_meta_cargo_home");
4949        let app_root = scenario_root.join("app");
4950        let cargo_home = scenario_root.join(".cargo");
4951        let registry_root = cargo_home.join("registry/src/index.crates.io-abc/itoa-1.0.15");
4952        let git_root = cargo_home.join("git/checkouts/gitdep-0123/abcdef0");
4953        let vendor_root = scenario_root.join("vendor/ryu");
4954
4955        write_bin_crate(&app_root, "cargo_home_app", &[]);
4956        write_lib_crate(&registry_root, "itoa", &[]);
4957        write_lib_crate(&git_root, "gitdep", &[]);
4958        write_lib_crate(&vendor_root, "ryu", &[]);
4959
4960        let app = app_root.canonicalize().expect("canonical app");
4961        let registry = registry_root.canonicalize().expect("canonical registry");
4962        let git = git_root.canonicalize().expect("canonical git");
4963        let vendor = vendor_root.canonicalize().expect("canonical vendor");
4964        let cargo_home = cargo_home.canonicalize().expect("canonical cargo home");
4965        let crates_io = "registry+https://github.com/rust-lang/crates.io-index";
4966
4967        let metadata_json = format!(
4968            r#"{{
4969                "packages": [
4970                    {{"id": "app", "name": "cargo_home_app", "source": null,
4971                      "manifest_path": "{app}/Cargo.toml", "dependencies": []}},
4972                    {{"id": "itoa", "name": "itoa", "source": "{crates_io}",
4973                      "manifest_path": "{registry}/Cargo.toml", "dependencies": []}},
4974                    {{"id": "gitdep", "name": "gitdep", "source": "git+https://example.com/gitdep#abcdef0",
4975                      "manifest_path": "{git}/Cargo.toml", "dependencies": []}},
4976                    {{"id": "ryu", "name": "ryu", "source": "{crates_io}",
4977                      "manifest_path": "{vendor}/Cargo.toml", "dependencies": []}}
4978                ],
4979                "workspace_members": ["app"],
4980                "workspace_root": "{app}",
4981                "resolve": {{
4982                    "root": "app",
4983                    "nodes": [
4984                        {{"id": "app", "deps": [
4985                            {{"name": "itoa", "pkg": "itoa", "dep_kinds": [{{"kind": null, "target": null}}]}},
4986                            {{"name": "gitdep", "pkg": "gitdep", "dep_kinds": [{{"kind": null, "target": null}}]}},
4987                            {{"name": "ryu", "pkg": "ryu", "dep_kinds": [{{"kind": null, "target": null}}]}}
4988                        ]}},
4989                        {{"id": "itoa", "deps": []}},
4990                        {{"id": "gitdep", "deps": []}},
4991                        {{"id": "ryu", "deps": []}}
4992                    ]
4993                }}
4994            }}"#,
4995            app = app.display(),
4996            registry = registry.display(),
4997            git = git.display(),
4998            vendor = vendor.display(),
4999        );
5000
5001        let package_roots = |cargo_home: Option<&Path>| {
5002            let json = metadata_json.clone();
5003            let graph = resolve_cargo_path_dependency_graph_with_cargo_home(
5004                &app_root,
5005                &fixture.policy(),
5006                move |_| Ok(json.clone()),
5007                cargo_home,
5008            )
5009            .expect("synthetic metadata should resolve");
5010            graph
5011                .packages
5012                .iter()
5013                .map(|package| package.package_root.clone())
5014                .collect::<BTreeSet<_>>()
5015        };
5016
5017        assert_eq!(
5018            package_roots(Some(&cargo_home)),
5019            BTreeSet::from([app.clone(), vendor.clone()]),
5020            "Cargo-home registry/git packages must not enter the closure"
5021        );
5022        // Control: a Cargo home elsewhere leaves in-root sourced packages alone.
5023        assert_eq!(
5024            package_roots(Some(&fixture.root.join("elsewhere-cargo-home"))),
5025            BTreeSet::from([app, registry, git, vendor]),
5026        );
5027    }
5028
5029    #[cfg(unix)]
5030    #[test]
5031    fn metadata_resolve_skips_non_local_packages_and_keeps_local_edges() {
5032        let fixture = TopologyFixture::new("synthetic-meta-nonlocal");
5033        let scenario_root = fixture.canonical_root.join("synth_meta_nonlocal");
5034        let app_root = scenario_root.join("app");
5035        let dep_root = scenario_root.join("dep");
5036        let external_root = fixture.root.join("external_registry/serde");
5037
5038        write_lib_crate(&dep_root, "local_dep", &[]);
5039        write_bin_crate(&app_root, "meta_nonlocal_app", &[]);
5040        write_lib_crate(&external_root, "serde", &[]);
5041
5042        let app_canonical = app_root.canonicalize().expect("canonical app");
5043        let dep_canonical = dep_root.canonicalize().expect("canonical dep");
5044        let external_canonical = external_root
5045            .canonicalize()
5046            .expect("canonical external dep");
5047        let app_manifest = app_canonical.join("Cargo.toml");
5048        let dep_manifest = dep_canonical.join("Cargo.toml");
5049        let external_manifest = external_canonical.join("Cargo.toml");
5050
5051        let metadata_json = format!(
5052            r#"{{
5053                "packages": [
5054                    {{
5055                        "id": "meta_nonlocal_app 0.1.0 (path+file://{app_root})",
5056                        "name": "meta_nonlocal_app",
5057                        "manifest_path": "{app_manifest}",
5058                        "dependencies": [
5059                            {{"name": "local_dep", "path": "{dep_root}"}},
5060                            {{"name": "serde", "path": null}}
5061                        ]
5062                    }},
5063                    {{
5064                        "id": "local_dep 0.1.0 (path+file://{dep_root})",
5065                        "name": "local_dep",
5066                        "manifest_path": "{dep_manifest}",
5067                        "dependencies": []
5068                    }},
5069                    {{
5070                        "id": "serde 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)",
5071                        "name": "serde",
5072                        "manifest_path": "{external_manifest}",
5073                        "dependencies": []
5074                    }}
5075                ],
5076                "workspace_members": [
5077                    "meta_nonlocal_app 0.1.0 (path+file://{app_root})"
5078                ],
5079                "workspace_root": null,
5080                "resolve": {{
5081                    "root": "meta_nonlocal_app 0.1.0 (path+file://{app_root})",
5082                    "nodes": [
5083                        {{
5084                            "id": "meta_nonlocal_app 0.1.0 (path+file://{app_root})",
5085                            "deps": [
5086                                {{
5087                                    "name": "local_dep",
5088                                    "pkg": "local_dep 0.1.0 (path+file://{dep_root})",
5089                                    "dep_kinds": [{{"kind": null, "target": null}}]
5090                                }},
5091                                {{
5092                                    "name": "serde",
5093                                    "pkg": "serde 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)",
5094                                    "dep_kinds": [{{"kind": null, "target": null}}]
5095                                }}
5096                            ]
5097                        }},
5098                        {{
5099                            "id": "local_dep 0.1.0 (path+file://{dep_root})",
5100                            "deps": []
5101                        }},
5102                        {{
5103                            "id": "serde 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)",
5104                            "deps": []
5105                        }}
5106                    ]
5107                }}
5108            }}"#,
5109            app_root = app_canonical.display(),
5110            app_manifest = app_manifest.display(),
5111            dep_root = dep_canonical.display(),
5112            dep_manifest = dep_manifest.display(),
5113            external_manifest = external_manifest.display(),
5114        );
5115
5116        let json_clone = metadata_json.clone();
5117        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
5118            &app_root,
5119            &fixture.policy(),
5120            move |_| Ok(json_clone.clone()),
5121        )
5122        .expect("synthetic metadata with non-local packages should still resolve");
5123
5124        assert_eq!(graph.root_packages, vec![app_canonical.clone()]);
5125        assert_eq!(
5126            graph.edges,
5127            vec![CargoPathDependencyEdge {
5128                from: app_canonical.clone(),
5129                to: dep_canonical.clone(),
5130                dependency_name: "local_dep".to_string(),
5131            }]
5132        );
5133        assert!(
5134            graph
5135                .packages
5136                .iter()
5137                .all(|package| package.package_root != external_canonical),
5138            "non-local registry packages must not be pulled into the sync closure"
5139        );
5140    }
5141
5142    #[cfg(unix)]
5143    #[test]
5144    fn manifest_fallback_ignores_pure_dev_path_edges() {
5145        let fixture = TopologyFixture::new("manifest-fallback-dev-filter");
5146        let scenario_root = fixture.canonical_root.join("manifest_fallback_dev_filter");
5147        let app_root = scenario_root.join("app");
5148        let dep_root = scenario_root.join("dep");
5149        let dev_dep_root = scenario_root.join("dev_dep");
5150
5151        write_lib_crate(&dep_root, "runtime_dep", &[]);
5152        write_lib_crate(&dev_dep_root, "dev_only_dep", &[]);
5153
5154        std::fs::create_dir_all(app_root.join("src")).expect("create app src");
5155        std::fs::write(
5156            app_root.join("Cargo.toml"),
5157            r#"[package]
5158name = "manifest_dev_filter_app"
5159version = "0.1.0"
5160edition = "2024"
5161
5162[dependencies]
5163runtime_dep = { path = "../dep" }
5164
5165[dev-dependencies]
5166dev_only_dep = { path = "../dev_dep" }
5167"#,
5168        )
5169        .expect("write app manifest");
5170        std::fs::write(app_root.join("src/main.rs"), "fn main() {}\n").expect("write app main");
5171
5172        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
5173            &app_root,
5174            &fixture.policy(),
5175            |_| {
5176                Err(CargoPathDependencyError::new(
5177                    CargoPathDependencyErrorKind::MetadataInvocationFailure,
5178                    "force manifest fallback",
5179                ))
5180            },
5181        )
5182        .expect("manifest fallback should ignore pure dev-only path edges");
5183
5184        let app_canonical = app_root.canonicalize().expect("canonical app");
5185        let dep_canonical = dep_root.canonicalize().expect("canonical dep");
5186        let dev_dep_canonical = dev_dep_root.canonicalize().expect("canonical dev dep");
5187
5188        assert_eq!(graph.root_packages, vec![app_canonical.clone()]);
5189        assert_eq!(graph.edges.len(), 1);
5190        assert_eq!(graph.edges[0].dependency_name, "runtime_dep");
5191        assert_eq!(graph.edges[0].from, app_canonical);
5192        assert_eq!(graph.edges[0].to, dep_canonical);
5193        assert!(
5194            graph
5195                .packages
5196                .iter()
5197                .all(|pkg| pkg.package_root != dev_dep_canonical),
5198            "manifest fallback must not pull pure dev-only path deps into runtime closure"
5199        );
5200    }
5201
5202    #[cfg(unix)]
5203    #[test]
5204    fn manifest_fallback_resolves_workspace_shared_path_dependencies() {
5205        let fixture = TopologyFixture::new("manifest-workspace-shared");
5206        let scenario_root = fixture.canonical_root.join("manifest_workspace_shared");
5207        let workspace_root = scenario_root.join("workspace");
5208        let app_root = workspace_root.join("crates/app");
5209        let shared_root = scenario_root.join("shared/shared_dep");
5210
5211        write_lib_crate(&shared_root, "shared_dep", &[]);
5212        std::fs::create_dir_all(app_root.join("src")).expect("create app src");
5213        std::fs::write(
5214            app_root.join("Cargo.toml"),
5215            r#"[package]
5216name = "workspace_shared_app"
5217version = "0.1.0"
5218edition = "2024"
5219
5220[dependencies]
5221shared_dep = { workspace = true }
5222"#,
5223        )
5224        .expect("write app manifest");
5225        std::fs::write(app_root.join("src/main.rs"), "fn main() {}\n").expect("write app main");
5226        std::fs::create_dir_all(&workspace_root).expect("create workspace root");
5227        std::fs::write(
5228            workspace_root.join("Cargo.toml"),
5229            r#"[workspace]
5230members = ["crates/app"]
5231resolver = "3"
5232
5233[workspace.dependencies]
5234shared_dep = { path = "../shared/shared_dep" }
5235"#,
5236        )
5237        .expect("write workspace manifest");
5238
5239        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
5240            &workspace_root,
5241            &fixture.policy(),
5242            |_| {
5243                Err(CargoPathDependencyError::new(
5244                    CargoPathDependencyErrorKind::MetadataInvocationFailure,
5245                    "force manifest fallback",
5246                ))
5247            },
5248        )
5249        .expect("manifest fallback should resolve workspace-shared path dependencies");
5250
5251        let app_canonical = app_root.canonicalize().expect("canonical app");
5252        let shared_canonical = shared_root.canonicalize().expect("canonical shared dep");
5253        assert_eq!(graph.root_packages, vec![app_canonical.clone()]);
5254        assert_eq!(
5255            graph.edges,
5256            vec![CargoPathDependencyEdge {
5257                from: app_canonical,
5258                to: shared_canonical,
5259                dependency_name: "shared_dep".to_string(),
5260            }]
5261        );
5262    }
5263
5264    #[cfg(unix)]
5265    #[test]
5266    fn manifest_fallback_resolves_foreign_workspace_inherited_path_dependencies() {
5267        let fixture = TopologyFixture::new("manifest-foreign-workspace-shared");
5268        let scenario_root = fixture
5269            .canonical_root
5270            .join("manifest_foreign_workspace_shared");
5271        let app_root = scenario_root.join("app");
5272        let foreign_workspace = scenario_root.join("foreign");
5273        let api_root = foreign_workspace.join("crates/api");
5274        let core_root = foreign_workspace.join("crates/core");
5275
5276        write_lib_crate(&core_root, "foreign_core", &[]);
5277        std::fs::create_dir_all(api_root.join("src")).expect("create foreign api src");
5278        std::fs::write(
5279            api_root.join("Cargo.toml"),
5280            r#"[package]
5281name = "foreign_api"
5282version = "0.1.0"
5283edition = "2024"
5284
5285[dependencies]
5286foreign_core = { workspace = true }
5287asupersync = { workspace = true }
5288"#,
5289        )
5290        .expect("write foreign api manifest");
5291        std::fs::write(api_root.join("src/lib.rs"), "pub fn api() {}\n")
5292            .expect("write foreign api lib");
5293        std::fs::write(
5294            foreign_workspace.join("Cargo.toml"),
5295            r#"[workspace]
5296members = ["crates/api", "crates/core"]
5297resolver = "3"
5298
5299[workspace.dependencies]
5300foreign_core = { path = "crates/core" }
5301asupersync = "0.3.9"
5302"#,
5303        )
5304        .expect("write foreign workspace manifest");
5305        write_bin_crate(
5306            &app_root,
5307            "foreign_workspace_app",
5308            &[("foreign_api", "../foreign/crates/api")],
5309        );
5310
5311        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
5312            &app_root,
5313            &fixture.policy(),
5314            |_| {
5315                Err(CargoPathDependencyError::new(
5316                    CargoPathDependencyErrorKind::MetadataInvocationFailure,
5317                    "force manifest fallback",
5318                ))
5319            },
5320        )
5321        .expect("manifest fallback should traverse the foreign workspace dependency");
5322
5323        let app = app_root.canonicalize().expect("canonical app");
5324        let api = api_root.canonicalize().expect("canonical api");
5325        let core = core_root.canonicalize().expect("canonical core");
5326        assert_eq!(graph.root_packages, vec![app.clone()]);
5327        assert_eq!(
5328            graph.edges,
5329            vec![
5330                CargoPathDependencyEdge {
5331                    from: app,
5332                    to: api.clone(),
5333                    dependency_name: "foreign_api".to_string(),
5334                },
5335                CargoPathDependencyEdge {
5336                    from: api,
5337                    to: core,
5338                    dependency_name: "foreign_core".to_string(),
5339                },
5340            ]
5341        );
5342        let materialization =
5343            resolve_materialization_from_manifests(&app_root.join("Cargo.toml"), &fixture.policy())
5344                .expect("materialization fallback should retain the same foreign path closure");
5345        assert_eq!(materialization.edges, graph.edges);
5346
5347        std::fs::write(
5348            foreign_workspace.join("Cargo.toml"),
5349            "[workspace]\nmembers = [\"crates/api\", \"crates/core\"]\nresolver = \"3\"\n\n[workspace.dependencies]\nasupersync = \"0.3.9\"\n",
5350        )
5351        .expect("remove inherited dependency declaration");
5352        let refusal = resolve_cargo_path_dependency_graph_with_policy_and_provider(
5353            &app_root,
5354            &fixture.policy(),
5355            |_| {
5356                Err(CargoPathDependencyError::new(
5357                    CargoPathDependencyErrorKind::MetadataInvocationFailure,
5358                    "force manifest fallback",
5359                ))
5360            },
5361        )
5362        .expect_err("missing foreign workspace dependency must fail closed");
5363        assert_eq!(
5364            refusal.kind(),
5365            &CargoPathDependencyErrorKind::ManifestParseFailure
5366        );
5367        assert!(
5368            refusal
5369                .detail()
5370                .contains("workspace-inherited dependency 'foreign_core' is not defined")
5371        );
5372    }
5373
5374    #[cfg(unix)]
5375    #[test]
5376    fn manifest_fallback_resolves_patch_path_dependencies() {
5377        let fixture = TopologyFixture::new("manifest-patch-shared");
5378        let scenario_root = fixture.canonical_root.join("manifest_patch_shared");
5379        let workspace_root = scenario_root.join("workspace");
5380        let app_root = workspace_root.join("app");
5381        let patched_root = scenario_root.join("patched/patched_dep");
5382
5383        write_lib_crate(&patched_root, "patched_dep", &[]);
5384        std::fs::create_dir_all(app_root.join("src")).expect("create app src");
5385        std::fs::write(
5386            app_root.join("Cargo.toml"),
5387            r#"[package]
5388name = "patched_app"
5389version = "0.1.0"
5390edition = "2024"
5391
5392[dependencies]
5393patched_dep = "0.1"
5394"#,
5395        )
5396        .expect("write app manifest");
5397        std::fs::write(app_root.join("src/main.rs"), "fn main() {}\n").expect("write app main");
5398        std::fs::create_dir_all(&workspace_root).expect("create workspace root");
5399        std::fs::write(
5400            workspace_root.join("Cargo.toml"),
5401            r#"[workspace]
5402members = ["app"]
5403resolver = "3"
5404
5405[patch.crates-io]
5406patched_dep = { path = "../patched/patched_dep" }
5407"#,
5408        )
5409        .expect("write workspace manifest");
5410
5411        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
5412            &workspace_root,
5413            &fixture.policy(),
5414            |_| {
5415                Err(CargoPathDependencyError::new(
5416                    CargoPathDependencyErrorKind::MetadataInvocationFailure,
5417                    "force manifest fallback",
5418                ))
5419            },
5420        )
5421        .expect("manifest fallback should resolve patch path dependencies");
5422
5423        let app_canonical = app_root.canonicalize().expect("canonical app");
5424        let patched_canonical = patched_root.canonicalize().expect("canonical patched dep");
5425        assert_eq!(graph.root_packages, vec![app_canonical.clone()]);
5426        assert_eq!(
5427            graph.edges,
5428            vec![CargoPathDependencyEdge {
5429                from: app_canonical,
5430                to: patched_canonical,
5431                dependency_name: "patched_dep".to_string(),
5432            }]
5433        );
5434    }
5435
5436    #[cfg(unix)]
5437    #[test]
5438    fn manifest_fallback_accepts_absolute_deps_that_normalize_under_policy() {
5439        let id = FIXTURE_COUNTER.fetch_add(1, Ordering::SeqCst);
5440        let root = std::env::temp_dir().join(format!(
5441            "rch-cargo-path-deps-mixed-roots-{}-{}",
5442            std::process::id(),
5443            id
5444        ));
5445        let user_root = root.join("users/jemanuel");
5446        let user_projects = user_root.join("projects");
5447        let user_dp = user_root.join("dp");
5448        let dp_alias = root.join("dp");
5449        let data_projects = root.join("data/projects");
5450        let app_root = user_projects.join("app");
5451        let data_dep_link = data_projects.join("asupersync");
5452        let projects_dep_link = user_projects.join("asupersync");
5453        let asupersync_root = user_dp.join("asupersync");
5454        let frankentui_root = user_dp.join("frankentui");
5455
5456        fs::create_dir_all(&app_root).expect("create app root");
5457        fs::create_dir_all(&data_projects).expect("create data projects root");
5458        write_lib_crate(&asupersync_root, "asupersync", &[]);
5459        write_lib_crate(&frankentui_root, "frankentui", &[]);
5460        symlink(&user_dp, &dp_alias).expect("create /dp-style alias");
5461        symlink(&asupersync_root, &projects_dep_link).expect("create canonical project symlink");
5462        symlink(&projects_dep_link, &data_dep_link).expect("create /data/projects symlink entry");
5463
5464        let manifest = format!(
5465            "[package]\nname = \"mixed_roots_app\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\nasupersync = {{ path = \"{}\" }}\nfrankentui = {{ path = \"{}\" }}\n",
5466            data_dep_link.display(),
5467            dp_alias.join("frankentui").display(),
5468        );
5469        fs::create_dir_all(app_root.join("src")).expect("create app src");
5470        fs::write(app_root.join("Cargo.toml"), manifest).expect("write app manifest");
5471        fs::write(app_root.join("src/lib.rs"), "pub fn app() {}\n").expect("write app lib");
5472
5473        let policy = PathTopologyPolicy::new(user_root.clone(), dp_alias);
5474        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
5475            &app_root,
5476            &policy,
5477            |_| {
5478                Err(CargoPathDependencyError::new(
5479                    CargoPathDependencyErrorKind::MetadataInvocationFailure,
5480                    "force manifest fallback",
5481                ))
5482            },
5483        )
5484        .expect("mixed absolute dependency roots should normalize under policy");
5485
5486        let dependency_roots = graph
5487            .edges
5488            .iter()
5489            .map(|edge| (edge.dependency_name.as_str(), edge.to.clone()))
5490            .collect::<BTreeMap<_, _>>();
5491        assert_eq!(
5492            dependency_roots.get("asupersync"),
5493            Some(
5494                &asupersync_root
5495                    .canonicalize()
5496                    .expect("canonical asupersync")
5497            )
5498        );
5499        assert_eq!(
5500            dependency_roots.get("frankentui"),
5501            Some(
5502                &frankentui_root
5503                    .canonicalize()
5504                    .expect("canonical frankentui")
5505            )
5506        );
5507
5508        let _ = fs::remove_dir_all(root);
5509    }
5510
5511    #[cfg(unix)]
5512    #[test]
5513    fn standalone_crate_no_dependencies() {
5514        let fixture = TopologyFixture::new("standalone");
5515        let crate_root = fixture.canonical_root.join("standalone_crate");
5516
5517        write_bin_crate(&crate_root, "standalone", &[]);
5518
5519        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
5520            &crate_root,
5521            &fixture.policy(),
5522            |_| Ok("{not-json".to_string()), // force fallback
5523        )
5524        .expect("standalone crate should resolve");
5525
5526        assert_eq!(graph.packages.len(), 1);
5527        assert_eq!(graph.packages[0].package_name, "standalone");
5528        assert!(graph.edges.is_empty());
5529    }
5530
5531    // ── PartialGraph internal behavior ──
5532
5533    #[test]
5534    fn partial_graph_deduplicates_edges() {
5535        let mut partial = PartialGraph::default();
5536        partial.add_edge(PathBuf::from("/a"), PathBuf::from("/b"), "b".to_string());
5537        partial.add_edge(PathBuf::from("/a"), PathBuf::from("/b"), "b".to_string());
5538
5539        let edges = partial.adjacency.get(Path::new("/a")).unwrap();
5540        assert_eq!(
5541            edges.len(),
5542            1,
5543            "duplicate edge should be deduplicated by BTreeSet"
5544        );
5545    }
5546
5547    #[test]
5548    fn partial_graph_add_package_updates_name_from_default() {
5549        let mut partial = PartialGraph::default();
5550        let root = PathBuf::from("/project/my_crate");
5551
5552        // First add with default name
5553        partial.add_package(
5554            root.clone(),
5555            root.join("Cargo.toml"),
5556            default_package_name(&root),
5557            false,
5558        );
5559        assert_eq!(
5560            partial.packages.get(&root).unwrap().package_name,
5561            "my_crate"
5562        );
5563
5564        // Second add with real name should update
5565        partial.add_package(
5566            root.clone(),
5567            root.join("Cargo.toml"),
5568            "real_name".to_string(),
5569            false,
5570        );
5571        assert_eq!(
5572            partial.packages.get(&root).unwrap().package_name,
5573            "real_name"
5574        );
5575    }
5576
5577    #[test]
5578    fn partial_graph_add_package_or_promotes_workspace_member() {
5579        let mut partial = PartialGraph::default();
5580        let root = PathBuf::from("/project");
5581
5582        partial.add_package(
5583            root.clone(),
5584            root.join("Cargo.toml"),
5585            "pkg".to_string(),
5586            false,
5587        );
5588        assert!(!partial.packages.get(&root).unwrap().workspace_member);
5589
5590        // Adding again with workspace_member=true should promote
5591        partial.add_package(
5592            root.clone(),
5593            root.join("Cargo.toml"),
5594            "pkg".to_string(),
5595            true,
5596        );
5597        assert!(partial.packages.get(&root).unwrap().workspace_member);
5598    }
5599
5600    /// Regression: `invoke_cargo_metadata` previously deadlocked when the JSON
5601    /// output exceeded the OS pipe capacity (~64 KB on Linux). The poll loop
5602    /// never read stdout, so the child blocked on `write()` and we always hit
5603    /// the 30s timeout. We now drain stdout/stderr concurrently in background
5604    /// threads. This test forces a large output by pulling in dozens of
5605    /// crates.io dependencies and asserting the call returns successfully
5606    /// well under the timeout.
5607    #[cfg(unix)]
5608    #[test]
5609    fn invoke_cargo_metadata_handles_output_larger_than_pipe_buffer() {
5610        let fixture = TopologyFixture::new("metadata-large");
5611        let project_root = fixture.canonical_root.join("metadata_pipe");
5612        fs::create_dir_all(project_root.join("src")).expect("create src");
5613
5614        // 60+ small dependencies inflate the cargo metadata JSON well past
5615        // the 64KB pipe buffer once each is resolved with version, source,
5616        // checksum, and feature lists.
5617        let mut deps = String::new();
5618        for name in [
5619            "serde",
5620            "serde_json",
5621            "anyhow",
5622            "thiserror",
5623            "tokio",
5624            "futures",
5625            "log",
5626            "tracing",
5627            "regex",
5628            "chrono",
5629            "uuid",
5630            "rand",
5631            "base64",
5632            "hex",
5633            "url",
5634            "bytes",
5635            "clap",
5636            "rusqlite",
5637            "blake3",
5638            "sha2",
5639            "reqwest",
5640            "tempfile",
5641            "directories",
5642            "dirs",
5643            "shellexpand",
5644            "shell-escape",
5645            "which",
5646            "openssh",
5647            "schemars",
5648            "indexmap",
5649            "lazy_static",
5650            "once_cell",
5651            "parking_lot",
5652            "toml",
5653            "shell-words",
5654            "globset",
5655            "walkdir",
5656            "ignore",
5657            "crossbeam-channel",
5658            "rayon",
5659            "memchr",
5660            "smallvec",
5661            "ahash",
5662            "fnv",
5663            "itertools",
5664            "num_cpus",
5665            "humantime",
5666            "humansize",
5667            "ratatui",
5668            "crossterm",
5669        ] {
5670            deps.push_str(&format!("{name} = \"*\"\n"));
5671        }
5672
5673        fs::write(
5674            project_root.join("Cargo.toml"),
5675            format!(
5676                "[package]\nname = \"metadata_pipe\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[workspace]\nresolver = \"2\"\n\n[dependencies]\n{deps}",
5677            ),
5678        )
5679        .expect("write manifest");
5680        fs::write(project_root.join("src/lib.rs"), "").expect("write lib.rs");
5681
5682        let manifest_path = project_root.join("Cargo.toml");
5683        let start = std::time::Instant::now();
5684        match invoke_cargo_metadata(&manifest_path) {
5685            Ok(json) => {
5686                let elapsed = start.elapsed();
5687                // Without the fix the function would either time out at 30s
5688                // or (depending on dependency cache) produce a truncated
5689                // result; both paths fail this assertion.
5690                assert!(
5691                    elapsed < CARGO_METADATA_TIMEOUT,
5692                    "metadata took {elapsed:?}, near the {CARGO_METADATA_TIMEOUT:?} timeout"
5693                );
5694                assert!(
5695                    json.len() > 64 * 1024,
5696                    "test fixture too small to exercise the pipe-buffer path: {} bytes",
5697                    json.len()
5698                );
5699                assert!(
5700                    json.starts_with('{'),
5701                    "metadata stdout should be JSON; got: {:.120}",
5702                    json
5703                );
5704            }
5705            Err(e) => {
5706                // Skip on machines without network access to crates.io or
5707                // without registry caches — we only care about the
5708                // *deadlock* regression, not about resolver outcomes.
5709                let detail = e.detail().to_lowercase();
5710                let offline = detail.contains("network")
5711                    || detail.contains("dns")
5712                    || detail.contains("registry")
5713                    || detail.contains("connection")
5714                    || detail.contains("not found")
5715                    || detail.contains("offline")
5716                    || detail.contains("could not")
5717                    || detail.contains("failed to fetch")
5718                    || detail.contains("no such file");
5719                assert!(
5720                    offline,
5721                    "invoke_cargo_metadata failed for non-network reason: {e}"
5722                );
5723            }
5724        }
5725    }
5726}