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