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