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_excludes: Vec<String>,
1917    workspace_dependency_names: BTreeSet<String>,
1918    workspace_path_dependencies: BTreeMap<String, String>,
1919    patch_path_dependencies: BTreeMap<String, String>,
1920    path_dependencies: Vec<ManifestDependency>,
1921    materialization_path_dependencies: Vec<ManifestDependency>,
1922}
1923
1924#[derive(Debug, Clone)]
1925struct ManifestDependency {
1926    dependency_name: String,
1927    dependency_path: Option<String>,
1928    uses_workspace_inheritance: bool,
1929}
1930
1931fn resolve_from_manifest_fallback(
1932    entry_manifest_path: &Path,
1933    policy: &PathTopologyPolicy,
1934) -> Result<CargoPathDependencyGraph, CargoPathDependencyError> {
1935    let entry_manifest = read_manifest_document(entry_manifest_path)?;
1936    let entry_root = entry_manifest_path.parent().ok_or_else(|| {
1937        CargoPathDependencyError::new(
1938            CargoPathDependencyErrorKind::ManifestParseFailure,
1939            format!(
1940                "entry manifest has no parent: {}",
1941                entry_manifest_path.display()
1942            ),
1943        )
1944        .with_manifest_path(entry_manifest_path)
1945    })?;
1946    let entry_root = normalize_path_for_policy(
1947        entry_root,
1948        policy,
1949        Some(entry_manifest_path),
1950        None,
1951        "normalize fallback entry root",
1952    )?;
1953
1954    let mut partial = PartialGraph::default();
1955    if entry_manifest.has_workspace {
1956        partial.workspace_root = Some(entry_root.clone());
1957    }
1958    let workspace_path_dependencies = entry_manifest.workspace_path_dependencies.clone();
1959    let patch_path_dependencies = entry_manifest.patch_path_dependencies.clone();
1960
1961    let workspace_member_manifests = expand_workspace_members(
1962        &entry_root,
1963        &entry_manifest.workspace_members,
1964        &entry_manifest.workspace_excludes,
1965        entry_manifest_path,
1966    )?;
1967    let workspace_member_set = workspace_member_manifests
1968        .iter()
1969        .cloned()
1970        .collect::<BTreeSet<_>>();
1971    let include_entry_manifest =
1972        entry_manifest.package_name.is_some() || workspace_member_set.is_empty();
1973
1974    let mut manifest_cache: BTreeMap<PathBuf, ManifestDocument> = BTreeMap::new();
1975    let mut states: BTreeMap<PathBuf, VisitState> = BTreeMap::new();
1976    let mut stack: Vec<PathBuf> = Vec::new();
1977
1978    for manifest_path in &workspace_member_set {
1979        visit_manifest_recursive(
1980            manifest_path,
1981            policy,
1982            &entry_root,
1983            &workspace_member_set,
1984            &workspace_path_dependencies,
1985            &patch_path_dependencies,
1986            true,
1987            &mut partial,
1988            &mut manifest_cache,
1989            &mut states,
1990            &mut stack,
1991        )?;
1992    }
1993    if include_entry_manifest {
1994        visit_manifest_recursive(
1995            entry_manifest_path,
1996            policy,
1997            &entry_root,
1998            &workspace_member_set,
1999            &workspace_path_dependencies,
2000            &patch_path_dependencies,
2001            true,
2002            &mut partial,
2003            &mut manifest_cache,
2004            &mut states,
2005            &mut stack,
2006        )?;
2007    }
2008
2009    finalize_graph(entry_manifest_path.to_path_buf(), partial)
2010}
2011
2012fn resolve_materialization_from_manifests(
2013    entry_manifest_path: &Path,
2014    policy: &PathTopologyPolicy,
2015) -> Result<CargoPathMaterializationClosure, CargoPathDependencyError> {
2016    let entry_manifest = read_manifest_document(entry_manifest_path)?;
2017    let entry_root = entry_manifest_path.parent().ok_or_else(|| {
2018        CargoPathDependencyError::new(
2019            CargoPathDependencyErrorKind::ManifestParseFailure,
2020            format!(
2021                "entry manifest has no parent: {}",
2022                entry_manifest_path.display()
2023            ),
2024        )
2025        .with_manifest_path(entry_manifest_path)
2026    })?;
2027    let entry_root = normalize_path_for_policy(
2028        entry_root,
2029        policy,
2030        Some(entry_manifest_path),
2031        None,
2032        "normalize materialization entry root",
2033    )?;
2034
2035    let mut partial = PartialGraph::default();
2036    if entry_manifest.has_workspace {
2037        partial.workspace_root = Some(entry_root.clone());
2038    }
2039    let workspace_path_dependencies = entry_manifest.workspace_path_dependencies.clone();
2040    let patch_path_dependencies = entry_manifest.patch_path_dependencies.clone();
2041    let workspace_member_manifests = expand_workspace_members(
2042        &entry_root,
2043        &entry_manifest.workspace_members,
2044        &entry_manifest.workspace_excludes,
2045        entry_manifest_path,
2046    )?;
2047    let workspace_member_set = workspace_member_manifests
2048        .iter()
2049        .cloned()
2050        .collect::<BTreeSet<_>>();
2051    let include_entry_manifest =
2052        entry_manifest.package_name.is_some() || workspace_member_set.is_empty();
2053
2054    let mut manifest_cache = BTreeMap::from([(entry_manifest_path.to_path_buf(), entry_manifest)]);
2055    let mut visited = BTreeSet::new();
2056
2057    for manifest_path in &workspace_member_set {
2058        visit_manifest_for_materialization(
2059            manifest_path,
2060            policy,
2061            &entry_root,
2062            &workspace_member_set,
2063            &workspace_path_dependencies,
2064            &patch_path_dependencies,
2065            true,
2066            &mut partial,
2067            &mut manifest_cache,
2068            &mut visited,
2069        )?;
2070    }
2071    if include_entry_manifest {
2072        visit_manifest_for_materialization(
2073            entry_manifest_path,
2074            policy,
2075            &entry_root,
2076            &workspace_member_set,
2077            &workspace_path_dependencies,
2078            &patch_path_dependencies,
2079            true,
2080            &mut partial,
2081            &mut manifest_cache,
2082            &mut visited,
2083        )?;
2084    }
2085
2086    for (dependency_name, raw_dependency_path) in workspace_path_dependencies
2087        .iter()
2088        .chain(patch_path_dependencies.iter())
2089    {
2090        let dependency_candidate = resolve_dependency_candidate(&entry_root, raw_dependency_path);
2091        let (_, dependency_manifest) = resolve_materialization_dependency_manifest(
2092            &dependency_candidate,
2093            policy,
2094            entry_manifest_path,
2095            dependency_name,
2096        )?;
2097        visit_manifest_for_materialization(
2098            &dependency_manifest,
2099            policy,
2100            &entry_root,
2101            &workspace_member_set,
2102            &workspace_path_dependencies,
2103            &patch_path_dependencies,
2104            false,
2105            &mut partial,
2106            &mut manifest_cache,
2107            &mut visited,
2108        )?;
2109    }
2110
2111    Ok(finalize_materialization_closure(
2112        entry_manifest_path.to_path_buf(),
2113        partial,
2114    ))
2115}
2116
2117#[allow(clippy::too_many_arguments)]
2118fn visit_manifest_for_materialization(
2119    manifest_path: &Path,
2120    policy: &PathTopologyPolicy,
2121    workspace_root: &Path,
2122    workspace_member_manifests: &BTreeSet<PathBuf>,
2123    workspace_path_dependencies: &BTreeMap<String, String>,
2124    patch_path_dependencies: &BTreeMap<String, String>,
2125    mark_workspace_member: bool,
2126    partial: &mut PartialGraph,
2127    manifest_cache: &mut BTreeMap<PathBuf, ManifestDocument>,
2128    visited: &mut BTreeSet<PathBuf>,
2129) -> Result<(), CargoPathDependencyError> {
2130    let manifest_root = manifest_path.parent().ok_or_else(|| {
2131        CargoPathDependencyError::new(
2132            CargoPathDependencyErrorKind::ManifestParseFailure,
2133            format!("manifest has no parent: {}", manifest_path.display()),
2134        )
2135        .with_manifest_path(manifest_path)
2136    })?;
2137    let package_root = normalize_path_for_policy(
2138        manifest_root,
2139        policy,
2140        Some(manifest_path),
2141        None,
2142        "normalize materialization package root",
2143    )?;
2144    let canonical_manifest = package_root.join("Cargo.toml");
2145    if !canonical_manifest.is_file() {
2146        return Err(CargoPathDependencyError::new(
2147            CargoPathDependencyErrorKind::ManifestParseFailure,
2148            format!("manifest file missing: {}", canonical_manifest.display()),
2149        )
2150        .with_manifest_path(canonical_manifest));
2151    }
2152
2153    if !visited.insert(package_root.clone()) {
2154        if mark_workspace_member {
2155            partial.add_root(package_root.clone());
2156            if let Some(package) = partial.packages.get_mut(&package_root) {
2157                package.workspace_member = true;
2158            }
2159        }
2160        return Ok(());
2161    }
2162
2163    let manifest = if let Some(cached) = manifest_cache.get(&canonical_manifest) {
2164        cached.clone()
2165    } else {
2166        let parsed = read_manifest_document(&canonical_manifest)?;
2167        manifest_cache.insert(canonical_manifest.clone(), parsed.clone());
2168        parsed
2169    };
2170
2171    let workspace_member =
2172        mark_workspace_member || workspace_member_manifests.contains(&canonical_manifest);
2173    partial.add_package(
2174        package_root.clone(),
2175        canonical_manifest.clone(),
2176        manifest
2177            .package_name
2178            .clone()
2179            .unwrap_or_else(|| default_package_name(&package_root)),
2180        workspace_member,
2181    );
2182    if workspace_member {
2183        partial.add_root(package_root.clone());
2184    }
2185
2186    for dependency in &manifest.materialization_path_dependencies {
2187        let Some(dependency_candidate) = resolve_manifest_dependency_candidate(
2188            &package_root,
2189            workspace_root,
2190            dependency,
2191            workspace_path_dependencies,
2192            patch_path_dependencies,
2193            &canonical_manifest,
2194        )?
2195        else {
2196            continue;
2197        };
2198        let (dependency_root, dependency_manifest) = resolve_materialization_dependency_manifest(
2199            &dependency_candidate,
2200            policy,
2201            &canonical_manifest,
2202            &dependency.dependency_name,
2203        )?;
2204
2205        partial.add_edge(
2206            package_root.clone(),
2207            dependency_root,
2208            dependency.dependency_name.clone(),
2209        );
2210        visit_manifest_for_materialization(
2211            &dependency_manifest,
2212            policy,
2213            workspace_root,
2214            workspace_member_manifests,
2215            workspace_path_dependencies,
2216            patch_path_dependencies,
2217            false,
2218            partial,
2219            manifest_cache,
2220            visited,
2221        )?;
2222    }
2223
2224    Ok(())
2225}
2226
2227fn resolve_materialization_dependency_manifest(
2228    dependency_candidate: &Path,
2229    policy: &PathTopologyPolicy,
2230    declaring_manifest: &Path,
2231    dependency_name: &str,
2232) -> Result<(PathBuf, PathBuf), CargoPathDependencyError> {
2233    validate_absolute_dependency_scope(
2234        dependency_candidate,
2235        policy,
2236        declaring_manifest,
2237        dependency_name,
2238        "materialization dependency path policy violation",
2239    )?;
2240    if !dependency_candidate.exists() {
2241        return Err(CargoPathDependencyError::new(
2242            CargoPathDependencyErrorKind::MissingPathDependency,
2243            format!(
2244                "materialization dependency path does not exist: {}",
2245                dependency_candidate.display()
2246            ),
2247        )
2248        .with_manifest_path(declaring_manifest)
2249        .with_dependency_name(dependency_name.to_string())
2250        .with_dependency_path(dependency_candidate));
2251    }
2252
2253    let dependency_root = normalize_path_for_policy(
2254        dependency_candidate,
2255        policy,
2256        Some(declaring_manifest),
2257        Some(dependency_name),
2258        "normalize materialization dependency path",
2259    )?;
2260    let dependency_manifest = dependency_root.join("Cargo.toml");
2261    if !dependency_manifest.is_file() {
2262        return Err(CargoPathDependencyError::new(
2263            CargoPathDependencyErrorKind::MissingPathDependency,
2264            format!(
2265                "materialization dependency manifest missing: {}",
2266                dependency_manifest.display()
2267            ),
2268        )
2269        .with_manifest_path(declaring_manifest)
2270        .with_dependency_name(dependency_name.to_string())
2271        .with_dependency_path(dependency_manifest.clone()));
2272    }
2273
2274    Ok((dependency_root, dependency_manifest))
2275}
2276
2277fn finalize_materialization_closure(
2278    entry_manifest_path: PathBuf,
2279    partial: PartialGraph,
2280) -> CargoPathMaterializationClosure {
2281    let packages = partial
2282        .packages
2283        .iter()
2284        .map(|(root, package)| CargoPathDependencyPackage {
2285            package_root: root.clone(),
2286            manifest_path: package.manifest_path.clone(),
2287            package_name: package.package_name.clone(),
2288            workspace_member: package.workspace_member,
2289        })
2290        .collect();
2291    let edges = partial
2292        .adjacency
2293        .iter()
2294        .flat_map(|(from, tails)| {
2295            tails.iter().map(|tail| CargoPathDependencyEdge {
2296                from: from.clone(),
2297                to: tail.to.clone(),
2298                dependency_name: tail.dependency_name.clone(),
2299            })
2300        })
2301        .collect();
2302
2303    CargoPathMaterializationClosure {
2304        entry_manifest_path,
2305        workspace_root: partial.workspace_root,
2306        root_packages: partial.roots.into_iter().collect(),
2307        packages,
2308        edges,
2309    }
2310}
2311
2312#[allow(clippy::too_many_arguments)]
2313fn visit_manifest_recursive(
2314    manifest_path: &Path,
2315    policy: &PathTopologyPolicy,
2316    workspace_root: &Path,
2317    workspace_member_manifests: &BTreeSet<PathBuf>,
2318    workspace_path_dependencies: &BTreeMap<String, String>,
2319    patch_path_dependencies: &BTreeMap<String, String>,
2320    mark_workspace_member: bool,
2321    partial: &mut PartialGraph,
2322    manifest_cache: &mut BTreeMap<PathBuf, ManifestDocument>,
2323    states: &mut BTreeMap<PathBuf, VisitState>,
2324    stack: &mut Vec<PathBuf>,
2325) -> Result<PathBuf, CargoPathDependencyError> {
2326    let manifest_root = manifest_path.parent().ok_or_else(|| {
2327        CargoPathDependencyError::new(
2328            CargoPathDependencyErrorKind::ManifestParseFailure,
2329            format!("manifest has no parent: {}", manifest_path.display()),
2330        )
2331        .with_manifest_path(manifest_path)
2332    })?;
2333    let package_root = normalize_path_for_policy(
2334        manifest_root,
2335        policy,
2336        Some(manifest_path),
2337        None,
2338        "normalize manifest package root",
2339    )?;
2340    let canonical_manifest = package_root.join("Cargo.toml");
2341    if !canonical_manifest.is_file() {
2342        return Err(CargoPathDependencyError::new(
2343            CargoPathDependencyErrorKind::ManifestParseFailure,
2344            format!("manifest file missing: {}", canonical_manifest.display()),
2345        )
2346        .with_manifest_path(canonical_manifest));
2347    }
2348
2349    if states.get(&package_root) == Some(&VisitState::Visiting) {
2350        let cycle = cycle_from_stack(stack, &package_root);
2351        return Err(CargoPathDependencyError::new(
2352            CargoPathDependencyErrorKind::CyclicDependency,
2353            format!(
2354                "cyclic path dependency detected at {}",
2355                package_root.display()
2356            ),
2357        )
2358        .with_cycle(cycle));
2359    }
2360    if states.get(&package_root) == Some(&VisitState::Visited) {
2361        if mark_workspace_member {
2362            partial.add_root(package_root.clone());
2363            if let Some(package) = partial.packages.get_mut(&package_root) {
2364                package.workspace_member = true;
2365            }
2366        }
2367        return Ok(package_root);
2368    }
2369
2370    states.insert(package_root.clone(), VisitState::Visiting);
2371    stack.push(package_root.clone());
2372
2373    let manifest = if let Some(cached) = manifest_cache.get(&canonical_manifest) {
2374        cached.clone()
2375    } else {
2376        let parsed = read_manifest_document(&canonical_manifest)?;
2377        manifest_cache.insert(canonical_manifest.clone(), parsed.clone());
2378        parsed
2379    };
2380
2381    let workspace_member =
2382        mark_workspace_member || workspace_member_manifests.contains(&canonical_manifest);
2383    partial.add_package(
2384        package_root.clone(),
2385        canonical_manifest.clone(),
2386        manifest
2387            .package_name
2388            .clone()
2389            .unwrap_or_else(|| default_package_name(&package_root)),
2390        workspace_member,
2391    );
2392    if workspace_member {
2393        partial.add_root(package_root.clone());
2394    }
2395
2396    for dependency in &manifest.path_dependencies {
2397        let Some(dependency_candidate) = resolve_manifest_dependency_candidate(
2398            &package_root,
2399            workspace_root,
2400            dependency,
2401            workspace_path_dependencies,
2402            patch_path_dependencies,
2403            &canonical_manifest,
2404        )?
2405        else {
2406            continue;
2407        };
2408        validate_absolute_dependency_scope(
2409            &dependency_candidate,
2410            policy,
2411            &canonical_manifest,
2412            &dependency.dependency_name,
2413            "manifest dependency path policy violation",
2414        )?;
2415        if !dependency_candidate.exists() {
2416            return Err(CargoPathDependencyError::new(
2417                CargoPathDependencyErrorKind::MissingPathDependency,
2418                format!(
2419                    "dependency path does not exist: {}",
2420                    dependency_candidate.display()
2421                ),
2422            )
2423            .with_manifest_path(canonical_manifest.clone())
2424            .with_dependency_name(dependency.dependency_name.clone())
2425            .with_dependency_path(dependency_candidate));
2426        }
2427
2428        let dependency_root = normalize_path_for_policy(
2429            &dependency_candidate,
2430            policy,
2431            Some(&canonical_manifest),
2432            Some(&dependency.dependency_name),
2433            "normalize manifest dependency path",
2434        )?;
2435        let dependency_manifest = dependency_root.join("Cargo.toml");
2436        if !dependency_manifest.is_file() {
2437            return Err(CargoPathDependencyError::new(
2438                CargoPathDependencyErrorKind::MissingPathDependency,
2439                format!(
2440                    "dependency manifest missing: {}",
2441                    dependency_manifest.display()
2442                ),
2443            )
2444            .with_manifest_path(canonical_manifest.clone())
2445            .with_dependency_name(dependency.dependency_name.clone())
2446            .with_dependency_path(dependency_manifest));
2447        }
2448
2449        partial.add_edge(
2450            package_root.clone(),
2451            dependency_root.clone(),
2452            dependency.dependency_name.clone(),
2453        );
2454        visit_manifest_recursive(
2455            &dependency_manifest,
2456            policy,
2457            workspace_root,
2458            workspace_member_manifests,
2459            workspace_path_dependencies,
2460            patch_path_dependencies,
2461            false,
2462            partial,
2463            manifest_cache,
2464            states,
2465            stack,
2466        )?;
2467    }
2468
2469    stack.pop();
2470    states.insert(package_root, VisitState::Visited);
2471    Ok(canonical_manifest
2472        .parent()
2473        .unwrap_or_else(|| Path::new("/"))
2474        .to_path_buf())
2475}
2476
2477fn resolve_dependency_candidate(base_root: &Path, raw_dependency_path: &str) -> PathBuf {
2478    let raw = PathBuf::from(raw_dependency_path);
2479    let resolved = if raw.is_absolute() {
2480        raw
2481    } else {
2482        base_root.join(raw)
2483    };
2484    if resolved
2485        .file_name()
2486        .is_some_and(|file_name| file_name == "Cargo.toml")
2487    {
2488        resolved.parent().map(Path::to_path_buf).unwrap_or(resolved)
2489    } else {
2490        resolved
2491    }
2492}
2493
2494fn resolve_manifest_dependency_candidate(
2495    package_root: &Path,
2496    workspace_root: &Path,
2497    dependency: &ManifestDependency,
2498    workspace_path_dependencies: &BTreeMap<String, String>,
2499    patch_path_dependencies: &BTreeMap<String, String>,
2500    declaring_manifest: &Path,
2501) -> Result<Option<PathBuf>, CargoPathDependencyError> {
2502    if let Some(raw_dependency_path) = dependency.dependency_path.as_deref() {
2503        return Ok(Some(resolve_dependency_candidate(
2504            package_root,
2505            raw_dependency_path,
2506        )));
2507    }
2508
2509    if dependency.uses_workspace_inheritance {
2510        if let Some(raw_dependency_path) =
2511            workspace_path_dependencies.get(&dependency.dependency_name)
2512        {
2513            return Ok(Some(resolve_dependency_candidate(
2514                workspace_root,
2515                raw_dependency_path,
2516            )));
2517        }
2518
2519        // A direct path dependency can itself be a member of a different
2520        // workspace. When cargo metadata is unavailable, its `{ workspace =
2521        // true }` dependencies must be resolved against that enclosing
2522        // workspace, not the entry package's workspace. Otherwise the fallback
2523        // stops at the directly named member and omits active transitive local
2524        // crates (for example fastapi -> fastapi-core).
2525        for ancestor in package_root.ancestors() {
2526            let candidate_manifest = ancestor.join("Cargo.toml");
2527            if candidate_manifest == declaring_manifest || !candidate_manifest.is_file() {
2528                continue;
2529            }
2530            let candidate = read_manifest_document(&candidate_manifest)?;
2531            if !candidate.has_workspace {
2532                continue;
2533            }
2534            let Some(raw_dependency_path) = candidate
2535                .workspace_path_dependencies
2536                .get(&dependency.dependency_name)
2537            else {
2538                if candidate
2539                    .workspace_dependency_names
2540                    .contains(&dependency.dependency_name)
2541                {
2542                    if let Some(raw_dependency_path) =
2543                        patch_path_dependencies.get(&dependency.dependency_name)
2544                    {
2545                        return Ok(Some(resolve_dependency_candidate(
2546                            workspace_root,
2547                            raw_dependency_path,
2548                        )));
2549                    }
2550                    // The dependency is inherited from the enclosing
2551                    // workspace, but resolves from a registry or Git rather
2552                    // than a local path. It is outside the source-sync closure.
2553                    return Ok(None);
2554                }
2555                return Err(CargoPathDependencyError::new(
2556                    CargoPathDependencyErrorKind::ManifestParseFailure,
2557                    format!(
2558                        "workspace-inherited dependency '{}' is not defined by {}",
2559                        dependency.dependency_name,
2560                        candidate_manifest.display()
2561                    ),
2562                )
2563                .with_manifest_path(declaring_manifest)
2564                .with_dependency_name(dependency.dependency_name.clone()));
2565            };
2566            return Ok(Some(resolve_dependency_candidate(
2567                ancestor,
2568                raw_dependency_path,
2569            )));
2570        }
2571        return Err(CargoPathDependencyError::new(
2572            CargoPathDependencyErrorKind::ManifestParseFailure,
2573            format!(
2574                "workspace-inherited dependency '{}' has no enclosing workspace",
2575                dependency.dependency_name
2576            ),
2577        )
2578        .with_manifest_path(declaring_manifest)
2579        .with_dependency_name(dependency.dependency_name.clone()));
2580    }
2581
2582    Ok(patch_path_dependencies
2583        .get(&dependency.dependency_name)
2584        .map(|raw_dependency_path| {
2585            resolve_dependency_candidate(workspace_root, raw_dependency_path)
2586        }))
2587}
2588
2589fn read_manifest_document(
2590    manifest_path: &Path,
2591) -> Result<ManifestDocument, CargoPathDependencyError> {
2592    let contents = std::fs::read_to_string(manifest_path).map_err(|error| {
2593        CargoPathDependencyError::new(
2594            CargoPathDependencyErrorKind::ManifestParseFailure,
2595            format!(
2596                "failed to read manifest {}: {error}",
2597                manifest_path.display()
2598            ),
2599        )
2600        .with_manifest_path(manifest_path)
2601    })?;
2602    let table = toml::from_str::<toml::Table>(&contents).map_err(|error| {
2603        CargoPathDependencyError::new(
2604            CargoPathDependencyErrorKind::ManifestParseFailure,
2605            format!(
2606                "failed to parse manifest {}: {error}",
2607                manifest_path.display()
2608            ),
2609        )
2610        .with_manifest_path(manifest_path)
2611    })?;
2612
2613    let package_name = table
2614        .get("package")
2615        .and_then(toml::Value::as_table)
2616        .and_then(|package| package.get("name"))
2617        .and_then(toml::Value::as_str)
2618        .map(ToOwned::to_owned);
2619    let has_workspace = table.contains_key("workspace");
2620    let workspace = table.get("workspace").and_then(toml::Value::as_table);
2621    let workspace_strings = |key: &str| {
2622        workspace
2623            .and_then(|workspace| workspace.get(key))
2624            .and_then(toml::Value::as_array)
2625            .map(|values| {
2626                values
2627                    .iter()
2628                    .filter_map(toml::Value::as_str)
2629                    .map(ToOwned::to_owned)
2630                    .collect::<Vec<_>>()
2631            })
2632            .unwrap_or_default()
2633    };
2634    let workspace_members = workspace_strings("members");
2635    let workspace_excludes = workspace_strings("exclude");
2636    let workspace_dependencies = table
2637        .get("workspace")
2638        .and_then(toml::Value::as_table)
2639        .and_then(|workspace| workspace.get("dependencies"));
2640    let workspace_dependency_names = workspace_dependencies
2641        .and_then(toml::Value::as_table)
2642        .map(|dependencies| dependencies.keys().cloned().collect())
2643        .unwrap_or_default();
2644    let workspace_path_dependencies = workspace_dependencies
2645        .map(collect_named_path_dependencies)
2646        .unwrap_or_default();
2647    let patch_path_dependencies = table
2648        .get("patch")
2649        .and_then(toml::Value::as_table)
2650        .map(collect_patch_path_dependencies)
2651        .unwrap_or_default();
2652
2653    let mut path_dependencies = Vec::new();
2654    collect_dependency_specs(table.get("dependencies"), &mut path_dependencies, false);
2655    collect_dependency_specs(
2656        table.get("build-dependencies"),
2657        &mut path_dependencies,
2658        false,
2659    );
2660
2661    if let Some(targets) = table.get("target").and_then(toml::Value::as_table) {
2662        for target_config in targets.values() {
2663            if let Some(target_table) = target_config.as_table() {
2664                collect_dependency_specs(
2665                    target_table.get("dependencies"),
2666                    &mut path_dependencies,
2667                    false,
2668                );
2669                collect_dependency_specs(
2670                    target_table.get("build-dependencies"),
2671                    &mut path_dependencies,
2672                    false,
2673                );
2674            }
2675        }
2676    }
2677
2678    let mut materialization_path_dependencies = Vec::new();
2679    for dependency_table in ["dependencies", "build-dependencies", "dev-dependencies"] {
2680        collect_dependency_specs(
2681            table.get(dependency_table),
2682            &mut materialization_path_dependencies,
2683            true,
2684        );
2685    }
2686    if let Some(targets) = table.get("target").and_then(toml::Value::as_table) {
2687        for target_config in targets.values() {
2688            if let Some(target_table) = target_config.as_table() {
2689                for dependency_table in ["dependencies", "build-dependencies", "dev-dependencies"] {
2690                    collect_dependency_specs(
2691                        target_table.get(dependency_table),
2692                        &mut materialization_path_dependencies,
2693                        true,
2694                    );
2695                }
2696            }
2697        }
2698    }
2699
2700    Ok(ManifestDocument {
2701        package_name,
2702        has_workspace,
2703        workspace_members,
2704        workspace_excludes,
2705        workspace_dependency_names,
2706        workspace_path_dependencies,
2707        patch_path_dependencies,
2708        path_dependencies,
2709        materialization_path_dependencies,
2710    })
2711}
2712
2713fn collect_dependency_specs(
2714    maybe_table_value: Option<&toml::Value>,
2715    collector: &mut Vec<ManifestDependency>,
2716    include_optional: bool,
2717) {
2718    let Some(table) = maybe_table_value.and_then(toml::Value::as_table) else {
2719        return;
2720    };
2721    for (dependency_name, dependency_value) in table {
2722        match dependency_value {
2723            toml::Value::String(_) => {
2724                collector.push(ManifestDependency {
2725                    dependency_name: dependency_name.clone(),
2726                    dependency_path: None,
2727                    uses_workspace_inheritance: false,
2728                });
2729            }
2730            toml::Value::Table(dependency_table) => {
2731                if !include_optional
2732                    && dependency_table
2733                        .get("optional")
2734                        .and_then(toml::Value::as_bool)
2735                        .unwrap_or(false)
2736                {
2737                    continue;
2738                }
2739                collector.push(ManifestDependency {
2740                    dependency_name: dependency_name.clone(),
2741                    dependency_path: dependency_table
2742                        .get("path")
2743                        .and_then(toml::Value::as_str)
2744                        .map(ToOwned::to_owned),
2745                    uses_workspace_inheritance: dependency_table
2746                        .get("workspace")
2747                        .and_then(toml::Value::as_bool)
2748                        .unwrap_or(false),
2749                });
2750            }
2751            _ => {}
2752        }
2753    }
2754}
2755
2756fn collect_named_path_dependencies(maybe_table_value: &toml::Value) -> BTreeMap<String, String> {
2757    let Some(table) = maybe_table_value.as_table() else {
2758        return BTreeMap::new();
2759    };
2760
2761    let mut dependencies = BTreeMap::new();
2762    for (dependency_name, dependency_value) in table {
2763        let Some(dependency_table) = dependency_value.as_table() else {
2764            continue;
2765        };
2766        let Some(path) = dependency_table.get("path").and_then(toml::Value::as_str) else {
2767            continue;
2768        };
2769        dependencies.insert(dependency_name.clone(), path.to_string());
2770    }
2771    dependencies
2772}
2773
2774fn collect_patch_path_dependencies(patch_table: &toml::value::Table) -> BTreeMap<String, String> {
2775    let mut dependencies = BTreeMap::new();
2776    for patch_source in patch_table.values() {
2777        dependencies.extend(collect_named_path_dependencies(patch_source));
2778    }
2779    dependencies
2780}
2781
2782fn workspace_manifest_candidate(candidate: PathBuf) -> PathBuf {
2783    if candidate
2784        .file_name()
2785        .is_some_and(|file_name| file_name == "Cargo.toml")
2786    {
2787        candidate
2788    } else {
2789        candidate.join("Cargo.toml")
2790    }
2791}
2792
2793fn expand_workspace_members(
2794    workspace_root: &Path,
2795    members: &[String],
2796    excludes: &[String],
2797    manifest_path: &Path,
2798) -> Result<Vec<PathBuf>, CargoPathDependencyError> {
2799    // Cargo applies workspace.exclude to the package paths produced by member
2800    // expansion. Filter at the corresponding manifest identity BEFORE
2801    // requiring Cargo.toml: an intentionally excluded helper directory may be
2802    // empty or contain arbitrary non-package files. Exact package matching is
2803    // important — excluding "crates/dev-tools" must not suppress an explicit
2804    // member "crates/dev-tools/probe".
2805    let mut excluded_manifests = BTreeSet::new();
2806    for exclude in excludes {
2807        let expanded_paths = expand_member_pattern(workspace_root, exclude).map_err(|error| {
2808            CargoPathDependencyError::new(
2809                CargoPathDependencyErrorKind::ManifestParseFailure,
2810                format!("failed to expand workspace exclude '{exclude}': {error}"),
2811            )
2812            .with_manifest_path(manifest_path)
2813        })?;
2814        excluded_manifests.extend(expanded_paths.into_iter().map(workspace_manifest_candidate));
2815    }
2816
2817    let mut manifests = BTreeSet::new();
2818    for member in members {
2819        let expanded_paths = expand_member_pattern(workspace_root, member).map_err(|error| {
2820            CargoPathDependencyError::new(
2821                CargoPathDependencyErrorKind::ManifestParseFailure,
2822                format!("failed to expand workspace member '{member}': {error}"),
2823            )
2824            .with_manifest_path(manifest_path)
2825        })?;
2826        for candidate in expanded_paths {
2827            let manifest_candidate = workspace_manifest_candidate(candidate);
2828            if excluded_manifests.contains(&manifest_candidate) {
2829                continue;
2830            }
2831            if !manifest_candidate.is_file() {
2832                return Err(CargoPathDependencyError::new(
2833                    CargoPathDependencyErrorKind::MissingPathDependency,
2834                    format!(
2835                        "workspace member manifest missing: {}",
2836                        manifest_candidate.display()
2837                    ),
2838                )
2839                .with_manifest_path(manifest_path)
2840                .with_dependency_name(member.clone())
2841                .with_dependency_path(manifest_candidate));
2842            }
2843            manifests.insert(manifest_candidate);
2844        }
2845    }
2846    Ok(manifests.into_iter().collect())
2847}
2848
2849fn expand_member_pattern(base: &Path, pattern: &str) -> Result<Vec<PathBuf>, std::io::Error> {
2850    if !contains_glob(pattern) {
2851        return Ok(vec![base.join(pattern)]);
2852    }
2853
2854    let mut candidates = vec![base.to_path_buf()];
2855    let normalized_pattern = pattern.replace('\\', "/");
2856    for segment in normalized_pattern.split('/') {
2857        if segment.is_empty() || segment == "." {
2858            continue;
2859        }
2860        if segment == ".." {
2861            candidates = candidates
2862                .into_iter()
2863                .map(|candidate| {
2864                    candidate
2865                        .parent()
2866                        .unwrap_or_else(|| Path::new("/"))
2867                        .to_path_buf()
2868                })
2869                .collect();
2870            continue;
2871        }
2872
2873        let wildcard_segment = contains_wildcard(segment);
2874        let mut next_candidates = Vec::new();
2875        for candidate in &candidates {
2876            if wildcard_segment {
2877                if !candidate.is_dir() {
2878                    continue;
2879                }
2880                for entry in std::fs::read_dir(candidate)? {
2881                    let entry = entry?;
2882                    let file_name = entry.file_name();
2883                    let Some(file_name) = file_name.to_str() else {
2884                        continue;
2885                    };
2886                    if wildcard_match(segment, file_name) {
2887                        next_candidates.push(entry.path());
2888                    }
2889                }
2890            } else {
2891                next_candidates.push(candidate.join(segment));
2892            }
2893        }
2894        candidates = next_candidates;
2895    }
2896
2897    Ok(candidates)
2898}
2899
2900fn contains_glob(pattern: &str) -> bool {
2901    pattern.chars().any(|ch| matches!(ch, '*' | '?' | '['))
2902}
2903
2904fn contains_wildcard(segment: &str) -> bool {
2905    segment.contains('*') || segment.contains('?')
2906}
2907
2908fn wildcard_match(pattern: &str, value: &str) -> bool {
2909    wildcard_match_bytes(pattern.as_bytes(), value.as_bytes())
2910}
2911
2912fn wildcard_match_bytes(pattern: &[u8], value: &[u8]) -> bool {
2913    if pattern.is_empty() {
2914        return value.is_empty();
2915    }
2916    if pattern[0] == b'*' {
2917        for index in 0..=value.len() {
2918            if wildcard_match_bytes(&pattern[1..], &value[index..]) {
2919                return true;
2920            }
2921        }
2922        return false;
2923    }
2924    if value.is_empty() {
2925        return false;
2926    }
2927    if pattern[0] == b'?' || pattern[0] == value[0] {
2928        return wildcard_match_bytes(&pattern[1..], &value[1..]);
2929    }
2930    false
2931}
2932
2933#[cfg(test)]
2934mod tests {
2935    use super::*;
2936    use crate::e2e::{MultiRepoFixtureConfig, reset_multi_repo_fixtures};
2937    use std::fs;
2938    use std::sync::atomic::{AtomicU64, Ordering};
2939
2940    #[cfg(unix)]
2941    use std::os::unix::fs::symlink;
2942
2943    static FIXTURE_COUNTER: AtomicU64 = AtomicU64::new(0);
2944
2945    fn selected_tree(files: &[(&str, &str)]) -> BTreeMap<PathBuf, SelectedCargoEntry> {
2946        files
2947            .iter()
2948            .map(|(path, text)| {
2949                (
2950                    PathBuf::from(path),
2951                    SelectedCargoEntry::File(Some((*text).into())),
2952                )
2953            })
2954            .collect()
2955    }
2956
2957    #[test]
2958    fn selected_cargo_combined_tree_accepts_only_selected_sibling_roots() {
2959        let mut entries = selected_tree(&[
2960            (
2961                "app/Cargo.toml",
2962                "[package]\nname='app'\n[dependencies]\ndep={path='../dep'}",
2963            ),
2964            ("dep/Cargo.toml", "[package]\nname='dep'"),
2965        ]);
2966        validate_selected_cargo_tree_at(&entries, Path::new("app/Cargo.toml")).unwrap();
2967        // The original API still requires an actual selected root manifest.
2968        assert!(validate_selected_cargo_tree(&entries).is_err());
2969        entries.insert(
2970            PathBuf::from("app/Cargo.toml"),
2971            SelectedCargoEntry::File(Some("[dependencies]\ndep={path='../../outside'}".into())),
2972        );
2973        let error =
2974            validate_selected_cargo_tree_at(&entries, Path::new("app/Cargo.toml")).unwrap_err();
2975        assert!(error.detail().contains("../../outside"), "{error}");
2976        assert!(error.detail().contains("escapes"), "{error}");
2977    }
2978
2979    #[test]
2980    fn selected_cargo_combined_tree_requires_entrypoint_and_checks_unrelated_metadata() {
2981        let mut entries = selected_tree(&[
2982            ("app/Cargo.toml", "[workspace]"),
2983            (
2984                "other/Cargo.toml",
2985                "[dependencies]\nstale={path='../../stale'}",
2986            ),
2987        ]);
2988        let error =
2989            validate_selected_cargo_tree_at(&entries, Path::new("app/Cargo.toml")).unwrap_err();
2990        assert!(error.detail().contains("other/Cargo.toml"), "{error}");
2991        entries.insert(
2992            PathBuf::from("other/Cargo.toml"),
2993            SelectedCargoEntry::File(Some("[workspace]".into())),
2994        );
2995        for primary in [
2996            "missing/Cargo.toml",
2997            "../app/Cargo.toml",
2998            "/app/Cargo.toml",
2999            "app",
3000        ] {
3001            assert!(
3002                validate_selected_cargo_tree_at(&entries, Path::new(primary)).is_err(),
3003                "{primary}"
3004            );
3005        }
3006        entries.insert(
3007            PathBuf::from("app/Cargo.toml"),
3008            SelectedCargoEntry::File(None),
3009        );
3010        assert!(
3011            validate_selected_cargo_tree_at(&entries, Path::new("app/Cargo.toml"))
3012                .unwrap_err()
3013                .detail()
3014                .contains("primary manifest text")
3015        );
3016    }
3017
3018    #[test]
3019    fn selected_cargo_combined_config_resolves_from_its_selected_base() {
3020        let entries = selected_tree(&[
3021            ("app/Cargo.toml", "[workspace]"),
3022            ("dep/Cargo.toml", "[package]\nname='dep'"),
3023        ]);
3024        let primary = Path::new("app/Cargo.toml");
3025        validate_selected_cargo_config_at(&entries, primary, Path::new("app"), "paths=['../dep']")
3026            .unwrap();
3027        validate_selected_cargo_config_at(&entries, primary, Path::new(""), "paths=['dep']")
3028            .unwrap();
3029        assert!(
3030            validate_selected_cargo_config_at(
3031                &entries,
3032                primary,
3033                Path::new("app"),
3034                "paths=['../../outside']"
3035            )
3036            .is_err()
3037        );
3038        assert!(
3039            validate_selected_cargo_config_at(
3040                &entries,
3041                Path::new("missing/Cargo.toml"),
3042                Path::new("app"),
3043                "[build]\njobs=1"
3044            )
3045            .is_err()
3046        );
3047        assert!(
3048            validate_selected_cargo_config_at(
3049                &entries,
3050                primary,
3051                Path::new("app/Cargo.toml"),
3052                "[build]\njobs=1"
3053            )
3054            .is_err()
3055        );
3056    }
3057
3058    #[test]
3059    fn selected_cargo_tree_allows_internal_parent_dependencies_and_workspace_globs() {
3060        let entries = selected_tree(&[
3061            (
3062                "Cargo.toml",
3063                "[workspace]\nmembers=['crates/*']\n[workspace.dependencies]\nshared={path='crates/dep'}\n",
3064            ),
3065            (
3066                "crates/app/Cargo.toml",
3067                "[package]\nname='app'\nworkspace='../..'\n[dependencies]\nshared={workspace=true}\n[dev-dependencies]\ndep={path='../dep'}\n[lib]\npath='src/lib.rs'\n",
3068            ),
3069            ("crates/app/src/lib.rs", ""),
3070            ("crates/dep/Cargo.toml", "[package]\nname='dep'\n"),
3071        ]);
3072        validate_selected_cargo_tree(&entries).unwrap();
3073        assert_eq!(
3074            validate_selected_cargo_path(&entries, Path::new("crates/app/../dep/Cargo.toml"))
3075                .unwrap(),
3076            Path::new("crates/dep/Cargo.toml")
3077        );
3078    }
3079
3080    #[test]
3081    fn selected_cargo_tree_aggregates_external_roots_and_declaring_manifests() {
3082        let entries = selected_tree(&[
3083            (
3084                "Cargo.toml",
3085                "[dependencies]\none={path='../one'}\n[patch.crates-io]\ntwo={path='../two'}\n",
3086            ),
3087            (
3088                "nested/Cargo.toml",
3089                "[target.'cfg(windows)'.build-dependencies]\nthree={path='../../three',optional=true}\n",
3090            ),
3091        ]);
3092        let error = validate_selected_cargo_tree(&entries).unwrap_err();
3093        assert_eq!(
3094            error.kind(),
3095            &CargoPathDependencyErrorKind::PathPolicyViolation
3096        );
3097        for expected in [
3098            "../one",
3099            "../two",
3100            "../../three",
3101            "nested/Cargo.toml",
3102            "target.cfg(windows).build-dependencies.three.path",
3103        ] {
3104            assert!(error.detail().contains(expected), "{error}");
3105        }
3106        assert_eq!(error.diagnostics().len(), 3);
3107    }
3108
3109    #[test]
3110    fn selected_cargo_tree_checks_replace_workspace_and_compiler_source_paths() {
3111        let invalid = [
3112            "[replace]\n'old:1.0.0'={path='../external'}\n",
3113            "[package]\nworkspace='../external'\n",
3114            "[workspace]\nmembers=['../external']\n",
3115            "[workspace]\ndefault-members=['../external']\n",
3116            "[workspace.dependencies]\nexternal={path='../external'}\n",
3117            "[lib]\npath='../external.rs'\n",
3118            "[[bin]]\npath='../external.rs'\n",
3119            "[[test]]\npath='../external.rs'\n",
3120            "[[example]]\npath='../external.rs'\n",
3121            "[[bench]]\npath='../external.rs'\n",
3122            "[package]\nbuild='../external.rs'\n",
3123            "[dependencies]\nexternal={path='/existing/on/worker'}\n",
3124            "[dependencies]\nexternal={path='C:\\existing'}\n",
3125        ];
3126        for manifest in invalid {
3127            let error = validate_selected_cargo_tree(&selected_tree(&[("Cargo.toml", manifest)]))
3128                .unwrap_err();
3129            assert_eq!(
3130                error.kind(),
3131                &CargoPathDependencyErrorKind::PathPolicyViolation,
3132                "{manifest}"
3133            );
3134            assert!(error.detail().contains("Cargo.toml"), "{error}");
3135        }
3136        validate_selected_cargo_tree(&selected_tree(&[
3137            (
3138                "Cargo.toml",
3139                "[package]\nbuild=true\n[replace]\n'old:1.0.0'={path='dep'}\n",
3140            ),
3141            ("build.rs", ""),
3142            ("dep/Cargo.toml", "[package]\nname='dep'\n"),
3143        ]))
3144        .unwrap();
3145    }
3146
3147    #[test]
3148    fn selected_cargo_tree_resolves_symlinks_before_parent_components() {
3149        let mut entries = selected_tree(&[
3150            ("Cargo.toml", "[dependencies]\ndep={path='alias/..'}\n"),
3151            ("a/Cargo.toml", "[package]\nname='a'\n"),
3152            ("a/nested/anchor", ""),
3153        ]);
3154        entries.insert(
3155            PathBuf::from("alias"),
3156            SelectedCargoEntry::Symlink("a/nested".into()),
3157        );
3158        validate_selected_cargo_tree(&entries).unwrap();
3159        assert_eq!(
3160            validate_selected_cargo_path(&entries, Path::new("alias/../Cargo.toml")).unwrap(),
3161            Path::new("a/Cargo.toml")
3162        );
3163        entries.insert(
3164            PathBuf::from("alias"),
3165            SelectedCargoEntry::Symlink("../outside".into()),
3166        );
3167        assert!(
3168            validate_selected_cargo_tree(&entries)
3169                .unwrap_err()
3170                .detail()
3171                .contains("escapes")
3172        );
3173        entries.insert(
3174            PathBuf::from("alias"),
3175            SelectedCargoEntry::Symlink("other".into()),
3176        );
3177        entries.insert(
3178            PathBuf::from("other"),
3179            SelectedCargoEntry::Symlink("alias".into()),
3180        );
3181        assert!(
3182            validate_selected_cargo_tree(&entries)
3183                .unwrap_err()
3184                .detail()
3185                .contains("cycle")
3186        );
3187    }
3188
3189    #[test]
3190    fn selected_cargo_tree_checks_selected_config_overrides_and_refuses_includes() {
3191        let invalid = [
3192            "paths=['../external']",
3193            "[patch.crates-io]\nexternal={path='../external'}",
3194            "[replace]\n'old:1.0.0'={path='../external'}",
3195            "[source.local]\ndirectory='../external'",
3196            "[source.local]\nlocal-registry='../external'",
3197            "include=['extra.toml']",
3198        ];
3199        for config in invalid {
3200            for name in [".cargo/config", ".cargo/config.toml"] {
3201                let entries = selected_tree(&[("Cargo.toml", "[workspace]"), (name, config)]);
3202                let error = validate_selected_cargo_tree(&entries).unwrap_err();
3203                assert!(error.detail().contains(name), "{error}");
3204            }
3205        }
3206        validate_selected_cargo_tree(&selected_tree(&[
3207            ("Cargo.toml", "[workspace]"),
3208            (".cargo/config.toml", "paths=['dep']\n[source.local]\ndirectory='vendor'\n[build]\nbuild-dir='/managed/cache'"),
3209            ("dep/Cargo.toml", "[package]\nname='dep'"),
3210            ("vendor/entry", ""),
3211        ])).unwrap();
3212        let entries = selected_tree(&[
3213            ("Cargo.toml", "[workspace]"),
3214            ("dep/Cargo.toml", "[package]\nname='dep'"),
3215            ("nested/anchor", ""),
3216        ]);
3217        validate_selected_cargo_config(&entries, Path::new(""), "paths=['dep']").unwrap();
3218        validate_selected_cargo_config(&entries, Path::new("nested"), "paths=['../dep']").unwrap();
3219        assert!(
3220            validate_selected_cargo_config(&entries, Path::new(""), "paths=['../dep']").is_err()
3221        );
3222        assert!(
3223            validate_selected_cargo_config(&entries, Path::new("nested/anchor"), "[build]\njobs=1")
3224                .is_err()
3225        );
3226        let mut alias = selected_tree(&[
3227            ("Cargo.toml", "[workspace]"),
3228            ("settings/config.toml", "paths=['../external']"),
3229        ]);
3230        alias.insert(
3231            PathBuf::from(".cargo"),
3232            SelectedCargoEntry::Symlink("settings".into()),
3233        );
3234        assert!(
3235            validate_selected_cargo_tree(&alias)
3236                .unwrap_err()
3237                .detail()
3238                .contains(".cargo/config.toml")
3239        );
3240        alias.insert(
3241            PathBuf::from("settings/config.toml"),
3242            SelectedCargoEntry::File(None),
3243        );
3244        assert!(
3245            validate_selected_cargo_tree(&alias)
3246                .unwrap_err()
3247                .detail()
3248                .contains("alias text")
3249        );
3250    }
3251
3252    #[test]
3253    fn selected_cargo_tree_refuses_missing_text_paths_and_invalid_inventory() {
3254        assert!(validate_selected_cargo_tree(&BTreeMap::new()).is_err());
3255        let mut unloaded = BTreeMap::from([
3256            (PathBuf::from("Cargo.toml"), SelectedCargoEntry::File(None)),
3257            (
3258                PathBuf::from("settings/config.toml"),
3259                SelectedCargoEntry::File(None),
3260            ),
3261            (
3262                PathBuf::from(".cargo"),
3263                SelectedCargoEntry::Symlink("settings".into()),
3264            ),
3265        ]);
3266        assert_eq!(
3267            validate_selected_cargo_path(&unloaded, Path::new("Cargo.toml")).unwrap(),
3268            Path::new("Cargo.toml")
3269        );
3270        let config_target =
3271            validate_selected_cargo_path(&unloaded, Path::new(".cargo/config.toml")).unwrap();
3272        assert_eq!(config_target, Path::new("settings/config.toml"));
3273        assert!(
3274            validate_selected_cargo_tree(&unloaded)
3275                .unwrap_err()
3276                .detail()
3277                .contains("text")
3278        );
3279        unloaded.insert(
3280            PathBuf::from("Cargo.toml"),
3281            SelectedCargoEntry::File(Some("[workspace]".into())),
3282        );
3283        unloaded.insert(
3284            config_target,
3285            SelectedCargoEntry::File(Some("[build]\njobs=1".into())),
3286        );
3287        validate_selected_cargo_tree(&unloaded).unwrap();
3288        let mut entries = selected_tree(&[("Cargo.toml", "[dependencies]\ndep={path='dep'}")]);
3289        // A real ambient dep directory cannot satisfy a selected inventory.
3290        assert!(
3291            validate_selected_cargo_tree(&entries)
3292                .unwrap_err()
3293                .detail()
3294                .contains("missing selected path dep")
3295        );
3296        entries.insert(
3297            PathBuf::from("dep/Cargo.toml"),
3298            SelectedCargoEntry::File(None),
3299        );
3300        assert!(
3301            validate_selected_cargo_tree(&entries)
3302                .unwrap_err()
3303                .detail()
3304                .contains("text")
3305        );
3306        let mut entries = selected_tree(&[("Cargo.toml", "[workspace]")]);
3307        entries.insert(PathBuf::from("file"), SelectedCargoEntry::File(None));
3308        assert!(validate_selected_cargo_path(&entries, Path::new("file/../Cargo.toml")).is_err());
3309        assert!(validate_selected_cargo_path(&entries, Path::new("../Cargo.toml")).is_err());
3310        entries.insert(PathBuf::from("file/child"), SelectedCargoEntry::File(None));
3311        assert!(
3312            validate_selected_cargo_tree(&entries)
3313                .unwrap_err()
3314                .detail()
3315                .contains("descendants")
3316        );
3317        entries.insert(PathBuf::from("../outside"), SelectedCargoEntry::File(None));
3318        assert!(validate_selected_cargo_path(&entries, Path::new("Cargo.toml")).is_err());
3319        assert!(
3320            validate_selected_cargo_tree(&entries)
3321                .unwrap_err()
3322                .detail()
3323                .contains("invalid selected inventory")
3324        );
3325    }
3326
3327    #[test]
3328    fn selected_cargo_tree_accepts_inert_unix_colon_leaf_but_not_colon_paths() {
3329        let mut entries = selected_tree(&[("Cargo.toml", "[workspace]")]);
3330        entries.insert(
3331            PathBuf::from("crates/fs-evidence/src/vv/model.rs:6150-6230"),
3332            SelectedCargoEntry::File(None),
3333        );
3334        if cfg!(unix) {
3335            validate_selected_cargo_tree(&entries).unwrap();
3336        } else {
3337            assert!(validate_selected_cargo_tree(&entries).is_err());
3338        }
3339        entries.insert(
3340            PathBuf::from("crates/fs-evidence:outside/src/lib.rs"),
3341            SelectedCargoEntry::File(None),
3342        );
3343        assert!(
3344            validate_selected_cargo_tree(&entries)
3345                .unwrap_err()
3346                .detail()
3347                .contains("invalid selected inventory path")
3348        );
3349        let referenced = selected_tree(&[(
3350            "Cargo.toml",
3351            "[package]\nname='root'\nversion='0.1.0'\n[dependencies]\ndep={path='dep:elsewhere'}",
3352        )]);
3353        assert!(
3354            validate_selected_cargo_tree(&referenced)
3355                .unwrap_err()
3356                .detail()
3357                .contains("unsupported or absolute path")
3358        );
3359        let mut linked = selected_tree(&[("Cargo.toml", "[workspace]")]);
3360        linked.insert(
3361            PathBuf::from("alias"),
3362            SelectedCargoEntry::Symlink("target:elsewhere".into()),
3363        );
3364        assert!(
3365            validate_selected_cargo_tree(&linked)
3366                .unwrap_err()
3367                .detail()
3368                .contains("unsupported or absolute path")
3369        );
3370    }
3371
3372    #[test]
3373    fn selected_cargo_tree_does_not_skip_nested_inactive_manifests_or_bad_globs() {
3374        let entries = selected_tree(&[
3375            ("Cargo.toml", "[workspace]\nmembers=['app']"),
3376            ("app/Cargo.toml", "[package]\nname='app'"),
3377            (
3378                "fixtures/unused/Cargo.toml",
3379                "[dev-dependencies]\nexternal={path='../../../external'}",
3380            ),
3381        ]);
3382        assert!(
3383            validate_selected_cargo_tree(&entries)
3384                .unwrap_err()
3385                .detail()
3386                .contains("fixtures/unused/Cargo.toml")
3387        );
3388        for pattern in ["crates/**", "crates/[ab]", "missing/*"] {
3389            let manifest = format!("[workspace]\nmembers=['{pattern}']");
3390            assert!(
3391                validate_selected_cargo_tree(&selected_tree(&[("Cargo.toml", &manifest)])).is_err()
3392            );
3393        }
3394        let inherited = selected_tree(&[
3395            ("Cargo.toml", "[package]\nname='root'"),
3396            (
3397                "nested/Cargo.toml",
3398                "[package]\nversion.workspace=true\n[dependencies]\nshared.workspace=true",
3399            ),
3400        ]);
3401        assert!(
3402            validate_selected_cargo_tree(&inherited)
3403                .unwrap_err()
3404                .detail()
3405                .contains("no selected enclosing workspace")
3406        );
3407        let selected_workspace = selected_tree(&[
3408            (
3409                "Cargo.toml",
3410                "[workspace]\n[workspace.dependencies]\nshared={path='dep'}",
3411            ),
3412            ("dep/Cargo.toml", "[package]\nname='dep'"),
3413            (
3414                "nested/Cargo.toml",
3415                "[package]\nversion.workspace=true\n[dependencies]\nshared.workspace=true",
3416            ),
3417        ]);
3418        validate_selected_cargo_tree(&selected_workspace).unwrap();
3419    }
3420
3421    #[cfg(unix)]
3422    struct TopologyFixture {
3423        root: PathBuf,
3424        canonical_root: PathBuf,
3425        alias_root: PathBuf,
3426    }
3427
3428    #[cfg(unix)]
3429    impl TopologyFixture {
3430        fn new(prefix: &str) -> Self {
3431            let id = FIXTURE_COUNTER.fetch_add(1, Ordering::SeqCst);
3432            let root = std::env::temp_dir().join(format!(
3433                "rch-cargo-path-deps-{}-{}-{}",
3434                prefix,
3435                std::process::id(),
3436                id
3437            ));
3438            let canonical_root = root.join("data/projects");
3439            let alias_root = root.join("dp");
3440            fs::create_dir_all(&canonical_root).expect("create canonical root");
3441            symlink(&canonical_root, &alias_root).expect("create alias symlink");
3442
3443            Self {
3444                root,
3445                canonical_root,
3446                alias_root,
3447            }
3448        }
3449
3450        fn policy(&self) -> PathTopologyPolicy {
3451            PathTopologyPolicy::new(self.canonical_root.clone(), self.alias_root.clone())
3452        }
3453    }
3454
3455    #[cfg(unix)]
3456    impl Drop for TopologyFixture {
3457        fn drop(&mut self) {
3458            let _ = fs::remove_dir_all(&self.root);
3459        }
3460    }
3461
3462    #[cfg(unix)]
3463    fn write_lib_crate(root: &Path, crate_name: &str, deps: &[(&str, &str)]) {
3464        fs::create_dir_all(root.join("src")).expect("create crate src");
3465        fs::write(root.join("Cargo.toml"), crate_manifest(crate_name, deps))
3466            .expect("write manifest");
3467        fs::write(
3468            root.join("src/lib.rs"),
3469            format!(
3470                "pub fn {}() -> &'static str {{ \"{}\" }}\n",
3471                crate_name, crate_name
3472            ),
3473        )
3474        .expect("write lib.rs");
3475    }
3476
3477    #[cfg(unix)]
3478    fn write_bin_crate(root: &Path, crate_name: &str, deps: &[(&str, &str)]) {
3479        fs::create_dir_all(root.join("src")).expect("create crate src");
3480        fs::write(root.join("Cargo.toml"), crate_manifest(crate_name, deps))
3481            .expect("write manifest");
3482        fs::write(
3483            root.join("src/main.rs"),
3484            format!("fn main() {{ println!(\"{}\"); }}\n", crate_name),
3485        )
3486        .expect("write main.rs");
3487    }
3488
3489    #[cfg(unix)]
3490    fn crate_manifest(crate_name: &str, deps: &[(&str, &str)]) -> String {
3491        let mut dependencies = String::new();
3492        for (name, path) in deps {
3493            dependencies.push_str(&format!("{name} = {{ path = \"{path}\" }}\n"));
3494        }
3495        format!(
3496            "[package]\nname = \"{crate_name}\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\n{dependencies}"
3497        )
3498    }
3499
3500    #[cfg(unix)]
3501    #[test]
3502    fn workspace_exclude_filters_glob_member_but_keeps_explicit_nested_member() {
3503        let fixture = TopologyFixture::new("workspace-exclude");
3504        let workspace = fixture.canonical_root.join("workspace-exclude-project");
3505        let app = workspace.join("crates/app");
3506        let helper = workspace.join("crates/dev-tools");
3507        let probe = helper.join("probe");
3508        fs::create_dir_all(&app).unwrap();
3509        fs::create_dir_all(&probe).unwrap();
3510        fs::write(
3511            workspace.join("Cargo.toml"),
3512            "[workspace]\nresolver='2'\nmembers=['crates/*','crates/dev-tools/probe']\nexclude=['crates/dev-tools']\n",
3513        )
3514        .unwrap();
3515        fs::write(
3516            app.join("Cargo.toml"),
3517            "[package]\nname='app'\nversion='0.1.0'\n",
3518        )
3519        .unwrap();
3520        fs::write(
3521            probe.join("Cargo.toml"),
3522            "[package]\nname='probe'\nversion='0.1.0'\n",
3523        )
3524        .unwrap();
3525
3526        let manifest = workspace.join("Cargo.toml");
3527        let policy = fixture.policy();
3528        let expected = BTreeSet::from([app.canonicalize().unwrap(), probe.canonicalize().unwrap()]);
3529
3530        let graph = resolve_from_manifest_fallback(&manifest, &policy).unwrap();
3531        assert_eq!(
3532            graph.root_packages.into_iter().collect::<BTreeSet<_>>(),
3533            expected
3534        );
3535        assert!(
3536            !graph
3537                .packages
3538                .iter()
3539                .any(|package| package.package_root == helper),
3540            "excluded glob candidate must not become a package"
3541        );
3542
3543        let closure = resolve_materialization_from_manifests(&manifest, &policy).unwrap();
3544        assert_eq!(
3545            closure.root_packages.into_iter().collect::<BTreeSet<_>>(),
3546            expected
3547        );
3548        assert!(
3549            !closure
3550                .packages
3551                .iter()
3552                .any(|package| package.package_root == helper),
3553            "materialization closure must apply the same workspace exclusion"
3554        );
3555
3556        // Exclusion is not a blanket permission to ignore malformed glob
3557        // members. A different matched directory with no Cargo.toml must
3558        // retain the existing fail-closed missing-manifest result.
3559        let missing = workspace.join("crates/not-a-package");
3560        fs::create_dir_all(&missing).unwrap();
3561        let error = resolve_from_manifest_fallback(&manifest, &policy).unwrap_err();
3562        assert_eq!(
3563            error.kind(),
3564            &CargoPathDependencyErrorKind::MissingPathDependency
3565        );
3566        // The error names the canonical path (macOS tempdirs live under the
3567        // /var -> /private/var symlink).
3568        let missing_manifest = missing.canonicalize().unwrap().join("Cargo.toml");
3569        assert!(
3570            error
3571                .dependency_path()
3572                .is_some_and(|path| path == missing_manifest),
3573            "{:?}",
3574            error.dependency_path()
3575        );
3576    }
3577
3578    #[cfg(unix)]
3579    struct LockPlanningFixture {
3580        _fixture: TopologyFixture,
3581        workspace_root: PathBuf,
3582        app_root: PathBuf,
3583        late_dep_root: PathBuf,
3584        cargo_home: PathBuf,
3585        expected_roots: BTreeSet<PathBuf>,
3586        policy: PathTopologyPolicy,
3587    }
3588
3589    #[cfg(unix)]
3590    impl LockPlanningFixture {
3591        fn new(prefix: &str) -> Self {
3592            let fixture = TopologyFixture::new(prefix);
3593            let scenario_root = fixture.canonical_root.join(prefix);
3594            let workspace_root = scenario_root.join("ambient_checkout");
3595            let app_root = workspace_root.join("app");
3596            let normal_dep_root = scenario_root.join("normal_dep");
3597            let build_dep_root = scenario_root.join("build_dep");
3598            let dev_dep_root = scenario_root.join("dev_dep");
3599            let optional_dep_root = scenario_root.join("optional_dep");
3600            let target_dep_root = scenario_root.join("target_dep");
3601            let transitive_dep_root = scenario_root.join("transitive_dep");
3602            let workspace_dep_root = scenario_root.join("workspace_dep");
3603            let patch_only_dep_root = scenario_root.join("patch_only_dep");
3604            let late_dep_root = scenario_root.join("late_dep");
3605            let cargo_home = scenario_root.join("cargo_home");
3606
3607            write_lib_crate(&normal_dep_root, "normal_dep", &[]);
3608            write_lib_crate(&build_dep_root, "build_dep", &[]);
3609            write_lib_crate(&dev_dep_root, "dev_dep", &[]);
3610            write_lib_crate(
3611                &optional_dep_root,
3612                "optional_dep",
3613                &[("transitive_dep", "../transitive_dep")],
3614            );
3615            write_lib_crate(&target_dep_root, "target_dep", &[]);
3616            write_lib_crate(&transitive_dep_root, "transitive_dep", &[]);
3617            write_lib_crate(&workspace_dep_root, "workspace_dep", &[]);
3618            write_lib_crate(&patch_only_dep_root, "patch_only_dep", &[]);
3619            fs::create_dir_all(app_root.join("src")).expect("create app source root");
3620            fs::write(app_root.join("src/main.rs"), "fn main() {}\n").expect("write app main");
3621            Self::write_app_manifest(&app_root, false);
3622            fs::write(
3623                workspace_root.join("Cargo.toml"),
3624                r#"[workspace]
3625members = ["app"]
3626resolver = "3"
3627
3628[workspace.dependencies]
3629workspace_dep = { path = "../workspace_dep" }
3630
3631[patch.crates-io]
3632patch_only_dep = { path = "../patch_only_dep" }
3633"#,
3634            )
3635            .expect("write workspace manifest");
3636            fs::create_dir_all(&cargo_home).expect("create isolated Cargo home");
3637
3638            let expected_roots = [
3639                &app_root,
3640                &normal_dep_root,
3641                &build_dep_root,
3642                &dev_dep_root,
3643                &optional_dep_root,
3644                &target_dep_root,
3645                &transitive_dep_root,
3646                &workspace_dep_root,
3647                &patch_only_dep_root,
3648            ]
3649            .into_iter()
3650            .map(|root| root.canonicalize().expect("canonical fixture package"))
3651            .collect();
3652            let policy = fixture.policy();
3653            Self {
3654                _fixture: fixture,
3655                workspace_root,
3656                app_root,
3657                late_dep_root,
3658                cargo_home,
3659                expected_roots,
3660                policy,
3661            }
3662        }
3663
3664        fn write_app_manifest(app_root: &Path, include_late_dependency: bool) {
3665            let late_dependency = if include_late_dependency {
3666                "late_dep = { path = \"../../late_dep\" }\n"
3667            } else {
3668                ""
3669            };
3670            fs::write(
3671                app_root.join("Cargo.toml"),
3672                format!(
3673                    r#"[package]
3674name = "lock_invariant_app"
3675version = "0.1.0"
3676edition = "2024"
3677
3678[dependencies]
3679normal_dep = {{ path = "../../normal_dep" }}
3680optional_dep = {{ path = "../../optional_dep", optional = true }}
3681workspace_dep = {{ workspace = true, optional = true }}
3682{late_dependency}
3683[build-dependencies]
3684build_dep = {{ path = "../../build_dep" }}
3685
3686[dev-dependencies]
3687dev_dep = {{ path = "../../dev_dep" }}
3688
3689[target.'cfg(target_os = "linux")'.dependencies]
3690target_dep = {{ path = "../../target_dep" }}
3691"#
3692                ),
3693            )
3694            .expect("write app manifest");
3695        }
3696
3697        fn lock_path(&self) -> PathBuf {
3698            self.workspace_root.join("Cargo.lock")
3699        }
3700
3701        fn manifest_path(&self) -> PathBuf {
3702            self.workspace_root.join("Cargo.toml")
3703        }
3704
3705        fn generate_valid_lockfile(&self) {
3706            let output = Command::new("cargo")
3707                .arg("generate-lockfile")
3708                .arg("--offline")
3709                .arg("--manifest-path")
3710                .arg(self.manifest_path())
3711                .env("CARGO_HOME", &self.cargo_home)
3712                .output()
3713                .expect("run real cargo generate-lockfile");
3714            assert!(
3715                output.status.success(),
3716                "cargo generate-lockfile failed: {}",
3717                String::from_utf8_lossy(&output.stderr)
3718            );
3719            assert!(self.lock_path().is_file(), "Cargo generated the lockfile");
3720        }
3721
3722        fn make_generated_lock_stale(&mut self) {
3723            write_lib_crate(&self.late_dep_root, "late_dep", &[]);
3724            self.expected_roots.insert(
3725                self.late_dep_root
3726                    .canonicalize()
3727                    .expect("canonical late dependency"),
3728            );
3729            Self::write_app_manifest(&self.app_root, true);
3730        }
3731
3732        fn resolve_planned_roots(&self) -> BTreeSet<PathBuf> {
3733            let plan = crate::build_dependency_closure_plan_with_policy(
3734                &self.workspace_root,
3735                &self.policy,
3736            );
3737            assert!(
3738                plan.is_ready(),
3739                "dependency plan must be ready; reason={:?}, issues={:?}",
3740                plan.fail_open_reason,
3741                plan.issues
3742            );
3743            plan.sync_order
3744                .iter()
3745                .map(|action| action.package_root.clone())
3746                .collect()
3747        }
3748
3749        fn assert_discovers_every_path_root(&self) {
3750            assert_eq!(
3751                self.resolve_planned_roots(),
3752                self.expected_roots,
3753                "locked-metadata success or manifest fallback must preserve the complete path-root closure"
3754            );
3755        }
3756    }
3757
3758    #[cfg(unix)]
3759    struct LockfileSnapshot {
3760        bytes: Vec<u8>,
3761        modified: std::time::SystemTime,
3762    }
3763
3764    #[cfg(unix)]
3765    impl LockfileSnapshot {
3766        fn capture(path: &Path) -> Self {
3767            Self {
3768                bytes: fs::read(path).expect("read lockfile snapshot"),
3769                modified: fs::metadata(path)
3770                    .and_then(|metadata| metadata.modified())
3771                    .expect("read lockfile mtime"),
3772            }
3773        }
3774
3775        fn arm_mtime_detection() {
3776            // Keep the pre-plan mtime outside common one-second timestamp
3777            // granularity. A byte-identical rewrite must still fail the mtime
3778            // assertion below instead of disappearing inside one clock tick.
3779            std::thread::sleep(std::time::Duration::from_millis(1100));
3780        }
3781
3782        fn assert_unchanged(&self, path: &Path, scenario: &str) {
3783            assert_eq!(
3784                fs::read(path).expect("read lockfile after planning"),
3785                self.bytes,
3786                "{scenario}: dependency planning changed Cargo.lock bytes"
3787            );
3788            assert_eq!(
3789                fs::metadata(path)
3790                    .and_then(|metadata| metadata.modified())
3791                    .expect("read lockfile mtime after planning"),
3792                self.modified,
3793                "{scenario}: dependency planning changed Cargo.lock mtime"
3794            );
3795        }
3796    }
3797
3798    /// bd-rch-pinned-metadata-lock-ytkda: with a VALID lockfile, dependency
3799    /// planning uses `cargo metadata --locked` and leaves the lockfile
3800    /// byte-for-byte and mtime-for-mtime untouched.
3801    #[cfg(unix)]
3802    #[test]
3803    fn valid_lockfile_planning_discovers_roots_without_mutating_the_lock() {
3804        let fixture = LockPlanningFixture::new("lock-valid");
3805        let lock_path = fixture.lock_path();
3806        fixture.generate_valid_lockfile();
3807        let snapshot = LockfileSnapshot::capture(&lock_path);
3808        LockfileSnapshot::arm_mtime_detection();
3809
3810        invoke_cargo_metadata(&fixture.manifest_path())
3811            .expect("real cargo metadata --locked accepts the generated valid lock");
3812        fixture.assert_discovers_every_path_root();
3813
3814        snapshot.assert_unchanged(&lock_path, "valid lockfile");
3815    }
3816
3817    /// bd-rch-pinned-metadata-lock-ytkda: a STALE lockfile makes
3818    /// `cargo metadata --locked` refuse; planning falls back to manifest-only
3819    /// discovery, still finds the path roots, and never rewrites the stale
3820    /// lock (bytes and mtime preserved).
3821    #[cfg(unix)]
3822    #[test]
3823    fn stale_lockfile_planning_falls_back_without_rewriting_the_lock() {
3824        let mut fixture = LockPlanningFixture::new("lock-stale");
3825        fixture.generate_valid_lockfile();
3826        let lock_path = fixture.lock_path();
3827        fixture.make_generated_lock_stale();
3828        let snapshot = LockfileSnapshot::capture(&lock_path);
3829        LockfileSnapshot::arm_mtime_detection();
3830
3831        let refusal = invoke_cargo_metadata(&fixture.manifest_path())
3832            .expect_err("real cargo metadata --locked must refuse a generated stale lock");
3833        assert_eq!(
3834            refusal.kind(),
3835            &CargoPathDependencyErrorKind::MetadataInvocationFailure
3836        );
3837        snapshot.assert_unchanged(&lock_path, "locked metadata refusal");
3838
3839        fixture.assert_discovers_every_path_root();
3840        snapshot.assert_unchanged(&lock_path, "stale-lock fallback planning");
3841    }
3842
3843    /// bd-rch-pinned-metadata-lock-ytkda: with NO lockfile, planning stays
3844    /// discoverable through the manifest-only fallback and must not create a
3845    /// `Cargo.lock` as a side effect (the historical mutation bug).
3846    #[cfg(unix)]
3847    #[test]
3848    fn missing_lockfile_planning_discovers_roots_without_creating_a_lock() {
3849        let fixture = LockPlanningFixture::new("lock-missing");
3850        let lock_path = fixture.lock_path();
3851        assert!(!lock_path.exists(), "fixture starts without a lockfile");
3852
3853        let refusal = invoke_cargo_metadata(&fixture.manifest_path())
3854            .expect_err("real cargo metadata --locked must refuse an absent lock");
3855        assert_eq!(
3856            refusal.kind(),
3857            &CargoPathDependencyErrorKind::MetadataInvocationFailure
3858        );
3859        assert!(
3860            !lock_path.exists(),
3861            "locked metadata refusal must not create Cargo.lock"
3862        );
3863
3864        fixture.assert_discovers_every_path_root();
3865
3866        assert!(
3867            !lock_path.exists(),
3868            "dependency planning must never create a Cargo.lock in the user checkout"
3869        );
3870    }
3871
3872    #[cfg(unix)]
3873    #[test]
3874    fn resolves_workspace_transitive_path_dependencies() {
3875        let fixture = TopologyFixture::new("workspace");
3876        let scenario_root = fixture.canonical_root.join("workspace_transitive");
3877        let workspace_root = scenario_root.join("workspace");
3878        let shared_root = scenario_root.join("shared/shared_lib");
3879        let util_root = workspace_root.join("crates/util");
3880        let app_root = workspace_root.join("crates/app");
3881
3882        write_lib_crate(&shared_root, "workspace_shared", &[]);
3883        write_lib_crate(
3884            &util_root,
3885            "workspace_util",
3886            &[("workspace_shared", "../../../shared/shared_lib")],
3887        );
3888        write_bin_crate(&app_root, "workspace_app", &[("workspace_util", "../util")]);
3889        fs::create_dir_all(&workspace_root).expect("create workspace root");
3890        fs::write(
3891            workspace_root.join("Cargo.toml"),
3892            "[workspace]\nmembers = [\"crates/app\", \"crates/util\"]\nresolver = \"3\"\n",
3893        )
3894        .expect("write workspace manifest");
3895
3896        let graph =
3897            resolve_cargo_path_dependency_graph_with_policy(&workspace_root, &fixture.policy())
3898                .expect("resolve workspace graph");
3899
3900        let app_root = app_root.canonicalize().expect("canonical app root");
3901        let util_root = util_root.canonicalize().expect("canonical util root");
3902        let shared_root = shared_root.canonicalize().expect("canonical shared root");
3903        assert_eq!(
3904            graph.root_packages,
3905            vec![app_root.clone(), util_root.clone()]
3906        );
3907        let package_roots = graph
3908            .packages
3909            .iter()
3910            .map(|package| package.package_root.clone())
3911            .collect::<Vec<_>>();
3912        assert!(
3913            package_roots
3914                .windows(2)
3915                .all(|window| window[0] <= window[1]),
3916            "packages should be deterministically sorted"
3917        );
3918        assert_eq!(
3919            package_roots.into_iter().collect::<BTreeSet<_>>(),
3920            BTreeSet::from([app_root.clone(), shared_root.clone(), util_root.clone()])
3921        );
3922        assert_eq!(
3923            graph.edges,
3924            vec![
3925                CargoPathDependencyEdge {
3926                    from: app_root,
3927                    to: util_root.clone(),
3928                    dependency_name: "workspace_util".to_string(),
3929                },
3930                CargoPathDependencyEdge {
3931                    from: util_root,
3932                    to: shared_root,
3933                    dependency_name: "workspace_shared".to_string(),
3934                },
3935            ]
3936        );
3937    }
3938
3939    #[cfg(unix)]
3940    #[test]
3941    fn resolves_virtual_workspace_members_from_alias_path() {
3942        let fixture = TopologyFixture::new("virtual-workspace");
3943        let scenario_root = fixture.canonical_root.join("virtual_workspace");
3944        let workspace_root = scenario_root.join("ws");
3945        let member_a = workspace_root.join("members/a");
3946        let member_b = workspace_root.join("members/b");
3947
3948        write_lib_crate(&member_b, "virtual_b", &[]);
3949        write_lib_crate(&member_a, "virtual_a", &[("virtual_b", "../b")]);
3950        fs::create_dir_all(&workspace_root).expect("create workspace root");
3951        fs::write(
3952            workspace_root.join("Cargo.toml"),
3953            "[workspace]\nmembers = [\"members/a\", \"members/b\"]\nresolver = \"3\"\n",
3954        )
3955        .expect("write workspace manifest");
3956
3957        let relative = workspace_root
3958            .strip_prefix(&fixture.canonical_root)
3959            .expect("workspace under canonical root");
3960        let alias_workspace = fixture.alias_root.join(relative);
3961
3962        let graph =
3963            resolve_cargo_path_dependency_graph_with_policy(&alias_workspace, &fixture.policy())
3964                .expect("resolve virtual workspace graph");
3965
3966        let member_a = member_a.canonicalize().expect("canonical member a");
3967        let member_b = member_b.canonicalize().expect("canonical member b");
3968        assert_eq!(
3969            graph.workspace_root,
3970            Some(
3971                workspace_root
3972                    .canonicalize()
3973                    .expect("canonical workspace root")
3974            )
3975        );
3976        assert_eq!(
3977            graph.root_packages,
3978            vec![member_a.clone(), member_b.clone()]
3979        );
3980        assert_eq!(
3981            graph.edges,
3982            vec![CargoPathDependencyEdge {
3983                from: member_a,
3984                to: member_b,
3985                dependency_name: "virtual_b".to_string(),
3986            }]
3987        );
3988    }
3989
3990    #[cfg(unix)]
3991    #[test]
3992    fn resolves_nested_manifest_transitive_closure() {
3993        let fixture = TopologyFixture::new("nested");
3994        let scenario_root = fixture.canonical_root.join("nested_manifests");
3995        let app_root = scenario_root.join("app");
3996        let util_root = scenario_root.join("libs/util");
3997        let core_root = scenario_root.join("libs/core");
3998
3999        write_lib_crate(&core_root, "nested_core", &[]);
4000        write_lib_crate(&util_root, "nested_util", &[("nested_core", "../core")]);
4001        write_bin_crate(&app_root, "nested_app", &[("nested_util", "../libs/util")]);
4002
4003        let graph = resolve_cargo_path_dependency_graph_with_policy(
4004            &app_root.join("Cargo.toml"),
4005            &fixture.policy(),
4006        )
4007        .expect("resolve nested manifest graph");
4008
4009        let app_root = app_root.canonicalize().expect("canonical app");
4010        let util_root = util_root.canonicalize().expect("canonical util");
4011        let core_root = core_root.canonicalize().expect("canonical core");
4012        assert_eq!(graph.root_packages, vec![app_root.clone()]);
4013        assert_eq!(
4014            graph.edges,
4015            vec![
4016                CargoPathDependencyEdge {
4017                    from: app_root,
4018                    to: util_root.clone(),
4019                    dependency_name: "nested_util".to_string(),
4020                },
4021                CargoPathDependencyEdge {
4022                    from: util_root,
4023                    to: core_root,
4024                    dependency_name: "nested_core".to_string(),
4025                },
4026            ]
4027        );
4028    }
4029
4030    #[cfg(unix)]
4031    #[test]
4032    fn malformed_metadata_uses_manifest_fallback() {
4033        let fixture = TopologyFixture::new("malformed-metadata");
4034        let scenario_root = fixture.canonical_root.join("metadata_fallback");
4035        let app_root = scenario_root.join("app");
4036        let dep_root = scenario_root.join("dep");
4037
4038        write_lib_crate(&dep_root, "fallback_dep", &[]);
4039        write_bin_crate(&app_root, "fallback_app", &[("fallback_dep", "../dep")]);
4040
4041        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
4042            &app_root,
4043            &fixture.policy(),
4044            |_| Ok("{not-json".to_string()),
4045        )
4046        .expect("resolver should recover using fallback");
4047
4048        let app_root = app_root.canonicalize().expect("canonical app");
4049        let dep_root = dep_root.canonicalize().expect("canonical dep");
4050        assert_eq!(graph.root_packages, vec![app_root.clone()]);
4051        assert_eq!(
4052            graph.edges,
4053            vec![CargoPathDependencyEdge {
4054                from: app_root,
4055                to: dep_root,
4056                dependency_name: "fallback_dep".to_string(),
4057            }]
4058        );
4059    }
4060
4061    #[cfg(unix)]
4062    #[test]
4063    fn malformed_manifest_reports_manifest_parse_failure() {
4064        let fixture = TopologyFixture::new("manifest-error");
4065        let config = MultiRepoFixtureConfig::new(
4066            fixture.canonical_root.clone(),
4067            fixture.alias_root.clone(),
4068            "resolver_manifest_error",
4069        );
4070        let fixtures = reset_multi_repo_fixtures(&config).expect("generate fixture set");
4071        let invalid = fixtures
4072            .fixture("fail_invalid_manifest")
4073            .expect("invalid fixture metadata");
4074
4075        let error = resolve_cargo_path_dependency_graph_with_policy(
4076            &invalid.canonical_entrypoint,
4077            &fixture.policy(),
4078        )
4079        .expect_err("invalid manifest must fail");
4080        assert_eq!(
4081            error.kind(),
4082            &CargoPathDependencyErrorKind::ManifestParseFailure
4083        );
4084    }
4085
4086    #[cfg(unix)]
4087    #[test]
4088    fn missing_path_reports_missing_dependency_kind() {
4089        let fixture = TopologyFixture::new("missing-path");
4090        let config = MultiRepoFixtureConfig::new(
4091            fixture.canonical_root.clone(),
4092            fixture.alias_root.clone(),
4093            "resolver_missing_path",
4094        );
4095        let fixtures = reset_multi_repo_fixtures(&config).expect("generate fixture set");
4096        let missing = fixtures
4097            .fixture("fail_missing_path_dep")
4098            .expect("missing fixture metadata");
4099
4100        let error = resolve_cargo_path_dependency_graph_with_policy(
4101            &missing.canonical_entrypoint,
4102            &fixture.policy(),
4103        )
4104        .expect_err("missing dependency must fail");
4105        assert_eq!(
4106            error.kind(),
4107            &CargoPathDependencyErrorKind::MissingPathDependency
4108        );
4109    }
4110
4111    #[cfg(unix)]
4112    #[test]
4113    fn outside_root_reports_path_policy_violation() {
4114        let fixture = TopologyFixture::new("outside-root");
4115        let config = MultiRepoFixtureConfig::new(
4116            fixture.canonical_root.clone(),
4117            fixture.alias_root.clone(),
4118            "resolver_outside_root",
4119        );
4120        let fixtures = reset_multi_repo_fixtures(&config).expect("generate fixture set");
4121        let outside = fixtures
4122            .fixture("fail_outside_canonical_dep")
4123            .expect("outside fixture metadata");
4124
4125        let error = resolve_cargo_path_dependency_graph_with_policy(
4126            &outside.canonical_entrypoint,
4127            &fixture.policy(),
4128        )
4129        .expect_err("outside root dependency must fail");
4130        assert_eq!(
4131            error.kind(),
4132            &CargoPathDependencyErrorKind::PathPolicyViolation
4133        );
4134    }
4135
4136    #[cfg(unix)]
4137    #[test]
4138    fn resolved_alias_target_is_allowed_for_absolute_dependency_scope() {
4139        let fixture = TopologyFixture::new("resolved-alias-target");
4140        let alias_target_root = fixture
4141            .alias_root
4142            .canonicalize()
4143            .expect("resolve alias target root");
4144        let dependency_candidate = alias_target_root.join("repo/crate_dep");
4145
4146        validate_absolute_dependency_scope(
4147            &dependency_candidate,
4148            &fixture.policy(),
4149            Path::new("/tmp/Cargo.toml"),
4150            "crate_dep",
4151            "metadata dependency path policy violation",
4152        )
4153        .expect("resolved alias target should be accepted");
4154    }
4155
4156    #[cfg(unix)]
4157    #[test]
4158    fn cyclic_path_dependencies_report_cycle_kind() {
4159        let fixture = TopologyFixture::new("cycle");
4160        let scenario_root = fixture.canonical_root.join("cycle");
4161        let crate_a = scenario_root.join("a");
4162        let crate_b = scenario_root.join("b");
4163
4164        write_lib_crate(&crate_a, "cycle_a", &[("cycle_b", "../b")]);
4165        write_lib_crate(&crate_b, "cycle_b", &[("cycle_a", "../a")]);
4166
4167        let error = resolve_cargo_path_dependency_graph_with_policy(&crate_a, &fixture.policy())
4168            .expect_err("cyclic path dependencies must fail");
4169        assert_eq!(
4170            error.kind(),
4171            &CargoPathDependencyErrorKind::CyclicDependency
4172        );
4173        assert!(
4174            error.cycle().len() >= 3,
4175            "cycle path should include repeated terminal node"
4176        );
4177    }
4178
4179    #[cfg(unix)]
4180    #[test]
4181    fn optional_path_dependency_cycle_is_ignored_for_active_closure() {
4182        let fixture = TopologyFixture::new("optional-cycle");
4183        let scenario_root = fixture.canonical_root.join("optional_cycle");
4184        let app_root = scenario_root.join("app");
4185        let real_dep_root = scenario_root.join("real_dep");
4186        let optional_a_root = scenario_root.join("optional_a");
4187        let optional_b_root = scenario_root.join("optional_b");
4188
4189        write_lib_crate(&real_dep_root, "real_dep", &[]);
4190        write_lib_crate(
4191            &optional_a_root,
4192            "optional_a",
4193            &[("optional_b", "../optional_b")],
4194        );
4195        write_lib_crate(
4196            &optional_b_root,
4197            "optional_b",
4198            &[("optional_a", "../optional_a")],
4199        );
4200
4201        fs::create_dir_all(app_root.join("src")).expect("create app src");
4202        fs::write(
4203            app_root.join("Cargo.toml"),
4204            r#"[package]
4205name = "optional_cycle_app"
4206version = "0.1.0"
4207edition = "2024"
4208
4209[dependencies]
4210real_dep = { path = "../real_dep" }
4211optional_a = { path = "../optional_a", optional = true }
4212"#,
4213        )
4214        .expect("write app manifest");
4215        fs::write(app_root.join("src/main.rs"), "fn main() {}\n").expect("write app main");
4216
4217        let graph = resolve_cargo_path_dependency_graph_with_policy(&app_root, &fixture.policy())
4218            .expect("optional cycle should not poison active dependency closure");
4219        let materialization =
4220            resolve_cargo_path_materialization_closure_with_policy(&app_root, &fixture.policy())
4221                .expect("optional cycle should terminate in materialization closure");
4222
4223        let app_root = app_root.canonicalize().expect("canonical app");
4224        let real_dep_root = real_dep_root.canonicalize().expect("canonical real dep");
4225        let optional_a_root = optional_a_root
4226            .canonicalize()
4227            .expect("canonical optional a");
4228        let optional_b_root = optional_b_root
4229            .canonicalize()
4230            .expect("canonical optional b");
4231        assert_eq!(graph.root_packages, vec![app_root.clone()]);
4232        assert_eq!(
4233            graph.edges,
4234            vec![CargoPathDependencyEdge {
4235                from: app_root.clone(),
4236                to: real_dep_root.clone(),
4237                dependency_name: "real_dep".to_string(),
4238            }]
4239        );
4240
4241        let materialized_roots = materialization
4242            .packages
4243            .iter()
4244            .map(|package| package.package_root.clone())
4245            .collect::<BTreeSet<_>>();
4246        assert_eq!(
4247            materialized_roots,
4248            BTreeSet::from([
4249                app_root.clone(),
4250                real_dep_root.clone(),
4251                optional_a_root.clone(),
4252                optional_b_root.clone(),
4253            ]),
4254            "inactive optional packages must still be available for remote materialization"
4255        );
4256        assert_eq!(
4257            materialization
4258                .edges
4259                .iter()
4260                .cloned()
4261                .collect::<BTreeSet<_>>(),
4262            BTreeSet::from([
4263                CargoPathDependencyEdge {
4264                    from: app_root.clone(),
4265                    to: real_dep_root,
4266                    dependency_name: "real_dep".to_string(),
4267                },
4268                CargoPathDependencyEdge {
4269                    from: optional_a_root.clone(),
4270                    to: optional_b_root.clone(),
4271                    dependency_name: "optional_b".to_string(),
4272                },
4273                CargoPathDependencyEdge {
4274                    from: optional_b_root,
4275                    to: optional_a_root.clone(),
4276                    dependency_name: "optional_a".to_string(),
4277                },
4278                CargoPathDependencyEdge {
4279                    from: app_root,
4280                    to: optional_a_root,
4281                    dependency_name: "optional_a".to_string(),
4282                },
4283            ]),
4284            "materialization evidence should retain the optional cycle without traversing forever"
4285        );
4286    }
4287
4288    #[cfg(unix)]
4289    #[test]
4290    fn materialization_closure_covers_every_manifest_path_dependency_class() {
4291        let fixture = TopologyFixture::new("materialization-all-kinds");
4292        let scenario_root = fixture
4293            .canonical_root
4294            .join("materialization_all_dependency_kinds");
4295        let workspace_root = scenario_root.join("workspace");
4296        let app_root = workspace_root.join("app");
4297        let build_dep_root = scenario_root.join("build_dep");
4298        let dev_dep_root = scenario_root.join("dev_dep");
4299        let optional_dep_root = scenario_root.join("optional_dep");
4300        let patched_dep_root = scenario_root.join("patched_dep");
4301        let target_dep_root = scenario_root.join("target_dep");
4302        let transitive_dep_root = scenario_root.join("transitive_dep");
4303        let unused_patch_dep_root = scenario_root.join("unused_patch_dep");
4304        let workspace_dep_root = scenario_root.join("workspace_dep");
4305
4306        write_lib_crate(&build_dep_root, "build_dep", &[]);
4307        write_lib_crate(&dev_dep_root, "dev_dep", &[]);
4308        write_lib_crate(
4309            &optional_dep_root,
4310            "optional_dep",
4311            &[("transitive_dep", "../transitive_dep")],
4312        );
4313        write_lib_crate(&patched_dep_root, "patched_dep", &[]);
4314        write_lib_crate(&target_dep_root, "target_dep", &[]);
4315        write_lib_crate(&transitive_dep_root, "transitive_dep", &[]);
4316        write_lib_crate(&unused_patch_dep_root, "unused_patch_dep", &[]);
4317        write_lib_crate(&workspace_dep_root, "workspace_dep", &[]);
4318
4319        fs::create_dir_all(app_root.join("src")).expect("create app src");
4320        fs::write(
4321            app_root.join("Cargo.toml"),
4322            r#"[package]
4323name = "materialization_app"
4324version = "0.1.0"
4325edition = "2024"
4326
4327[dependencies]
4328optional_dep = { path = "../../optional_dep", optional = true }
4329patched_dep = "0.1"
4330workspace_dep = { workspace = true, optional = true }
4331
4332[build-dependencies]
4333build_dep = { path = "../../build_dep" }
4334
4335[dev-dependencies]
4336dev_dep = { path = "../../dev_dep" }
4337
4338[target.'cfg(target_os = "linux")'.dependencies]
4339target_dep = { path = "../../target_dep" }
4340"#,
4341        )
4342        .expect("write app manifest");
4343        fs::write(app_root.join("src/lib.rs"), "pub fn app() {}\n").expect("write app lib");
4344        fs::create_dir_all(&workspace_root).expect("create workspace root");
4345        fs::write(
4346            workspace_root.join("Cargo.toml"),
4347            r#"[workspace]
4348members = ["app"]
4349resolver = "3"
4350
4351[workspace.dependencies]
4352workspace_dep = { path = "../workspace_dep" }
4353
4354[patch.crates-io]
4355patched_dep = { path = "../patched_dep" }
4356unused_patch_dep = { path = "../unused_patch_dep" }
4357"#,
4358        )
4359        .expect("write workspace manifest");
4360
4361        let first = resolve_cargo_path_materialization_closure_with_policy(
4362            &workspace_root,
4363            &fixture.policy(),
4364        )
4365        .expect("resolve complete materialization closure");
4366        let second = resolve_cargo_path_materialization_closure_with_policy(
4367            &workspace_root,
4368            &fixture.policy(),
4369        )
4370        .expect("repeat complete materialization closure");
4371        assert_eq!(first, second, "materialization must be deterministic");
4372
4373        let expected_roots = [
4374            &app_root,
4375            &build_dep_root,
4376            &dev_dep_root,
4377            &optional_dep_root,
4378            &patched_dep_root,
4379            &target_dep_root,
4380            &transitive_dep_root,
4381            &unused_patch_dep_root,
4382            &workspace_dep_root,
4383        ]
4384        .into_iter()
4385        .map(|root| root.canonicalize().expect("canonical fixture package"))
4386        .collect::<BTreeSet<_>>();
4387        let observed_roots = first
4388            .packages
4389            .iter()
4390            .map(|package| package.package_root.clone())
4391            .collect::<BTreeSet<_>>();
4392        assert_eq!(observed_roots, expected_roots);
4393
4394        let dependency_names = first
4395            .edges
4396            .iter()
4397            .map(|edge| edge.dependency_name.as_str())
4398            .collect::<BTreeSet<_>>();
4399        assert_eq!(
4400            dependency_names,
4401            BTreeSet::from([
4402                "build_dep",
4403                "dev_dep",
4404                "optional_dep",
4405                "patched_dep",
4406                "target_dep",
4407                "transitive_dep",
4408                "workspace_dep",
4409            ]),
4410            "normal, build, dev, target, optional, workspace, patch, and transitive paths must all materialize"
4411        );
4412    }
4413
4414    // ── Error type construction and accessor tests ──
4415
4416    #[test]
4417    fn error_kind_display_all_variants() {
4418        let variants = [
4419            (
4420                CargoPathDependencyErrorKind::ManifestParseFailure,
4421                "manifest parse failure",
4422            ),
4423            (
4424                CargoPathDependencyErrorKind::MissingPathDependency,
4425                "missing path dependency",
4426            ),
4427            (
4428                CargoPathDependencyErrorKind::CyclicDependency,
4429                "cyclic path dependency",
4430            ),
4431            (
4432                CargoPathDependencyErrorKind::PathPolicyViolation,
4433                "path policy violation",
4434            ),
4435            (
4436                CargoPathDependencyErrorKind::MetadataParseFailure,
4437                "metadata parse failure",
4438            ),
4439            (
4440                CargoPathDependencyErrorKind::MetadataInvocationFailure,
4441                "metadata invocation failure",
4442            ),
4443        ];
4444        for (kind, expected) in variants {
4445            assert_eq!(kind.to_string(), expected, "Display for {kind:?}");
4446        }
4447    }
4448
4449    #[test]
4450    fn error_accessors_return_builder_values() {
4451        let error = CargoPathDependencyError::new(
4452            CargoPathDependencyErrorKind::MissingPathDependency,
4453            "test detail",
4454        )
4455        .with_manifest_path("/a/Cargo.toml")
4456        .with_dependency_name("dep_x")
4457        .with_dependency_path("/b/dep_x");
4458
4459        assert_eq!(
4460            error.kind(),
4461            &CargoPathDependencyErrorKind::MissingPathDependency
4462        );
4463        assert_eq!(error.detail(), "test detail");
4464        assert_eq!(error.manifest_path(), Some(Path::new("/a/Cargo.toml")));
4465        assert_eq!(error.dependency_name(), Some("dep_x"));
4466        assert_eq!(error.dependency_path(), Some(Path::new("/b/dep_x")));
4467        assert!(error.cycle().is_empty());
4468        assert!(error.diagnostics().is_empty());
4469    }
4470
4471    #[test]
4472    fn error_display_includes_all_fields() {
4473        let error = CargoPathDependencyError::new(
4474            CargoPathDependencyErrorKind::MissingPathDependency,
4475            "not found",
4476        )
4477        .with_manifest_path("/a/Cargo.toml")
4478        .with_dependency_name("missing_dep")
4479        .with_dependency_path("/b/missing");
4480
4481        let display = error.to_string();
4482        assert!(
4483            display.contains("missing path dependency"),
4484            "should contain kind"
4485        );
4486        assert!(display.contains("not found"), "should contain detail");
4487        assert!(
4488            display.contains("/a/Cargo.toml"),
4489            "should contain manifest path"
4490        );
4491        assert!(
4492            display.contains("missing_dep"),
4493            "should contain dependency name"
4494        );
4495        assert!(
4496            display.contains("/b/missing"),
4497            "should contain dependency path"
4498        );
4499    }
4500
4501    #[test]
4502    fn error_diagnostics_accumulate() {
4503        let error = CargoPathDependencyError::new(
4504            CargoPathDependencyErrorKind::MetadataParseFailure,
4505            "parse error",
4506        )
4507        .with_diagnostic("line 1")
4508        .with_diagnostics(["line 2", "line 3"]);
4509
4510        assert_eq!(error.diagnostics().len(), 3);
4511        assert_eq!(error.diagnostics()[0], "line 1");
4512        assert_eq!(error.diagnostics()[1], "line 2");
4513        assert_eq!(error.diagnostics()[2], "line 3");
4514    }
4515
4516    #[test]
4517    fn error_implements_std_error() {
4518        let error = CargoPathDependencyError::new(
4519            CargoPathDependencyErrorKind::ManifestParseFailure,
4520            "bad toml",
4521        );
4522        let _: &dyn std::error::Error = &error;
4523    }
4524
4525    // ── Internal helper function tests ──
4526
4527    #[test]
4528    fn default_package_name_uses_last_component() {
4529        assert_eq!(default_package_name(Path::new("/a/b/my_crate")), "my_crate");
4530        assert_eq!(default_package_name(Path::new("/single")), "single");
4531    }
4532
4533    #[test]
4534    fn default_package_name_root_path_uses_display() {
4535        let name = default_package_name(Path::new("/"));
4536        assert!(
4537            !name.is_empty(),
4538            "root path should produce a non-empty name"
4539        );
4540    }
4541
4542    #[test]
4543    fn resolve_dependency_candidate_relative_path() {
4544        let result = resolve_dependency_candidate(Path::new("/project/app"), "../lib");
4545        assert_eq!(result, PathBuf::from("/project/app/../lib"));
4546    }
4547
4548    #[test]
4549    fn resolve_dependency_candidate_absolute_path() {
4550        let result = resolve_dependency_candidate(Path::new("/project/app"), "/other/lib");
4551        assert_eq!(result, PathBuf::from("/other/lib"));
4552    }
4553
4554    #[test]
4555    fn resolve_dependency_candidate_strips_cargo_toml_suffix() {
4556        let result = resolve_dependency_candidate(Path::new("/project/app"), "../lib/Cargo.toml");
4557        assert_eq!(result, PathBuf::from("/project/app/../lib"));
4558    }
4559
4560    #[test]
4561    fn resolve_dependency_candidate_preserves_non_cargo_toml() {
4562        let result = resolve_dependency_candidate(Path::new("/project/app"), "../lib/src");
4563        assert_eq!(result, PathBuf::from("/project/app/../lib/src"));
4564    }
4565
4566    // ── Wildcard / glob helper tests ──
4567
4568    #[test]
4569    fn contains_glob_detects_patterns() {
4570        assert!(contains_glob("crates/*"));
4571        assert!(contains_glob("lib?"));
4572        assert!(contains_glob("[abc]"));
4573        assert!(!contains_glob("plain_path"));
4574        assert!(!contains_glob(""));
4575    }
4576
4577    #[test]
4578    fn contains_wildcard_detects_star_and_question() {
4579        assert!(contains_wildcard("*"));
4580        assert!(contains_wildcard("foo*"));
4581        assert!(contains_wildcard("fo?"));
4582        assert!(!contains_wildcard("plain"));
4583        assert!(!contains_wildcard("[abc]"));
4584    }
4585
4586    #[test]
4587    fn wildcard_match_exact() {
4588        assert!(wildcard_match("hello", "hello"));
4589        assert!(!wildcard_match("hello", "world"));
4590    }
4591
4592    #[test]
4593    fn wildcard_match_star_patterns() {
4594        assert!(wildcard_match("*", "anything"));
4595        assert!(wildcard_match("*", ""));
4596        assert!(wildcard_match("he*o", "hello"));
4597        assert!(wildcard_match("he*o", "heo"));
4598        assert!(!wildcard_match("he*o", "hex"));
4599        assert!(wildcard_match("*.*", "file.rs"));
4600        assert!(!wildcard_match("*.*", "nodot"));
4601    }
4602
4603    #[test]
4604    fn wildcard_match_question_mark() {
4605        assert!(wildcard_match("h?llo", "hello"));
4606        assert!(wildcard_match("h?llo", "hallo"));
4607        assert!(!wildcard_match("h?llo", "hllo"));
4608        assert!(!wildcard_match("?", ""));
4609    }
4610
4611    #[test]
4612    fn wildcard_match_combined() {
4613        assert!(wildcard_match("rch-*", "rch-common"));
4614        assert!(wildcard_match("rch-*", "rch-"));
4615        assert!(!wildcard_match("rch-*", "rch"));
4616    }
4617
4618    #[test]
4619    fn wildcard_match_empty_pattern_and_value() {
4620        assert!(wildcard_match("", ""));
4621        assert!(!wildcard_match("", "x"));
4622        assert!(wildcard_match("*", ""));
4623    }
4624
4625    // ── cycle_from_stack tests ──
4626
4627    #[test]
4628    fn cycle_from_stack_extracts_cycle_segment() {
4629        let stack = vec![
4630            PathBuf::from("/a"),
4631            PathBuf::from("/b"),
4632            PathBuf::from("/c"),
4633        ];
4634        let cycle = cycle_from_stack(&stack, Path::new("/b"));
4635        assert_eq!(
4636            cycle,
4637            vec![
4638                PathBuf::from("/b"),
4639                PathBuf::from("/c"),
4640                PathBuf::from("/b"),
4641            ]
4642        );
4643    }
4644
4645    #[test]
4646    fn cycle_from_stack_terminal_not_in_stack() {
4647        let stack = vec![PathBuf::from("/a")];
4648        let cycle = cycle_from_stack(&stack, Path::new("/z"));
4649        assert_eq!(cycle, vec![PathBuf::from("/z")]);
4650    }
4651
4652    #[test]
4653    fn cycle_from_stack_single_node_self_cycle() {
4654        let stack = vec![PathBuf::from("/a")];
4655        let cycle = cycle_from_stack(&stack, Path::new("/a"));
4656        assert_eq!(cycle, vec![PathBuf::from("/a"), PathBuf::from("/a")]);
4657    }
4658
4659    #[test]
4660    fn cycle_from_stack_empty_stack() {
4661        let stack: Vec<PathBuf> = vec![];
4662        let cycle = cycle_from_stack(&stack, Path::new("/a"));
4663        assert_eq!(cycle, vec![PathBuf::from("/a")]);
4664    }
4665
4666    // ── finalize_graph tests ──
4667
4668    #[test]
4669    fn finalize_empty_graph() {
4670        let partial = PartialGraph::default();
4671        let graph =
4672            finalize_graph(PathBuf::from("/fake/Cargo.toml"), partial).expect("should succeed");
4673        assert!(graph.packages.is_empty());
4674        assert!(graph.edges.is_empty());
4675        assert!(graph.root_packages.is_empty());
4676        assert!(graph.workspace_root.is_none());
4677    }
4678
4679    #[test]
4680    fn finalize_graph_single_root_no_edges() {
4681        let mut partial = PartialGraph::default();
4682        partial.add_root(PathBuf::from("/project"));
4683        partial.add_package(
4684            PathBuf::from("/project"),
4685            PathBuf::from("/project/Cargo.toml"),
4686            "my_project".to_string(),
4687            true,
4688        );
4689
4690        let graph =
4691            finalize_graph(PathBuf::from("/project/Cargo.toml"), partial).expect("should succeed");
4692        assert_eq!(graph.packages.len(), 1);
4693        assert_eq!(graph.packages[0].package_name, "my_project");
4694        assert!(graph.packages[0].workspace_member);
4695        assert!(graph.edges.is_empty());
4696        assert_eq!(graph.root_packages, vec![PathBuf::from("/project")]);
4697    }
4698
4699    #[test]
4700    fn finalize_graph_unreachable_packages_excluded() {
4701        let mut partial = PartialGraph::default();
4702        partial.add_root(PathBuf::from("/root"));
4703        partial.add_package(
4704            PathBuf::from("/root"),
4705            PathBuf::from("/root/Cargo.toml"),
4706            "root_pkg".to_string(),
4707            true,
4708        );
4709        // Add an unreachable package (no edge from root)
4710        partial.add_package(
4711            PathBuf::from("/orphan"),
4712            PathBuf::from("/orphan/Cargo.toml"),
4713            "orphan_pkg".to_string(),
4714            false,
4715        );
4716
4717        let graph =
4718            finalize_graph(PathBuf::from("/root/Cargo.toml"), partial).expect("should succeed");
4719        assert_eq!(graph.packages.len(), 1, "orphan should be excluded");
4720        assert_eq!(graph.packages[0].package_name, "root_pkg");
4721    }
4722
4723    #[test]
4724    fn finalize_graph_detects_cycle() {
4725        let mut partial = PartialGraph::default();
4726        partial.add_root(PathBuf::from("/a"));
4727        partial.add_package(
4728            PathBuf::from("/a"),
4729            PathBuf::from("/a/Cargo.toml"),
4730            "a".to_string(),
4731            true,
4732        );
4733        partial.add_package(
4734            PathBuf::from("/b"),
4735            PathBuf::from("/b/Cargo.toml"),
4736            "b".to_string(),
4737            false,
4738        );
4739        partial.add_edge(PathBuf::from("/a"), PathBuf::from("/b"), "b".to_string());
4740        partial.add_edge(PathBuf::from("/b"), PathBuf::from("/a"), "a".to_string());
4741
4742        let error = finalize_graph(PathBuf::from("/a/Cargo.toml"), partial)
4743            .expect_err("should detect cycle");
4744        assert_eq!(
4745            error.kind(),
4746            &CargoPathDependencyErrorKind::CyclicDependency
4747        );
4748        assert!(
4749            error.cycle().len() >= 2,
4750            "cycle should include at least the two nodes"
4751        );
4752    }
4753
4754    #[test]
4755    fn finalize_graph_diamond_reachable() {
4756        // A -> B, A -> C, B -> D, C -> D
4757        let mut partial = PartialGraph::default();
4758        partial.add_root(PathBuf::from("/a"));
4759        for (name, root) in [("a", "/a"), ("b", "/b"), ("c", "/c"), ("d", "/d")] {
4760            partial.add_package(
4761                PathBuf::from(root),
4762                PathBuf::from(format!("{root}/Cargo.toml")),
4763                name.to_string(),
4764                name == "a",
4765            );
4766        }
4767        partial.add_edge(PathBuf::from("/a"), PathBuf::from("/b"), "b".to_string());
4768        partial.add_edge(PathBuf::from("/a"), PathBuf::from("/c"), "c".to_string());
4769        partial.add_edge(PathBuf::from("/b"), PathBuf::from("/d"), "d".to_string());
4770        partial.add_edge(PathBuf::from("/c"), PathBuf::from("/d"), "d".to_string());
4771
4772        let graph =
4773            finalize_graph(PathBuf::from("/a/Cargo.toml"), partial).expect("should succeed");
4774        assert_eq!(graph.packages.len(), 4, "all 4 nodes reachable in diamond");
4775        assert_eq!(graph.edges.len(), 4, "all 4 edges present");
4776        // Packages should be sorted by BTreeSet order
4777        let names: Vec<_> = graph
4778            .packages
4779            .iter()
4780            .map(|p| p.package_name.as_str())
4781            .collect();
4782        assert_eq!(names, vec!["a", "b", "c", "d"]);
4783    }
4784
4785    // ── Graph serialization round-trip ──
4786
4787    #[test]
4788    fn graph_serialization_round_trip() {
4789        let graph = CargoPathDependencyGraph {
4790            entry_manifest_path: PathBuf::from("/project/Cargo.toml"),
4791            workspace_root: Some(PathBuf::from("/project")),
4792            root_packages: vec![PathBuf::from("/project/app")],
4793            packages: vec![
4794                CargoPathDependencyPackage {
4795                    package_root: PathBuf::from("/project/app"),
4796                    manifest_path: PathBuf::from("/project/app/Cargo.toml"),
4797                    package_name: "app".to_string(),
4798                    workspace_member: true,
4799                },
4800                CargoPathDependencyPackage {
4801                    package_root: PathBuf::from("/project/lib"),
4802                    manifest_path: PathBuf::from("/project/lib/Cargo.toml"),
4803                    package_name: "lib".to_string(),
4804                    workspace_member: true,
4805                },
4806            ],
4807            edges: vec![CargoPathDependencyEdge {
4808                from: PathBuf::from("/project/app"),
4809                to: PathBuf::from("/project/lib"),
4810                dependency_name: "lib".to_string(),
4811            }],
4812        };
4813
4814        let json = serde_json::to_string(&graph).expect("serialize");
4815        let deserialized: CargoPathDependencyGraph =
4816            serde_json::from_str(&json).expect("deserialize");
4817        assert_eq!(graph, deserialized);
4818    }
4819
4820    // ── Edge type ordering ──
4821
4822    #[test]
4823    fn edge_ordering_is_deterministic() {
4824        let edge_a = CargoPathDependencyEdge {
4825            from: PathBuf::from("/a"),
4826            to: PathBuf::from("/b"),
4827            dependency_name: "b".to_string(),
4828        };
4829        let edge_b = CargoPathDependencyEdge {
4830            from: PathBuf::from("/a"),
4831            to: PathBuf::from("/c"),
4832            dependency_name: "c".to_string(),
4833        };
4834        let edge_c = CargoPathDependencyEdge {
4835            from: PathBuf::from("/b"),
4836            to: PathBuf::from("/c"),
4837            dependency_name: "c".to_string(),
4838        };
4839
4840        let mut edges = vec![edge_c.clone(), edge_a.clone(), edge_b.clone()];
4841        edges.sort();
4842        assert_eq!(edges, vec![edge_a, edge_b, edge_c]);
4843    }
4844
4845    // ── Metadata provider fallback tests ──
4846
4847    #[cfg(unix)]
4848    #[test]
4849    fn metadata_error_then_fallback_success() {
4850        let fixture = TopologyFixture::new("meta-err-fallback");
4851        let scenario_root = fixture.canonical_root.join("meta_fallback");
4852        let app_root = scenario_root.join("app");
4853        let dep_root = scenario_root.join("dep");
4854
4855        write_lib_crate(&dep_root, "fb_dep", &[]);
4856        write_bin_crate(&app_root, "fb_app", &[("fb_dep", "../dep")]);
4857
4858        // Metadata provider returns an error, fallback should recover
4859        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
4860            &app_root,
4861            &fixture.policy(),
4862            |_| {
4863                Err(CargoPathDependencyError::new(
4864                    CargoPathDependencyErrorKind::MetadataInvocationFailure,
4865                    "simulated cargo metadata failure",
4866                ))
4867            },
4868        )
4869        .expect("should recover via manifest fallback");
4870
4871        let app_root = app_root.canonicalize().expect("canonical app");
4872        let dep_root = dep_root.canonicalize().expect("canonical dep");
4873        assert_eq!(graph.root_packages, vec![app_root.clone()]);
4874        assert_eq!(graph.edges.len(), 1);
4875        assert_eq!(graph.edges[0].from, app_root);
4876        assert_eq!(graph.edges[0].to, dep_root);
4877    }
4878
4879    #[cfg(unix)]
4880    #[test]
4881    fn metadata_provider_with_synthetic_json() {
4882        let fixture = TopologyFixture::new("synthetic-meta");
4883        let scenario_root = fixture.canonical_root.join("synth_meta");
4884        let app_root = scenario_root.join("app");
4885        let dep_root = scenario_root.join("dep");
4886
4887        write_lib_crate(&dep_root, "synth_dep", &[]);
4888        write_bin_crate(&app_root, "synth_app", &[("synth_dep", "../dep")]);
4889
4890        // Provide well-formed synthetic metadata JSON
4891        let app_canonical = app_root.canonicalize().expect("canonical app");
4892        let dep_canonical = dep_root.canonicalize().expect("canonical dep");
4893        let app_manifest = app_canonical.join("Cargo.toml");
4894        let dep_manifest = dep_canonical.join("Cargo.toml");
4895
4896        let metadata_json = format!(
4897            r#"{{
4898                "packages": [
4899                    {{
4900                        "id": "synth_app 0.1.0 (path+file://{})",
4901                        "name": "synth_app",
4902                        "manifest_path": "{}",
4903                        "dependencies": [
4904                            {{"name": "synth_dep", "path": "{}"}}
4905                        ]
4906                    }},
4907                    {{
4908                        "id": "synth_dep 0.1.0 (path+file://{})",
4909                        "name": "synth_dep",
4910                        "manifest_path": "{}",
4911                        "dependencies": []
4912                    }}
4913                ],
4914                "workspace_members": [],
4915                "workspace_root": null,
4916                "resolve": {{
4917                    "root": "synth_app 0.1.0 (path+file://{})"
4918                }}
4919            }}"#,
4920            app_canonical.display(),
4921            app_manifest.display(),
4922            dep_canonical.display(),
4923            dep_canonical.display(),
4924            dep_manifest.display(),
4925            app_canonical.display(),
4926        );
4927
4928        let json_clone = metadata_json.clone();
4929        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
4930            &app_root,
4931            &fixture.policy(),
4932            move |_| Ok(json_clone.clone()),
4933        )
4934        .expect("synthetic metadata should resolve");
4935
4936        assert_eq!(graph.root_packages, vec![app_canonical.clone()]);
4937        assert_eq!(graph.edges.len(), 1);
4938        assert_eq!(graph.edges[0].dependency_name, "synth_dep");
4939    }
4940
4941    #[cfg(unix)]
4942    #[test]
4943    fn metadata_resolve_ignores_pure_dev_path_edges() {
4944        let fixture = TopologyFixture::new("synthetic-meta-dev-filter");
4945        let scenario_root = fixture.canonical_root.join("synth_meta_dev_filter");
4946        let app_root = scenario_root.join("app");
4947        let dep_root = scenario_root.join("dep");
4948        let dev_dep_root = scenario_root.join("dev_dep");
4949
4950        write_lib_crate(&dep_root, "runtime_dep", &[]);
4951        write_lib_crate(&dev_dep_root, "dev_only_dep", &[]);
4952        write_bin_crate(&app_root, "dev_filter_app", &[]);
4953
4954        let app_canonical = app_root.canonicalize().expect("canonical app");
4955        let dep_canonical = dep_root.canonicalize().expect("canonical dep");
4956        let dev_dep_canonical = dev_dep_root.canonicalize().expect("canonical dev dep");
4957        let app_manifest = app_canonical.join("Cargo.toml");
4958        let dep_manifest = dep_canonical.join("Cargo.toml");
4959        let dev_dep_manifest = dev_dep_canonical.join("Cargo.toml");
4960
4961        let metadata_json = format!(
4962            r#"{{
4963                "packages": [
4964                    {{
4965                        "id": "dev_filter_app 0.1.0 (path+file://{app_root})",
4966                        "name": "dev_filter_app",
4967                        "manifest_path": "{app_manifest}",
4968                        "dependencies": [
4969                            {{"name": "runtime_dep", "path": "{dep_root}"}},
4970                            {{"name": "dev_only_dep", "path": "{dev_dep_root}"}}
4971                        ]
4972                    }},
4973                    {{
4974                        "id": "runtime_dep 0.1.0 (path+file://{dep_root})",
4975                        "name": "runtime_dep",
4976                        "manifest_path": "{dep_manifest}",
4977                        "dependencies": []
4978                    }},
4979                    {{
4980                        "id": "dev_only_dep 0.1.0 (path+file://{dev_dep_root})",
4981                        "name": "dev_only_dep",
4982                        "manifest_path": "{dev_dep_manifest}",
4983                        "dependencies": []
4984                    }}
4985                ],
4986                "workspace_members": [],
4987                "workspace_root": null,
4988                "resolve": {{
4989                    "root": "dev_filter_app 0.1.0 (path+file://{app_root})",
4990                    "nodes": [
4991                        {{
4992                            "id": "dev_filter_app 0.1.0 (path+file://{app_root})",
4993                            "deps": [
4994                                {{
4995                                    "name": "runtime_dep",
4996                                    "pkg": "runtime_dep 0.1.0 (path+file://{dep_root})",
4997                                    "dep_kinds": [{{"kind": null, "target": null}}]
4998                                }},
4999                                {{
5000                                    "name": "dev_only_dep",
5001                                    "pkg": "dev_only_dep 0.1.0 (path+file://{dev_dep_root})",
5002                                    "dep_kinds": [{{"kind": "dev", "target": null}}]
5003                                }}
5004                            ]
5005                        }},
5006                        {{
5007                            "id": "runtime_dep 0.1.0 (path+file://{dep_root})",
5008                            "deps": []
5009                        }},
5010                        {{
5011                            "id": "dev_only_dep 0.1.0 (path+file://{dev_dep_root})",
5012                            "deps": []
5013                        }}
5014                    ]
5015                }}
5016            }}"#,
5017            app_root = app_canonical.display(),
5018            app_manifest = app_manifest.display(),
5019            dep_root = dep_canonical.display(),
5020            dep_manifest = dep_manifest.display(),
5021            dev_dep_root = dev_dep_canonical.display(),
5022            dev_dep_manifest = dev_dep_manifest.display(),
5023        );
5024
5025        let json_clone = metadata_json.clone();
5026        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
5027            &app_root,
5028            &fixture.policy(),
5029            move |_| Ok(json_clone.clone()),
5030        )
5031        .expect("synthetic metadata with dev-only edge should resolve");
5032
5033        assert_eq!(graph.root_packages, vec![app_canonical.clone()]);
5034        assert_eq!(
5035            graph.edges.len(),
5036            1,
5037            "pure dev-only path edges must be ignored"
5038        );
5039        assert_eq!(graph.edges[0].dependency_name, "runtime_dep");
5040        assert_eq!(graph.edges[0].from, app_canonical);
5041        assert_eq!(graph.edges[0].to, dep_canonical);
5042        assert!(
5043            graph
5044                .packages
5045                .iter()
5046                .all(|pkg| pkg.package_root != dev_dep_canonical),
5047            "dev-only dependency package should not be pulled into runtime closure"
5048        );
5049    }
5050
5051    #[cfg(unix)]
5052    #[test]
5053    fn metadata_resolve_excludes_cargo_home_packages_inside_topology_root() {
5054        // Issue #77: with CARGO_HOME under canonical_root, registry and git
5055        // packages from Cargo's caches must not become closure packages. A
5056        // vendored (`[source] directory`) crate keeps its registry source id
5057        // but is a real local input, so it stays.
5058        let fixture = TopologyFixture::new("synthetic-meta-cargo-home");
5059        let scenario_root = fixture.canonical_root.join("synth_meta_cargo_home");
5060        let app_root = scenario_root.join("app");
5061        let cargo_home = scenario_root.join(".cargo");
5062        let registry_root = cargo_home.join("registry/src/index.crates.io-abc/itoa-1.0.15");
5063        let git_root = cargo_home.join("git/checkouts/gitdep-0123/abcdef0");
5064        let vendor_root = scenario_root.join("vendor/ryu");
5065
5066        write_bin_crate(&app_root, "cargo_home_app", &[]);
5067        write_lib_crate(&registry_root, "itoa", &[]);
5068        write_lib_crate(&git_root, "gitdep", &[]);
5069        write_lib_crate(&vendor_root, "ryu", &[]);
5070
5071        let app = app_root.canonicalize().expect("canonical app");
5072        let registry = registry_root.canonicalize().expect("canonical registry");
5073        let git = git_root.canonicalize().expect("canonical git");
5074        let vendor = vendor_root.canonicalize().expect("canonical vendor");
5075        let cargo_home = cargo_home.canonicalize().expect("canonical cargo home");
5076        let crates_io = "registry+https://github.com/rust-lang/crates.io-index";
5077
5078        let metadata_json = format!(
5079            r#"{{
5080                "packages": [
5081                    {{"id": "app", "name": "cargo_home_app", "source": null,
5082                      "manifest_path": "{app}/Cargo.toml", "dependencies": []}},
5083                    {{"id": "itoa", "name": "itoa", "source": "{crates_io}",
5084                      "manifest_path": "{registry}/Cargo.toml", "dependencies": []}},
5085                    {{"id": "gitdep", "name": "gitdep", "source": "git+https://example.com/gitdep#abcdef0",
5086                      "manifest_path": "{git}/Cargo.toml", "dependencies": []}},
5087                    {{"id": "ryu", "name": "ryu", "source": "{crates_io}",
5088                      "manifest_path": "{vendor}/Cargo.toml", "dependencies": []}}
5089                ],
5090                "workspace_members": ["app"],
5091                "workspace_root": "{app}",
5092                "resolve": {{
5093                    "root": "app",
5094                    "nodes": [
5095                        {{"id": "app", "deps": [
5096                            {{"name": "itoa", "pkg": "itoa", "dep_kinds": [{{"kind": null, "target": null}}]}},
5097                            {{"name": "gitdep", "pkg": "gitdep", "dep_kinds": [{{"kind": null, "target": null}}]}},
5098                            {{"name": "ryu", "pkg": "ryu", "dep_kinds": [{{"kind": null, "target": null}}]}}
5099                        ]}},
5100                        {{"id": "itoa", "deps": []}},
5101                        {{"id": "gitdep", "deps": []}},
5102                        {{"id": "ryu", "deps": []}}
5103                    ]
5104                }}
5105            }}"#,
5106            app = app.display(),
5107            registry = registry.display(),
5108            git = git.display(),
5109            vendor = vendor.display(),
5110        );
5111
5112        let package_roots = |cargo_home: Option<&Path>| {
5113            let json = metadata_json.clone();
5114            let graph = resolve_cargo_path_dependency_graph_with_cargo_home(
5115                &app_root,
5116                &fixture.policy(),
5117                move |_| Ok(json.clone()),
5118                cargo_home,
5119            )
5120            .expect("synthetic metadata should resolve");
5121            graph
5122                .packages
5123                .iter()
5124                .map(|package| package.package_root.clone())
5125                .collect::<BTreeSet<_>>()
5126        };
5127
5128        assert_eq!(
5129            package_roots(Some(&cargo_home)),
5130            BTreeSet::from([app.clone(), vendor.clone()]),
5131            "Cargo-home registry/git packages must not enter the closure"
5132        );
5133        // Control: a Cargo home elsewhere leaves in-root sourced packages alone.
5134        assert_eq!(
5135            package_roots(Some(&fixture.root.join("elsewhere-cargo-home"))),
5136            BTreeSet::from([app, registry, git, vendor]),
5137        );
5138    }
5139
5140    #[cfg(unix)]
5141    #[test]
5142    fn metadata_resolve_skips_non_local_packages_and_keeps_local_edges() {
5143        let fixture = TopologyFixture::new("synthetic-meta-nonlocal");
5144        let scenario_root = fixture.canonical_root.join("synth_meta_nonlocal");
5145        let app_root = scenario_root.join("app");
5146        let dep_root = scenario_root.join("dep");
5147        let external_root = fixture.root.join("external_registry/serde");
5148
5149        write_lib_crate(&dep_root, "local_dep", &[]);
5150        write_bin_crate(&app_root, "meta_nonlocal_app", &[]);
5151        write_lib_crate(&external_root, "serde", &[]);
5152
5153        let app_canonical = app_root.canonicalize().expect("canonical app");
5154        let dep_canonical = dep_root.canonicalize().expect("canonical dep");
5155        let external_canonical = external_root
5156            .canonicalize()
5157            .expect("canonical external dep");
5158        let app_manifest = app_canonical.join("Cargo.toml");
5159        let dep_manifest = dep_canonical.join("Cargo.toml");
5160        let external_manifest = external_canonical.join("Cargo.toml");
5161
5162        let metadata_json = format!(
5163            r#"{{
5164                "packages": [
5165                    {{
5166                        "id": "meta_nonlocal_app 0.1.0 (path+file://{app_root})",
5167                        "name": "meta_nonlocal_app",
5168                        "manifest_path": "{app_manifest}",
5169                        "dependencies": [
5170                            {{"name": "local_dep", "path": "{dep_root}"}},
5171                            {{"name": "serde", "path": null}}
5172                        ]
5173                    }},
5174                    {{
5175                        "id": "local_dep 0.1.0 (path+file://{dep_root})",
5176                        "name": "local_dep",
5177                        "manifest_path": "{dep_manifest}",
5178                        "dependencies": []
5179                    }},
5180                    {{
5181                        "id": "serde 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)",
5182                        "name": "serde",
5183                        "manifest_path": "{external_manifest}",
5184                        "dependencies": []
5185                    }}
5186                ],
5187                "workspace_members": [
5188                    "meta_nonlocal_app 0.1.0 (path+file://{app_root})"
5189                ],
5190                "workspace_root": null,
5191                "resolve": {{
5192                    "root": "meta_nonlocal_app 0.1.0 (path+file://{app_root})",
5193                    "nodes": [
5194                        {{
5195                            "id": "meta_nonlocal_app 0.1.0 (path+file://{app_root})",
5196                            "deps": [
5197                                {{
5198                                    "name": "local_dep",
5199                                    "pkg": "local_dep 0.1.0 (path+file://{dep_root})",
5200                                    "dep_kinds": [{{"kind": null, "target": null}}]
5201                                }},
5202                                {{
5203                                    "name": "serde",
5204                                    "pkg": "serde 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)",
5205                                    "dep_kinds": [{{"kind": null, "target": null}}]
5206                                }}
5207                            ]
5208                        }},
5209                        {{
5210                            "id": "local_dep 0.1.0 (path+file://{dep_root})",
5211                            "deps": []
5212                        }},
5213                        {{
5214                            "id": "serde 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)",
5215                            "deps": []
5216                        }}
5217                    ]
5218                }}
5219            }}"#,
5220            app_root = app_canonical.display(),
5221            app_manifest = app_manifest.display(),
5222            dep_root = dep_canonical.display(),
5223            dep_manifest = dep_manifest.display(),
5224            external_manifest = external_manifest.display(),
5225        );
5226
5227        let json_clone = metadata_json.clone();
5228        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
5229            &app_root,
5230            &fixture.policy(),
5231            move |_| Ok(json_clone.clone()),
5232        )
5233        .expect("synthetic metadata with non-local packages should still resolve");
5234
5235        assert_eq!(graph.root_packages, vec![app_canonical.clone()]);
5236        assert_eq!(
5237            graph.edges,
5238            vec![CargoPathDependencyEdge {
5239                from: app_canonical.clone(),
5240                to: dep_canonical.clone(),
5241                dependency_name: "local_dep".to_string(),
5242            }]
5243        );
5244        assert!(
5245            graph
5246                .packages
5247                .iter()
5248                .all(|package| package.package_root != external_canonical),
5249            "non-local registry packages must not be pulled into the sync closure"
5250        );
5251    }
5252
5253    #[cfg(unix)]
5254    #[test]
5255    fn manifest_fallback_ignores_pure_dev_path_edges() {
5256        let fixture = TopologyFixture::new("manifest-fallback-dev-filter");
5257        let scenario_root = fixture.canonical_root.join("manifest_fallback_dev_filter");
5258        let app_root = scenario_root.join("app");
5259        let dep_root = scenario_root.join("dep");
5260        let dev_dep_root = scenario_root.join("dev_dep");
5261
5262        write_lib_crate(&dep_root, "runtime_dep", &[]);
5263        write_lib_crate(&dev_dep_root, "dev_only_dep", &[]);
5264
5265        std::fs::create_dir_all(app_root.join("src")).expect("create app src");
5266        std::fs::write(
5267            app_root.join("Cargo.toml"),
5268            r#"[package]
5269name = "manifest_dev_filter_app"
5270version = "0.1.0"
5271edition = "2024"
5272
5273[dependencies]
5274runtime_dep = { path = "../dep" }
5275
5276[dev-dependencies]
5277dev_only_dep = { path = "../dev_dep" }
5278"#,
5279        )
5280        .expect("write app manifest");
5281        std::fs::write(app_root.join("src/main.rs"), "fn main() {}\n").expect("write app main");
5282
5283        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
5284            &app_root,
5285            &fixture.policy(),
5286            |_| {
5287                Err(CargoPathDependencyError::new(
5288                    CargoPathDependencyErrorKind::MetadataInvocationFailure,
5289                    "force manifest fallback",
5290                ))
5291            },
5292        )
5293        .expect("manifest fallback should ignore pure dev-only path edges");
5294
5295        let app_canonical = app_root.canonicalize().expect("canonical app");
5296        let dep_canonical = dep_root.canonicalize().expect("canonical dep");
5297        let dev_dep_canonical = dev_dep_root.canonicalize().expect("canonical dev dep");
5298
5299        assert_eq!(graph.root_packages, vec![app_canonical.clone()]);
5300        assert_eq!(graph.edges.len(), 1);
5301        assert_eq!(graph.edges[0].dependency_name, "runtime_dep");
5302        assert_eq!(graph.edges[0].from, app_canonical);
5303        assert_eq!(graph.edges[0].to, dep_canonical);
5304        assert!(
5305            graph
5306                .packages
5307                .iter()
5308                .all(|pkg| pkg.package_root != dev_dep_canonical),
5309            "manifest fallback must not pull pure dev-only path deps into runtime closure"
5310        );
5311    }
5312
5313    #[cfg(unix)]
5314    #[test]
5315    fn manifest_fallback_resolves_workspace_shared_path_dependencies() {
5316        let fixture = TopologyFixture::new("manifest-workspace-shared");
5317        let scenario_root = fixture.canonical_root.join("manifest_workspace_shared");
5318        let workspace_root = scenario_root.join("workspace");
5319        let app_root = workspace_root.join("crates/app");
5320        let shared_root = scenario_root.join("shared/shared_dep");
5321
5322        write_lib_crate(&shared_root, "shared_dep", &[]);
5323        std::fs::create_dir_all(app_root.join("src")).expect("create app src");
5324        std::fs::write(
5325            app_root.join("Cargo.toml"),
5326            r#"[package]
5327name = "workspace_shared_app"
5328version = "0.1.0"
5329edition = "2024"
5330
5331[dependencies]
5332shared_dep = { workspace = true }
5333"#,
5334        )
5335        .expect("write app manifest");
5336        std::fs::write(app_root.join("src/main.rs"), "fn main() {}\n").expect("write app main");
5337        std::fs::create_dir_all(&workspace_root).expect("create workspace root");
5338        std::fs::write(
5339            workspace_root.join("Cargo.toml"),
5340            r#"[workspace]
5341members = ["crates/app"]
5342resolver = "3"
5343
5344[workspace.dependencies]
5345shared_dep = { path = "../shared/shared_dep" }
5346"#,
5347        )
5348        .expect("write workspace manifest");
5349
5350        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
5351            &workspace_root,
5352            &fixture.policy(),
5353            |_| {
5354                Err(CargoPathDependencyError::new(
5355                    CargoPathDependencyErrorKind::MetadataInvocationFailure,
5356                    "force manifest fallback",
5357                ))
5358            },
5359        )
5360        .expect("manifest fallback should resolve workspace-shared path dependencies");
5361
5362        let app_canonical = app_root.canonicalize().expect("canonical app");
5363        let shared_canonical = shared_root.canonicalize().expect("canonical shared dep");
5364        assert_eq!(graph.root_packages, vec![app_canonical.clone()]);
5365        assert_eq!(
5366            graph.edges,
5367            vec![CargoPathDependencyEdge {
5368                from: app_canonical,
5369                to: shared_canonical,
5370                dependency_name: "shared_dep".to_string(),
5371            }]
5372        );
5373    }
5374
5375    #[cfg(unix)]
5376    #[test]
5377    fn manifest_fallback_resolves_foreign_workspace_inherited_path_dependencies() {
5378        let fixture = TopologyFixture::new("manifest-foreign-workspace-shared");
5379        let scenario_root = fixture
5380            .canonical_root
5381            .join("manifest_foreign_workspace_shared");
5382        let app_root = scenario_root.join("app");
5383        let foreign_workspace = scenario_root.join("foreign");
5384        let api_root = foreign_workspace.join("crates/api");
5385        let core_root = foreign_workspace.join("crates/core");
5386
5387        write_lib_crate(&core_root, "foreign_core", &[]);
5388        std::fs::create_dir_all(api_root.join("src")).expect("create foreign api src");
5389        std::fs::write(
5390            api_root.join("Cargo.toml"),
5391            r#"[package]
5392name = "foreign_api"
5393version = "0.1.0"
5394edition = "2024"
5395
5396[dependencies]
5397foreign_core = { workspace = true }
5398asupersync = { workspace = true }
5399"#,
5400        )
5401        .expect("write foreign api manifest");
5402        std::fs::write(api_root.join("src/lib.rs"), "pub fn api() {}\n")
5403            .expect("write foreign api lib");
5404        std::fs::write(
5405            foreign_workspace.join("Cargo.toml"),
5406            r#"[workspace]
5407members = ["crates/api", "crates/core"]
5408resolver = "3"
5409
5410[workspace.dependencies]
5411foreign_core = { path = "crates/core" }
5412asupersync = "0.3.9"
5413"#,
5414        )
5415        .expect("write foreign workspace manifest");
5416        write_bin_crate(
5417            &app_root,
5418            "foreign_workspace_app",
5419            &[("foreign_api", "../foreign/crates/api")],
5420        );
5421
5422        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
5423            &app_root,
5424            &fixture.policy(),
5425            |_| {
5426                Err(CargoPathDependencyError::new(
5427                    CargoPathDependencyErrorKind::MetadataInvocationFailure,
5428                    "force manifest fallback",
5429                ))
5430            },
5431        )
5432        .expect("manifest fallback should traverse the foreign workspace dependency");
5433
5434        let app = app_root.canonicalize().expect("canonical app");
5435        let api = api_root.canonicalize().expect("canonical api");
5436        let core = core_root.canonicalize().expect("canonical core");
5437        assert_eq!(graph.root_packages, vec![app.clone()]);
5438        assert_eq!(
5439            graph.edges,
5440            vec![
5441                CargoPathDependencyEdge {
5442                    from: app,
5443                    to: api.clone(),
5444                    dependency_name: "foreign_api".to_string(),
5445                },
5446                CargoPathDependencyEdge {
5447                    from: api,
5448                    to: core,
5449                    dependency_name: "foreign_core".to_string(),
5450                },
5451            ]
5452        );
5453        let materialization =
5454            resolve_materialization_from_manifests(&app_root.join("Cargo.toml"), &fixture.policy())
5455                .expect("materialization fallback should retain the same foreign path closure");
5456        assert_eq!(materialization.edges, graph.edges);
5457
5458        std::fs::write(
5459            foreign_workspace.join("Cargo.toml"),
5460            "[workspace]\nmembers = [\"crates/api\", \"crates/core\"]\nresolver = \"3\"\n\n[workspace.dependencies]\nasupersync = \"0.3.9\"\n",
5461        )
5462        .expect("remove inherited dependency declaration");
5463        let refusal = resolve_cargo_path_dependency_graph_with_policy_and_provider(
5464            &app_root,
5465            &fixture.policy(),
5466            |_| {
5467                Err(CargoPathDependencyError::new(
5468                    CargoPathDependencyErrorKind::MetadataInvocationFailure,
5469                    "force manifest fallback",
5470                ))
5471            },
5472        )
5473        .expect_err("missing foreign workspace dependency must fail closed");
5474        assert_eq!(
5475            refusal.kind(),
5476            &CargoPathDependencyErrorKind::ManifestParseFailure
5477        );
5478        assert!(
5479            refusal
5480                .detail()
5481                .contains("workspace-inherited dependency 'foreign_core' is not defined")
5482        );
5483    }
5484
5485    #[cfg(unix)]
5486    #[test]
5487    fn manifest_fallback_resolves_patch_path_dependencies() {
5488        let fixture = TopologyFixture::new("manifest-patch-shared");
5489        let scenario_root = fixture.canonical_root.join("manifest_patch_shared");
5490        let workspace_root = scenario_root.join("workspace");
5491        let app_root = workspace_root.join("app");
5492        let patched_root = scenario_root.join("patched/patched_dep");
5493
5494        write_lib_crate(&patched_root, "patched_dep", &[]);
5495        std::fs::create_dir_all(app_root.join("src")).expect("create app src");
5496        std::fs::write(
5497            app_root.join("Cargo.toml"),
5498            r#"[package]
5499name = "patched_app"
5500version = "0.1.0"
5501edition = "2024"
5502
5503[dependencies]
5504patched_dep = "0.1"
5505"#,
5506        )
5507        .expect("write app manifest");
5508        std::fs::write(app_root.join("src/main.rs"), "fn main() {}\n").expect("write app main");
5509        std::fs::create_dir_all(&workspace_root).expect("create workspace root");
5510        std::fs::write(
5511            workspace_root.join("Cargo.toml"),
5512            r#"[workspace]
5513members = ["app"]
5514resolver = "3"
5515
5516[patch.crates-io]
5517patched_dep = { path = "../patched/patched_dep" }
5518"#,
5519        )
5520        .expect("write workspace manifest");
5521
5522        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
5523            &workspace_root,
5524            &fixture.policy(),
5525            |_| {
5526                Err(CargoPathDependencyError::new(
5527                    CargoPathDependencyErrorKind::MetadataInvocationFailure,
5528                    "force manifest fallback",
5529                ))
5530            },
5531        )
5532        .expect("manifest fallback should resolve patch path dependencies");
5533
5534        let app_canonical = app_root.canonicalize().expect("canonical app");
5535        let patched_canonical = patched_root.canonicalize().expect("canonical patched dep");
5536        assert_eq!(graph.root_packages, vec![app_canonical.clone()]);
5537        assert_eq!(
5538            graph.edges,
5539            vec![CargoPathDependencyEdge {
5540                from: app_canonical,
5541                to: patched_canonical,
5542                dependency_name: "patched_dep".to_string(),
5543            }]
5544        );
5545    }
5546
5547    #[cfg(unix)]
5548    #[test]
5549    fn manifest_fallback_accepts_absolute_deps_that_normalize_under_policy() {
5550        let id = FIXTURE_COUNTER.fetch_add(1, Ordering::SeqCst);
5551        let root = std::env::temp_dir().join(format!(
5552            "rch-cargo-path-deps-mixed-roots-{}-{}",
5553            std::process::id(),
5554            id
5555        ));
5556        let user_root = root.join("users/jemanuel");
5557        let user_projects = user_root.join("projects");
5558        let user_dp = user_root.join("dp");
5559        let dp_alias = root.join("dp");
5560        let data_projects = root.join("data/projects");
5561        let app_root = user_projects.join("app");
5562        let data_dep_link = data_projects.join("asupersync");
5563        let projects_dep_link = user_projects.join("asupersync");
5564        let asupersync_root = user_dp.join("asupersync");
5565        let frankentui_root = user_dp.join("frankentui");
5566
5567        fs::create_dir_all(&app_root).expect("create app root");
5568        fs::create_dir_all(&data_projects).expect("create data projects root");
5569        write_lib_crate(&asupersync_root, "asupersync", &[]);
5570        write_lib_crate(&frankentui_root, "frankentui", &[]);
5571        symlink(&user_dp, &dp_alias).expect("create /dp-style alias");
5572        symlink(&asupersync_root, &projects_dep_link).expect("create canonical project symlink");
5573        symlink(&projects_dep_link, &data_dep_link).expect("create /data/projects symlink entry");
5574
5575        let manifest = format!(
5576            "[package]\nname = \"mixed_roots_app\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\nasupersync = {{ path = \"{}\" }}\nfrankentui = {{ path = \"{}\" }}\n",
5577            data_dep_link.display(),
5578            dp_alias.join("frankentui").display(),
5579        );
5580        fs::create_dir_all(app_root.join("src")).expect("create app src");
5581        fs::write(app_root.join("Cargo.toml"), manifest).expect("write app manifest");
5582        fs::write(app_root.join("src/lib.rs"), "pub fn app() {}\n").expect("write app lib");
5583
5584        let policy = PathTopologyPolicy::new(user_root.clone(), dp_alias);
5585        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
5586            &app_root,
5587            &policy,
5588            |_| {
5589                Err(CargoPathDependencyError::new(
5590                    CargoPathDependencyErrorKind::MetadataInvocationFailure,
5591                    "force manifest fallback",
5592                ))
5593            },
5594        )
5595        .expect("mixed absolute dependency roots should normalize under policy");
5596
5597        let dependency_roots = graph
5598            .edges
5599            .iter()
5600            .map(|edge| (edge.dependency_name.as_str(), edge.to.clone()))
5601            .collect::<BTreeMap<_, _>>();
5602        assert_eq!(
5603            dependency_roots.get("asupersync"),
5604            Some(
5605                &asupersync_root
5606                    .canonicalize()
5607                    .expect("canonical asupersync")
5608            )
5609        );
5610        assert_eq!(
5611            dependency_roots.get("frankentui"),
5612            Some(
5613                &frankentui_root
5614                    .canonicalize()
5615                    .expect("canonical frankentui")
5616            )
5617        );
5618
5619        let _ = fs::remove_dir_all(root);
5620    }
5621
5622    #[cfg(unix)]
5623    #[test]
5624    fn standalone_crate_no_dependencies() {
5625        let fixture = TopologyFixture::new("standalone");
5626        let crate_root = fixture.canonical_root.join("standalone_crate");
5627
5628        write_bin_crate(&crate_root, "standalone", &[]);
5629
5630        let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
5631            &crate_root,
5632            &fixture.policy(),
5633            |_| Ok("{not-json".to_string()), // force fallback
5634        )
5635        .expect("standalone crate should resolve");
5636
5637        assert_eq!(graph.packages.len(), 1);
5638        assert_eq!(graph.packages[0].package_name, "standalone");
5639        assert!(graph.edges.is_empty());
5640    }
5641
5642    // ── PartialGraph internal behavior ──
5643
5644    #[test]
5645    fn partial_graph_deduplicates_edges() {
5646        let mut partial = PartialGraph::default();
5647        partial.add_edge(PathBuf::from("/a"), PathBuf::from("/b"), "b".to_string());
5648        partial.add_edge(PathBuf::from("/a"), PathBuf::from("/b"), "b".to_string());
5649
5650        let edges = partial.adjacency.get(Path::new("/a")).unwrap();
5651        assert_eq!(
5652            edges.len(),
5653            1,
5654            "duplicate edge should be deduplicated by BTreeSet"
5655        );
5656    }
5657
5658    #[test]
5659    fn partial_graph_add_package_updates_name_from_default() {
5660        let mut partial = PartialGraph::default();
5661        let root = PathBuf::from("/project/my_crate");
5662
5663        // First add with default name
5664        partial.add_package(
5665            root.clone(),
5666            root.join("Cargo.toml"),
5667            default_package_name(&root),
5668            false,
5669        );
5670        assert_eq!(
5671            partial.packages.get(&root).unwrap().package_name,
5672            "my_crate"
5673        );
5674
5675        // Second add with real name should update
5676        partial.add_package(
5677            root.clone(),
5678            root.join("Cargo.toml"),
5679            "real_name".to_string(),
5680            false,
5681        );
5682        assert_eq!(
5683            partial.packages.get(&root).unwrap().package_name,
5684            "real_name"
5685        );
5686    }
5687
5688    #[test]
5689    fn partial_graph_add_package_or_promotes_workspace_member() {
5690        let mut partial = PartialGraph::default();
5691        let root = PathBuf::from("/project");
5692
5693        partial.add_package(
5694            root.clone(),
5695            root.join("Cargo.toml"),
5696            "pkg".to_string(),
5697            false,
5698        );
5699        assert!(!partial.packages.get(&root).unwrap().workspace_member);
5700
5701        // Adding again with workspace_member=true should promote
5702        partial.add_package(
5703            root.clone(),
5704            root.join("Cargo.toml"),
5705            "pkg".to_string(),
5706            true,
5707        );
5708        assert!(partial.packages.get(&root).unwrap().workspace_member);
5709    }
5710
5711    /// Regression: `invoke_cargo_metadata` previously deadlocked when the JSON
5712    /// output exceeded the OS pipe capacity (~64 KB on Linux). The poll loop
5713    /// never read stdout, so the child blocked on `write()` and we always hit
5714    /// the 30s timeout. We now drain stdout/stderr concurrently in background
5715    /// threads. This test forces a large output by pulling in dozens of
5716    /// crates.io dependencies and asserting the call returns successfully
5717    /// well under the timeout.
5718    #[cfg(unix)]
5719    #[test]
5720    fn invoke_cargo_metadata_handles_output_larger_than_pipe_buffer() {
5721        let fixture = TopologyFixture::new("metadata-large");
5722        let project_root = fixture.canonical_root.join("metadata_pipe");
5723        fs::create_dir_all(project_root.join("src")).expect("create src");
5724
5725        // 60+ small dependencies inflate the cargo metadata JSON well past
5726        // the 64KB pipe buffer once each is resolved with version, source,
5727        // checksum, and feature lists.
5728        let mut deps = String::new();
5729        for name in [
5730            "serde",
5731            "serde_json",
5732            "anyhow",
5733            "thiserror",
5734            "tokio",
5735            "futures",
5736            "log",
5737            "tracing",
5738            "regex",
5739            "chrono",
5740            "uuid",
5741            "rand",
5742            "base64",
5743            "hex",
5744            "url",
5745            "bytes",
5746            "clap",
5747            "rusqlite",
5748            "blake3",
5749            "sha2",
5750            "reqwest",
5751            "tempfile",
5752            "directories",
5753            "dirs",
5754            "shellexpand",
5755            "shell-escape",
5756            "which",
5757            "openssh",
5758            "schemars",
5759            "indexmap",
5760            "lazy_static",
5761            "once_cell",
5762            "parking_lot",
5763            "toml",
5764            "shell-words",
5765            "globset",
5766            "walkdir",
5767            "ignore",
5768            "crossbeam-channel",
5769            "rayon",
5770            "memchr",
5771            "smallvec",
5772            "ahash",
5773            "fnv",
5774            "itertools",
5775            "num_cpus",
5776            "humantime",
5777            "humansize",
5778            "ratatui",
5779            "crossterm",
5780        ] {
5781            deps.push_str(&format!("{name} = \"*\"\n"));
5782        }
5783
5784        fs::write(
5785            project_root.join("Cargo.toml"),
5786            format!(
5787                "[package]\nname = \"metadata_pipe\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[workspace]\nresolver = \"2\"\n\n[dependencies]\n{deps}",
5788            ),
5789        )
5790        .expect("write manifest");
5791        fs::write(project_root.join("src/lib.rs"), "").expect("write lib.rs");
5792
5793        let manifest_path = project_root.join("Cargo.toml");
5794        let start = std::time::Instant::now();
5795        match invoke_cargo_metadata(&manifest_path) {
5796            Ok(json) => {
5797                let elapsed = start.elapsed();
5798                // Without the fix the function would either time out at 30s
5799                // or (depending on dependency cache) produce a truncated
5800                // result; both paths fail this assertion.
5801                assert!(
5802                    elapsed < CARGO_METADATA_TIMEOUT,
5803                    "metadata took {elapsed:?}, near the {CARGO_METADATA_TIMEOUT:?} timeout"
5804                );
5805                assert!(
5806                    json.len() > 64 * 1024,
5807                    "test fixture too small to exercise the pipe-buffer path: {} bytes",
5808                    json.len()
5809                );
5810                assert!(
5811                    json.starts_with('{'),
5812                    "metadata stdout should be JSON; got: {:.120}",
5813                    json
5814                );
5815            }
5816            Err(e) => {
5817                // Skip on machines without network access to crates.io or
5818                // without registry caches — we only care about the
5819                // *deadlock* regression, not about resolver outcomes.
5820                let detail = e.detail().to_lowercase();
5821                let offline = detail.contains("network")
5822                    || detail.contains("dns")
5823                    || detail.contains("registry")
5824                    || detail.contains("connection")
5825                    || detail.contains("not found")
5826                    || detail.contains("offline")
5827                    || detail.contains("could not")
5828                    || detail.contains("failed to fetch")
5829                    || detail.contains("no such file");
5830                assert!(
5831                    offline,
5832                    "invoke_cargo_metadata failed for non-network reason: {e}"
5833                );
5834            }
5835        }
5836    }
5837}