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