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