1use serde::{Deserialize, Serialize};
12use std::collections::{BTreeMap, BTreeSet};
13use std::fmt;
14use std::path::{Path, PathBuf};
15use std::process::Command;
16
17use crate::{PathNormalizationErrorKind, PathTopologyPolicy, normalize_project_path_with_policy};
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct CargoPathDependencyGraph {
22 pub entry_manifest_path: PathBuf,
24 pub workspace_root: Option<PathBuf>,
26 pub root_packages: Vec<PathBuf>,
28 pub packages: Vec<CargoPathDependencyPackage>,
30 pub edges: Vec<CargoPathDependencyEdge>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct CargoPathDependencyPackage {
37 pub package_root: PathBuf,
39 pub manifest_path: PathBuf,
41 pub package_name: String,
43 pub workspace_member: bool,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
49pub struct CargoPathDependencyEdge {
50 pub from: PathBuf,
52 pub to: PathBuf,
54 pub dependency_name: String,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum CargoPathDependencyErrorKind {
61 ManifestParseFailure,
62 MissingPathDependency,
63 CyclicDependency,
64 PathPolicyViolation,
65 MetadataParseFailure,
66 MetadataInvocationFailure,
67}
68
69impl fmt::Display for CargoPathDependencyErrorKind {
70 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 match self {
72 Self::ManifestParseFailure => write!(f, "manifest parse failure"),
73 Self::MissingPathDependency => write!(f, "missing path dependency"),
74 Self::CyclicDependency => write!(f, "cyclic path dependency"),
75 Self::PathPolicyViolation => write!(f, "path policy violation"),
76 Self::MetadataParseFailure => write!(f, "metadata parse failure"),
77 Self::MetadataInvocationFailure => write!(f, "metadata invocation failure"),
78 }
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct CargoPathDependencyError {
85 kind: CargoPathDependencyErrorKind,
86 detail: String,
87 manifest_path: Option<Box<PathBuf>>,
88 dependency_name: Option<Box<str>>,
89 dependency_path: Option<Box<PathBuf>>,
90 cycle: Vec<PathBuf>,
91 diagnostics: Vec<String>,
92}
93
94impl CargoPathDependencyError {
95 pub(crate) fn new(kind: CargoPathDependencyErrorKind, detail: impl Into<String>) -> Self {
96 Self {
97 kind,
98 detail: detail.into(),
99 manifest_path: None,
100 dependency_name: None,
101 dependency_path: None,
102 cycle: Vec::new(),
103 diagnostics: Vec::new(),
104 }
105 }
106
107 pub(crate) fn with_manifest_path(mut self, manifest_path: impl Into<PathBuf>) -> Self {
108 self.manifest_path = Some(Box::new(manifest_path.into()));
109 self
110 }
111
112 pub(crate) fn with_dependency_name(mut self, dependency_name: impl Into<String>) -> Self {
113 self.dependency_name = Some(dependency_name.into().into_boxed_str());
114 self
115 }
116
117 pub(crate) fn with_dependency_path(mut self, dependency_path: impl Into<PathBuf>) -> Self {
118 self.dependency_path = Some(Box::new(dependency_path.into()));
119 self
120 }
121
122 fn with_cycle(mut self, cycle: Vec<PathBuf>) -> Self {
123 self.cycle = cycle;
124 self
125 }
126
127 fn with_diagnostic(mut self, diagnostic: impl Into<String>) -> Self {
128 self.diagnostics.push(diagnostic.into());
129 self
130 }
131
132 fn with_diagnostics<I>(mut self, diagnostics: I) -> Self
133 where
134 I: IntoIterator,
135 I::Item: Into<String>,
136 {
137 self.diagnostics
138 .extend(diagnostics.into_iter().map(Into::into));
139 self
140 }
141
142 fn push_diagnostic(&mut self, diagnostic: impl Into<String>) {
143 self.diagnostics.push(diagnostic.into());
144 }
145
146 pub fn kind(&self) -> &CargoPathDependencyErrorKind {
148 &self.kind
149 }
150
151 pub fn detail(&self) -> &str {
153 &self.detail
154 }
155
156 pub fn manifest_path(&self) -> Option<&Path> {
158 self.manifest_path.as_deref().map(PathBuf::as_path)
159 }
160
161 pub fn dependency_name(&self) -> Option<&str> {
163 self.dependency_name.as_deref()
164 }
165
166 pub fn dependency_path(&self) -> Option<&Path> {
168 self.dependency_path.as_deref().map(PathBuf::as_path)
169 }
170
171 pub fn cycle(&self) -> &[PathBuf] {
173 &self.cycle
174 }
175
176 pub fn diagnostics(&self) -> &[String] {
178 &self.diagnostics
179 }
180}
181
182impl fmt::Display for CargoPathDependencyError {
183 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184 write!(f, "{}: {}", self.kind, self.detail)?;
185 if let Some(manifest_path) = &self.manifest_path {
186 write!(f, " (manifest: {})", manifest_path.display())?;
187 }
188 if let Some(dependency_name) = &self.dependency_name {
189 write!(f, " (dependency: {dependency_name})")?;
190 }
191 if let Some(dependency_path) = &self.dependency_path {
192 write!(f, " (path: {})", dependency_path.display())?;
193 }
194 Ok(())
195 }
196}
197
198impl std::error::Error for CargoPathDependencyError {}
199
200pub fn resolve_cargo_path_dependency_graph(
202 entrypoint: &Path,
203) -> Result<CargoPathDependencyGraph, CargoPathDependencyError> {
204 resolve_cargo_path_dependency_graph_with_policy(entrypoint, &PathTopologyPolicy::default())
205}
206
207pub fn resolve_cargo_path_dependency_graph_with_policy(
209 entrypoint: &Path,
210 policy: &PathTopologyPolicy,
211) -> Result<CargoPathDependencyGraph, CargoPathDependencyError> {
212 resolve_cargo_path_dependency_graph_with_policy_and_provider(
213 entrypoint,
214 policy,
215 invoke_cargo_metadata,
216 )
217}
218
219fn resolve_cargo_path_dependency_graph_with_policy_and_provider<F>(
220 entrypoint: &Path,
221 policy: &PathTopologyPolicy,
222 metadata_provider: F,
223) -> Result<CargoPathDependencyGraph, CargoPathDependencyError>
224where
225 F: Fn(&Path) -> Result<String, CargoPathDependencyError>,
226{
227 let entry_manifest = resolve_entry_manifest(entrypoint, policy)?;
228
229 match resolve_from_metadata(&entry_manifest, policy, &metadata_provider) {
230 Ok(graph) => Ok(graph),
231 Err(metadata_error) => match resolve_from_manifest_fallback(&entry_manifest, policy) {
232 Ok(graph) => Ok(graph),
233 Err(mut error) => {
234 error.push_diagnostic(format!("metadata phase failure: {metadata_error}"));
235 if !metadata_error.diagnostics().is_empty() {
236 error.push_diagnostic("metadata diagnostics follow".to_string());
237 error
238 .diagnostics
239 .extend(metadata_error.diagnostics().iter().cloned());
240 }
241 Err(error)
242 }
243 },
244 }
245}
246
247fn resolve_entry_manifest(
248 entrypoint: &Path,
249 policy: &PathTopologyPolicy,
250) -> Result<PathBuf, CargoPathDependencyError> {
251 let maybe_manifest = entrypoint
252 .file_name()
253 .is_some_and(|name| name == "Cargo.toml");
254 let root_candidate = if maybe_manifest {
255 entrypoint.parent().ok_or_else(|| {
256 CargoPathDependencyError::new(
257 CargoPathDependencyErrorKind::ManifestParseFailure,
258 format!("invalid manifest path: {}", entrypoint.display()),
259 )
260 })?
261 } else {
262 entrypoint
263 };
264
265 let normalized_root = normalize_path_for_policy(
266 root_candidate,
267 policy,
268 None,
269 None,
270 "resolve entrypoint root",
271 )?;
272 let manifest_path = normalized_root.join("Cargo.toml");
273 if !manifest_path.is_file() {
274 return Err(CargoPathDependencyError::new(
275 CargoPathDependencyErrorKind::ManifestParseFailure,
276 format!("manifest does not exist: {}", manifest_path.display()),
277 )
278 .with_manifest_path(manifest_path));
279 }
280
281 Ok(manifest_path)
282}
283
284const CARGO_METADATA_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
286
287fn invoke_cargo_metadata(manifest_path: &Path) -> Result<String, CargoPathDependencyError> {
288 use std::io::Read;
289
290 let mut child = Command::new("cargo")
291 .arg("metadata")
292 .arg("--format-version")
293 .arg("1")
294 .arg("--manifest-path")
295 .arg(manifest_path)
296 .stdout(std::process::Stdio::piped())
297 .stderr(std::process::Stdio::piped())
298 .spawn()
299 .map_err(|error| {
300 CargoPathDependencyError::new(
301 CargoPathDependencyErrorKind::MetadataInvocationFailure,
302 format!("failed to spawn cargo metadata: {error}"),
303 )
304 .with_manifest_path(manifest_path)
305 })?;
306
307 let mut stdout_pipe = child
318 .stdout
319 .take()
320 .expect("child spawned with piped stdout");
321 let mut stderr_pipe = child
322 .stderr
323 .take()
324 .expect("child spawned with piped stderr");
325
326 let stdout_thread = std::thread::spawn(move || -> std::io::Result<Vec<u8>> {
327 let mut buf = Vec::new();
328 stdout_pipe.read_to_end(&mut buf)?;
329 Ok(buf)
330 });
331 let stderr_thread = std::thread::spawn(move || -> std::io::Result<Vec<u8>> {
332 let mut buf = Vec::new();
333 stderr_pipe.read_to_end(&mut buf)?;
334 Ok(buf)
335 });
336
337 let deadline = std::time::Instant::now() + CARGO_METADATA_TIMEOUT;
340 loop {
341 match child.try_wait() {
342 Ok(Some(_)) => break,
343 Ok(None) => {
344 if std::time::Instant::now() >= deadline {
345 let kill_err = child.kill().err();
346 let reap_deadline =
347 std::time::Instant::now() + std::time::Duration::from_secs(2);
348 let reaped = loop {
349 match child.try_wait() {
350 Ok(Some(_)) => break true,
351 Ok(None) => {
352 if std::time::Instant::now() >= reap_deadline {
353 break false;
354 }
355 std::thread::sleep(std::time::Duration::from_millis(20));
356 }
357 Err(_) => break false,
358 }
359 };
360 let _ = stdout_thread.join();
364 let _ = stderr_thread.join();
365
366 let mut err = CargoPathDependencyError::new(
367 CargoPathDependencyErrorKind::MetadataInvocationFailure,
368 format!(
369 "cargo metadata timed out after {}s for {}",
370 CARGO_METADATA_TIMEOUT.as_secs(),
371 manifest_path.display()
372 ),
373 )
374 .with_manifest_path(manifest_path)
375 .with_diagnostic(format!("timeout_secs={}", CARGO_METADATA_TIMEOUT.as_secs()));
376 if let Some(ke) = kill_err {
377 err = err.with_diagnostic(format!("kill_failed={ke}"));
378 }
379 if !reaped {
380 err = err.with_diagnostic("reap_failed=true".to_string());
381 }
382 return Err(err);
383 }
384 std::thread::sleep(std::time::Duration::from_millis(50));
385 }
386 Err(error) => {
387 let _ = child.kill();
388 let _ = child.wait();
389 let _ = stdout_thread.join();
390 let _ = stderr_thread.join();
391 return Err(CargoPathDependencyError::new(
392 CargoPathDependencyErrorKind::MetadataInvocationFailure,
393 format!("failed to poll cargo metadata: {error}"),
394 )
395 .with_manifest_path(manifest_path));
396 }
397 }
398 }
399
400 let status = child.wait().map_err(|error| {
401 CargoPathDependencyError::new(
402 CargoPathDependencyErrorKind::MetadataInvocationFailure,
403 format!("failed to wait on cargo metadata: {error}"),
404 )
405 .with_manifest_path(manifest_path)
406 })?;
407
408 let stdout_bytes = stdout_thread
409 .join()
410 .map_err(|_| {
411 CargoPathDependencyError::new(
412 CargoPathDependencyErrorKind::MetadataInvocationFailure,
413 "stdout drain thread panicked".to_string(),
414 )
415 .with_manifest_path(manifest_path)
416 })?
417 .map_err(|error| {
418 CargoPathDependencyError::new(
419 CargoPathDependencyErrorKind::MetadataInvocationFailure,
420 format!("failed to read cargo metadata stdout: {error}"),
421 )
422 .with_manifest_path(manifest_path)
423 })?;
424 let stderr_bytes = stderr_thread
425 .join()
426 .map_err(|_| {
427 CargoPathDependencyError::new(
428 CargoPathDependencyErrorKind::MetadataInvocationFailure,
429 "stderr drain thread panicked".to_string(),
430 )
431 .with_manifest_path(manifest_path)
432 })?
433 .map_err(|error| {
434 CargoPathDependencyError::new(
435 CargoPathDependencyErrorKind::MetadataInvocationFailure,
436 format!("failed to read cargo metadata stderr: {error}"),
437 )
438 .with_manifest_path(manifest_path)
439 })?;
440
441 if !status.success() {
442 let stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
443 let detail = if stderr.trim().is_empty() {
444 format!("cargo metadata exited with status {status}")
445 } else {
446 stderr.trim().to_string()
447 };
448 return Err(CargoPathDependencyError::new(
449 CargoPathDependencyErrorKind::MetadataInvocationFailure,
450 detail,
451 )
452 .with_manifest_path(manifest_path));
453 }
454
455 String::from_utf8(stdout_bytes).map_err(|error| {
456 CargoPathDependencyError::new(
457 CargoPathDependencyErrorKind::MetadataParseFailure,
458 format!("metadata stdout is not valid UTF-8: {error}"),
459 )
460 .with_manifest_path(manifest_path)
461 })
462}
463
464#[derive(Debug, Default)]
465struct PartialGraph {
466 workspace_root: Option<PathBuf>,
467 roots: BTreeSet<PathBuf>,
468 packages: BTreeMap<PathBuf, PackageRecord>,
469 adjacency: BTreeMap<PathBuf, BTreeSet<EdgeTail>>,
470}
471
472impl PartialGraph {
473 fn add_root(&mut self, root: PathBuf) {
474 self.roots.insert(root);
475 }
476
477 fn add_package(
478 &mut self,
479 package_root: PathBuf,
480 manifest_path: PathBuf,
481 package_name: String,
482 workspace_member: bool,
483 ) {
484 self.packages
485 .entry(package_root.clone())
486 .and_modify(|existing| {
487 if existing.package_name == default_package_name(&package_root)
488 && package_name != existing.package_name
489 {
490 existing.package_name = package_name.clone();
491 }
492 existing.workspace_member |= workspace_member;
493 existing.manifest_path = manifest_path.clone();
494 })
495 .or_insert(PackageRecord {
496 manifest_path,
497 package_name,
498 workspace_member,
499 });
500 self.adjacency.entry(package_root).or_default();
501 }
502
503 fn add_edge(&mut self, from: PathBuf, to: PathBuf, dependency_name: String) {
504 self.adjacency.entry(from).or_default().insert(EdgeTail {
505 to,
506 dependency_name,
507 });
508 }
509}
510
511#[derive(Debug, Clone)]
512struct PackageRecord {
513 manifest_path: PathBuf,
514 package_name: String,
515 workspace_member: bool,
516}
517
518#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
519struct EdgeTail {
520 to: PathBuf,
521 dependency_name: String,
522}
523
524#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
525struct EdgeRecord {
526 from: PathBuf,
527 to: PathBuf,
528 dependency_name: String,
529}
530
531#[derive(Debug, Clone, Copy, PartialEq, Eq)]
532enum VisitState {
533 Visiting,
534 Visited,
535}
536
537fn finalize_graph(
538 entry_manifest_path: PathBuf,
539 partial: PartialGraph,
540) -> Result<CargoPathDependencyGraph, CargoPathDependencyError> {
541 let mut states: BTreeMap<PathBuf, VisitState> = BTreeMap::new();
542 let mut stack: Vec<PathBuf> = Vec::new();
543 let mut reachable_nodes: BTreeSet<PathBuf> = BTreeSet::new();
544 let mut reachable_edges: BTreeSet<EdgeRecord> = BTreeSet::new();
545
546 for root in &partial.roots {
547 traverse_for_reachable(
548 root,
549 &partial.adjacency,
550 &mut states,
551 &mut stack,
552 &mut reachable_nodes,
553 &mut reachable_edges,
554 )?;
555 }
556
557 let packages = reachable_nodes
558 .iter()
559 .map(|root| {
560 if let Some(package) = partial.packages.get(root) {
561 CargoPathDependencyPackage {
562 package_root: root.clone(),
563 manifest_path: package.manifest_path.clone(),
564 package_name: package.package_name.clone(),
565 workspace_member: package.workspace_member,
566 }
567 } else {
568 CargoPathDependencyPackage {
569 package_root: root.clone(),
570 manifest_path: root.join("Cargo.toml"),
571 package_name: default_package_name(root),
572 workspace_member: partial.roots.contains(root),
573 }
574 }
575 })
576 .collect::<Vec<_>>();
577
578 let edges = reachable_edges
579 .into_iter()
580 .map(|edge| CargoPathDependencyEdge {
581 from: edge.from,
582 to: edge.to,
583 dependency_name: edge.dependency_name,
584 })
585 .collect::<Vec<_>>();
586
587 Ok(CargoPathDependencyGraph {
588 entry_manifest_path,
589 workspace_root: partial.workspace_root,
590 root_packages: partial.roots.into_iter().collect(),
591 packages,
592 edges,
593 })
594}
595
596fn traverse_for_reachable(
597 node: &Path,
598 adjacency: &BTreeMap<PathBuf, BTreeSet<EdgeTail>>,
599 states: &mut BTreeMap<PathBuf, VisitState>,
600 stack: &mut Vec<PathBuf>,
601 reachable_nodes: &mut BTreeSet<PathBuf>,
602 reachable_edges: &mut BTreeSet<EdgeRecord>,
603) -> Result<(), CargoPathDependencyError> {
604 match states.get(node).copied() {
605 Some(VisitState::Visited) => return Ok(()),
606 Some(VisitState::Visiting) => {
607 let cycle = cycle_from_stack(stack, node);
608 return Err(CargoPathDependencyError::new(
609 CargoPathDependencyErrorKind::CyclicDependency,
610 "cycle detected while traversing dependency graph",
611 )
612 .with_cycle(cycle));
613 }
614 None => {}
615 }
616
617 states.insert(node.to_path_buf(), VisitState::Visiting);
618 stack.push(node.to_path_buf());
619 reachable_nodes.insert(node.to_path_buf());
620
621 if let Some(edges) = adjacency.get(node) {
622 for edge in edges {
623 reachable_edges.insert(EdgeRecord {
624 from: node.to_path_buf(),
625 to: edge.to.clone(),
626 dependency_name: edge.dependency_name.clone(),
627 });
628 if states.get(&edge.to) == Some(&VisitState::Visiting) {
629 let cycle = cycle_from_stack(stack, &edge.to);
630 return Err(CargoPathDependencyError::new(
631 CargoPathDependencyErrorKind::CyclicDependency,
632 format!(
633 "cycle detected between {} and {}",
634 node.display(),
635 edge.to.display()
636 ),
637 )
638 .with_cycle(cycle));
639 }
640 traverse_for_reachable(
641 &edge.to,
642 adjacency,
643 states,
644 stack,
645 reachable_nodes,
646 reachable_edges,
647 )?;
648 }
649 }
650
651 stack.pop();
652 states.insert(node.to_path_buf(), VisitState::Visited);
653 Ok(())
654}
655
656fn cycle_from_stack(stack: &[PathBuf], terminal: &Path) -> Vec<PathBuf> {
657 if let Some(position) = stack.iter().position(|entry| entry == terminal) {
658 let mut cycle = stack[position..].to_vec();
659 cycle.push(terminal.to_path_buf());
660 cycle
661 } else {
662 vec![terminal.to_path_buf()]
663 }
664}
665
666fn default_package_name(root: &Path) -> String {
667 root.file_name()
668 .and_then(|segment| segment.to_str())
669 .map(ToOwned::to_owned)
670 .unwrap_or_else(|| root.display().to_string())
671}
672
673fn allowed_dependency_roots(policy: &PathTopologyPolicy) -> Vec<PathBuf> {
674 let mut roots = vec![
675 policy.canonical_root().to_path_buf(),
676 policy.alias_root().to_path_buf(),
677 ];
678
679 for candidate in [policy.canonical_root(), policy.alias_root()] {
680 if let Ok(resolved) = std::fs::canonicalize(candidate)
681 && !roots.iter().any(|root| root == &resolved)
682 {
683 roots.push(resolved);
684 }
685 }
686
687 roots
688}
689
690fn validate_absolute_dependency_scope(
691 dependency_candidate: &Path,
692 policy: &PathTopologyPolicy,
693 manifest_path: &Path,
694 dependency_name: &str,
695 context: &str,
696) -> Result<(), CargoPathDependencyError> {
697 if !dependency_candidate.is_absolute() {
698 return Ok(());
699 }
700 let allowed_roots = allowed_dependency_roots(policy);
701 if allowed_roots
702 .iter()
703 .any(|root| dependency_candidate.starts_with(root))
704 {
705 return Ok(());
706 }
707
708 let mut error = CargoPathDependencyError::new(
709 CargoPathDependencyErrorKind::PathPolicyViolation,
710 format!("{context}: {}", dependency_candidate.display()),
711 )
712 .with_manifest_path(manifest_path)
713 .with_dependency_name(dependency_name.to_string())
714 .with_dependency_path(dependency_candidate.to_path_buf());
715
716 for root in &allowed_roots {
717 error = error.with_diagnostic(format!("allowed root: {}", root.display()));
718 }
719
720 Err(error)
721}
722
723fn normalize_path_for_policy(
724 path: &Path,
725 policy: &PathTopologyPolicy,
726 manifest_path: Option<&Path>,
727 dependency_name: Option<&str>,
728 context: &str,
729) -> Result<PathBuf, CargoPathDependencyError> {
730 normalize_project_path_with_policy(path, policy)
731 .map(|normalized| normalized.canonical_path().to_path_buf())
732 .map_err(|error| {
733 let mapped_kind = if error.kind() == &PathNormalizationErrorKind::InputResolveFailed {
734 CargoPathDependencyErrorKind::MissingPathDependency
735 } else {
736 CargoPathDependencyErrorKind::PathPolicyViolation
737 };
738 let mut mapped = CargoPathDependencyError::new(
739 mapped_kind,
740 format!("{context}: {} ({})", error.kind(), error.detail()),
741 )
742 .with_diagnostic(format!("normalization_error_kind={}", error.kind()))
743 .with_diagnostic(format!("normalization_detail={}", error.detail()))
744 .with_diagnostics(error.decision_trace().iter().map(ToString::to_string));
745
746 if let Some(manifest_path) = manifest_path {
747 mapped = mapped.with_manifest_path(manifest_path);
748 }
749 if let Some(dependency_name) = dependency_name {
750 mapped = mapped.with_dependency_name(dependency_name.to_string());
751 }
752 mapped.with_dependency_path(path)
753 })
754}
755
756#[derive(Debug, Deserialize)]
757struct MetadataDocument {
758 #[serde(default)]
759 packages: Vec<MetadataPackage>,
760 #[serde(default)]
761 workspace_members: Vec<String>,
762 workspace_root: Option<String>,
763 resolve: Option<MetadataResolve>,
764}
765
766#[derive(Debug, Deserialize)]
767struct MetadataResolve {
768 root: Option<String>,
769 #[serde(default)]
770 nodes: Vec<MetadataResolveNode>,
771}
772
773#[derive(Debug, Deserialize)]
774struct MetadataResolveNode {
775 id: String,
776 #[serde(default)]
777 deps: Vec<MetadataResolveDep>,
778}
779
780#[derive(Debug, Deserialize)]
781struct MetadataResolveDep {
782 name: String,
783 pkg: String,
784 #[serde(default)]
785 dep_kinds: Vec<MetadataResolveDepKind>,
786}
787
788#[derive(Debug, Deserialize)]
789struct MetadataResolveDepKind {
790 kind: Option<String>,
791}
792
793impl MetadataResolveDep {
794 fn is_runtime_relevant(&self) -> bool {
795 if self.dep_kinds.is_empty() {
796 return true;
797 }
798
799 self.dep_kinds
800 .iter()
801 .any(|dep_kind| dep_kind.kind.as_deref() != Some("dev"))
802 }
803}
804
805#[derive(Debug, Deserialize)]
806struct MetadataPackage {
807 id: String,
808 name: String,
809 manifest_path: String,
810 #[serde(default)]
811 dependencies: Vec<MetadataDependency>,
812}
813
814#[derive(Debug, Deserialize)]
815struct MetadataDependency {
816 name: String,
817 path: Option<String>,
818 #[serde(default)]
819 optional: bool,
820}
821
822#[derive(Debug)]
823struct MetadataPackageRecord {
824 package_id: String,
825 package_root: PathBuf,
826 manifest_path: PathBuf,
827 dependencies: Vec<MetadataDependency>,
828}
829
830fn resolve_from_metadata<F>(
831 entry_manifest_path: &Path,
832 policy: &PathTopologyPolicy,
833 metadata_provider: &F,
834) -> Result<CargoPathDependencyGraph, CargoPathDependencyError>
835where
836 F: Fn(&Path) -> Result<String, CargoPathDependencyError>,
837{
838 let raw_metadata = metadata_provider(entry_manifest_path)?;
839 let metadata = serde_json::from_str::<MetadataDocument>(&raw_metadata).map_err(|error| {
840 CargoPathDependencyError::new(
841 CargoPathDependencyErrorKind::MetadataParseFailure,
842 format!("failed to parse cargo metadata JSON: {error}"),
843 )
844 .with_manifest_path(entry_manifest_path)
845 })?;
846
847 let mut partial = PartialGraph::default();
848 let workspace_member_ids = metadata
849 .workspace_members
850 .iter()
851 .cloned()
852 .collect::<BTreeSet<_>>();
853 let mut id_to_root: BTreeMap<String, PathBuf> = BTreeMap::new();
854 let mut package_records: Vec<MetadataPackageRecord> = Vec::new();
855
856 for package in metadata.packages {
857 let manifest_path = PathBuf::from(&package.manifest_path);
858 let manifest_dir = manifest_path.parent().ok_or_else(|| {
859 CargoPathDependencyError::new(
860 CargoPathDependencyErrorKind::MetadataParseFailure,
861 format!(
862 "metadata package has invalid manifest path: {}",
863 manifest_path.display()
864 ),
865 )
866 .with_manifest_path(entry_manifest_path)
867 })?;
868 let workspace_member = workspace_member_ids.contains(&package.id);
869 let package_root = match normalize_path_for_policy(
870 manifest_dir,
871 policy,
872 Some(entry_manifest_path),
873 None,
874 "normalize metadata package root",
875 ) {
876 Ok(root) => root,
877 Err(error)
878 if !workspace_member
879 && error.kind() == &CargoPathDependencyErrorKind::PathPolicyViolation =>
880 {
881 continue;
882 }
883 Err(error) => return Err(error),
884 };
885 let canonical_manifest_path = package_root.join("Cargo.toml");
886
887 partial.add_package(
888 package_root.clone(),
889 canonical_manifest_path.clone(),
890 package.name.clone(),
891 workspace_member,
892 );
893
894 id_to_root.insert(package.id.clone(), package_root.clone());
895 package_records.push(MetadataPackageRecord {
896 package_id: package.id,
897 package_root,
898 manifest_path: canonical_manifest_path,
899 dependencies: package.dependencies,
900 });
901 }
902
903 let resolve_nodes = metadata
904 .resolve
905 .as_ref()
906 .map(|resolve| {
907 resolve
908 .nodes
909 .iter()
910 .map(|node| (node.id.clone(), node))
911 .collect::<BTreeMap<_, _>>()
912 })
913 .unwrap_or_default();
914
915 for package in &package_records {
916 if let Some(node) = resolve_nodes.get(&package.package_id) {
917 for dependency in &node.deps {
918 if !dependency.is_runtime_relevant() {
919 continue;
920 }
921 let Some(dependency_root) = id_to_root.get(&dependency.pkg) else {
922 continue;
923 };
924 let dependency_manifest = dependency_root.join("Cargo.toml");
925 partial.add_package(
926 dependency_root.clone(),
927 dependency_manifest,
928 dependency.name.clone(),
929 false,
930 );
931 partial.add_edge(
932 package.package_root.clone(),
933 dependency_root.clone(),
934 dependency.name.clone(),
935 );
936 }
937 continue;
938 }
939
940 for dependency in &package.dependencies {
941 if dependency.optional {
942 continue;
943 }
944 let Some(raw_path) = dependency.path.as_deref() else {
945 continue;
946 };
947
948 let dependency_candidate =
949 resolve_dependency_candidate(&package.package_root, raw_path);
950 validate_absolute_dependency_scope(
951 &dependency_candidate,
952 policy,
953 &package.manifest_path,
954 &dependency.name,
955 "metadata dependency path policy violation",
956 )?;
957 if !dependency_candidate.exists() {
958 return Err(CargoPathDependencyError::new(
959 CargoPathDependencyErrorKind::MissingPathDependency,
960 format!(
961 "metadata dependency path does not exist: {}",
962 dependency_candidate.display()
963 ),
964 )
965 .with_manifest_path(package.manifest_path.clone())
966 .with_dependency_name(dependency.name.clone())
967 .with_dependency_path(dependency_candidate));
968 }
969
970 let dependency_root = normalize_path_for_policy(
971 &dependency_candidate,
972 policy,
973 Some(&package.manifest_path),
974 Some(&dependency.name),
975 "normalize metadata dependency path",
976 )?;
977 let dependency_manifest = dependency_root.join("Cargo.toml");
978 if !dependency_manifest.is_file() {
979 return Err(CargoPathDependencyError::new(
980 CargoPathDependencyErrorKind::MissingPathDependency,
981 format!(
982 "dependency manifest is missing: {}",
983 dependency_manifest.display()
984 ),
985 )
986 .with_manifest_path(package.manifest_path.clone())
987 .with_dependency_name(dependency.name.clone())
988 .with_dependency_path(dependency_manifest));
989 }
990
991 partial.add_package(
992 dependency_root.clone(),
993 dependency_manifest,
994 dependency.name.clone(),
995 false,
996 );
997 partial.add_edge(
998 package.package_root.clone(),
999 dependency_root,
1000 dependency.name.clone(),
1001 );
1002 }
1003 }
1004
1005 if let Some(workspace_root) = metadata.workspace_root {
1006 let workspace_root = normalize_path_for_policy(
1007 Path::new(&workspace_root),
1008 policy,
1009 Some(entry_manifest_path),
1010 None,
1011 "normalize metadata workspace root",
1012 )?;
1013 partial.workspace_root = Some(workspace_root);
1014 }
1015
1016 if !workspace_member_ids.is_empty() {
1017 for workspace_id in &workspace_member_ids {
1018 let root = id_to_root.get(workspace_id).ok_or_else(|| {
1019 CargoPathDependencyError::new(
1020 CargoPathDependencyErrorKind::MetadataParseFailure,
1021 format!("workspace member id missing from package list: {workspace_id}"),
1022 )
1023 .with_manifest_path(entry_manifest_path)
1024 })?;
1025 partial.add_root(root.clone());
1026 if let Some(package) = partial.packages.get_mut(root) {
1027 package.workspace_member = true;
1028 }
1029 }
1030 } else if let Some(resolve_root) = metadata.resolve.and_then(|resolve| resolve.root) {
1031 let root = id_to_root.get(&resolve_root).ok_or_else(|| {
1032 CargoPathDependencyError::new(
1033 CargoPathDependencyErrorKind::MetadataParseFailure,
1034 format!("resolve root id missing from package list: {resolve_root}"),
1035 )
1036 .with_manifest_path(entry_manifest_path)
1037 })?;
1038 partial.add_root(root.clone());
1039 } else {
1040 let entry_root = entry_manifest_path.parent().ok_or_else(|| {
1041 CargoPathDependencyError::new(
1042 CargoPathDependencyErrorKind::MetadataParseFailure,
1043 format!(
1044 "entry manifest has no parent: {}",
1045 entry_manifest_path.display()
1046 ),
1047 )
1048 .with_manifest_path(entry_manifest_path)
1049 })?;
1050 partial.add_root(entry_root.to_path_buf());
1051 }
1052
1053 for root in partial.roots.clone() {
1054 partial
1055 .packages
1056 .entry(root.clone())
1057 .or_insert(PackageRecord {
1058 manifest_path: root.join("Cargo.toml"),
1059 package_name: default_package_name(&root),
1060 workspace_member: true,
1061 });
1062 }
1063
1064 finalize_graph(entry_manifest_path.to_path_buf(), partial)
1065}
1066
1067#[derive(Debug, Clone)]
1068struct ManifestDocument {
1069 package_name: Option<String>,
1070 has_workspace: bool,
1071 workspace_members: Vec<String>,
1072 workspace_path_dependencies: BTreeMap<String, String>,
1073 patch_path_dependencies: BTreeMap<String, String>,
1074 path_dependencies: Vec<ManifestDependency>,
1075}
1076
1077#[derive(Debug, Clone)]
1078struct ManifestDependency {
1079 dependency_name: String,
1080 dependency_path: Option<String>,
1081 uses_workspace_inheritance: bool,
1082}
1083
1084fn resolve_from_manifest_fallback(
1085 entry_manifest_path: &Path,
1086 policy: &PathTopologyPolicy,
1087) -> Result<CargoPathDependencyGraph, CargoPathDependencyError> {
1088 let entry_manifest = read_manifest_document(entry_manifest_path)?;
1089 let entry_root = entry_manifest_path.parent().ok_or_else(|| {
1090 CargoPathDependencyError::new(
1091 CargoPathDependencyErrorKind::ManifestParseFailure,
1092 format!(
1093 "entry manifest has no parent: {}",
1094 entry_manifest_path.display()
1095 ),
1096 )
1097 .with_manifest_path(entry_manifest_path)
1098 })?;
1099 let entry_root = normalize_path_for_policy(
1100 entry_root,
1101 policy,
1102 Some(entry_manifest_path),
1103 None,
1104 "normalize fallback entry root",
1105 )?;
1106
1107 let mut partial = PartialGraph::default();
1108 if entry_manifest.has_workspace {
1109 partial.workspace_root = Some(entry_root.clone());
1110 }
1111 let workspace_path_dependencies = entry_manifest.workspace_path_dependencies.clone();
1112 let patch_path_dependencies = entry_manifest.patch_path_dependencies.clone();
1113
1114 let workspace_member_manifests = expand_workspace_members(
1115 &entry_root,
1116 &entry_manifest.workspace_members,
1117 entry_manifest_path,
1118 )?;
1119 let workspace_member_set = workspace_member_manifests
1120 .iter()
1121 .cloned()
1122 .collect::<BTreeSet<_>>();
1123 let include_entry_manifest =
1124 entry_manifest.package_name.is_some() || workspace_member_set.is_empty();
1125
1126 let mut manifest_cache: BTreeMap<PathBuf, ManifestDocument> = BTreeMap::new();
1127 let mut states: BTreeMap<PathBuf, VisitState> = BTreeMap::new();
1128 let mut stack: Vec<PathBuf> = Vec::new();
1129
1130 for manifest_path in &workspace_member_set {
1131 visit_manifest_recursive(
1132 manifest_path,
1133 policy,
1134 &entry_root,
1135 &workspace_member_set,
1136 &workspace_path_dependencies,
1137 &patch_path_dependencies,
1138 true,
1139 &mut partial,
1140 &mut manifest_cache,
1141 &mut states,
1142 &mut stack,
1143 )?;
1144 }
1145 if include_entry_manifest {
1146 visit_manifest_recursive(
1147 entry_manifest_path,
1148 policy,
1149 &entry_root,
1150 &workspace_member_set,
1151 &workspace_path_dependencies,
1152 &patch_path_dependencies,
1153 true,
1154 &mut partial,
1155 &mut manifest_cache,
1156 &mut states,
1157 &mut stack,
1158 )?;
1159 }
1160
1161 finalize_graph(entry_manifest_path.to_path_buf(), partial)
1162}
1163
1164#[allow(clippy::too_many_arguments)]
1165fn visit_manifest_recursive(
1166 manifest_path: &Path,
1167 policy: &PathTopologyPolicy,
1168 workspace_root: &Path,
1169 workspace_member_manifests: &BTreeSet<PathBuf>,
1170 workspace_path_dependencies: &BTreeMap<String, String>,
1171 patch_path_dependencies: &BTreeMap<String, String>,
1172 mark_workspace_member: bool,
1173 partial: &mut PartialGraph,
1174 manifest_cache: &mut BTreeMap<PathBuf, ManifestDocument>,
1175 states: &mut BTreeMap<PathBuf, VisitState>,
1176 stack: &mut Vec<PathBuf>,
1177) -> Result<PathBuf, CargoPathDependencyError> {
1178 let manifest_root = manifest_path.parent().ok_or_else(|| {
1179 CargoPathDependencyError::new(
1180 CargoPathDependencyErrorKind::ManifestParseFailure,
1181 format!("manifest has no parent: {}", manifest_path.display()),
1182 )
1183 .with_manifest_path(manifest_path)
1184 })?;
1185 let package_root = normalize_path_for_policy(
1186 manifest_root,
1187 policy,
1188 Some(manifest_path),
1189 None,
1190 "normalize manifest package root",
1191 )?;
1192 let canonical_manifest = package_root.join("Cargo.toml");
1193 if !canonical_manifest.is_file() {
1194 return Err(CargoPathDependencyError::new(
1195 CargoPathDependencyErrorKind::ManifestParseFailure,
1196 format!("manifest file missing: {}", canonical_manifest.display()),
1197 )
1198 .with_manifest_path(canonical_manifest));
1199 }
1200
1201 if states.get(&package_root) == Some(&VisitState::Visiting) {
1202 let cycle = cycle_from_stack(stack, &package_root);
1203 return Err(CargoPathDependencyError::new(
1204 CargoPathDependencyErrorKind::CyclicDependency,
1205 format!(
1206 "cyclic path dependency detected at {}",
1207 package_root.display()
1208 ),
1209 )
1210 .with_cycle(cycle));
1211 }
1212 if states.get(&package_root) == Some(&VisitState::Visited) {
1213 if mark_workspace_member {
1214 partial.add_root(package_root.clone());
1215 if let Some(package) = partial.packages.get_mut(&package_root) {
1216 package.workspace_member = true;
1217 }
1218 }
1219 return Ok(package_root);
1220 }
1221
1222 states.insert(package_root.clone(), VisitState::Visiting);
1223 stack.push(package_root.clone());
1224
1225 let manifest = if let Some(cached) = manifest_cache.get(&canonical_manifest) {
1226 cached.clone()
1227 } else {
1228 let parsed = read_manifest_document(&canonical_manifest)?;
1229 manifest_cache.insert(canonical_manifest.clone(), parsed.clone());
1230 parsed
1231 };
1232
1233 let workspace_member =
1234 mark_workspace_member || workspace_member_manifests.contains(&canonical_manifest);
1235 partial.add_package(
1236 package_root.clone(),
1237 canonical_manifest.clone(),
1238 manifest
1239 .package_name
1240 .clone()
1241 .unwrap_or_else(|| default_package_name(&package_root)),
1242 workspace_member,
1243 );
1244 if workspace_member {
1245 partial.add_root(package_root.clone());
1246 }
1247
1248 for dependency in &manifest.path_dependencies {
1249 let Some(dependency_candidate) = resolve_manifest_dependency_candidate(
1250 &package_root,
1251 workspace_root,
1252 dependency,
1253 workspace_path_dependencies,
1254 patch_path_dependencies,
1255 ) else {
1256 continue;
1257 };
1258 validate_absolute_dependency_scope(
1259 &dependency_candidate,
1260 policy,
1261 &canonical_manifest,
1262 &dependency.dependency_name,
1263 "manifest dependency path policy violation",
1264 )?;
1265 if !dependency_candidate.exists() {
1266 return Err(CargoPathDependencyError::new(
1267 CargoPathDependencyErrorKind::MissingPathDependency,
1268 format!(
1269 "dependency path does not exist: {}",
1270 dependency_candidate.display()
1271 ),
1272 )
1273 .with_manifest_path(canonical_manifest.clone())
1274 .with_dependency_name(dependency.dependency_name.clone())
1275 .with_dependency_path(dependency_candidate));
1276 }
1277
1278 let dependency_root = normalize_path_for_policy(
1279 &dependency_candidate,
1280 policy,
1281 Some(&canonical_manifest),
1282 Some(&dependency.dependency_name),
1283 "normalize manifest dependency path",
1284 )?;
1285 let dependency_manifest = dependency_root.join("Cargo.toml");
1286 if !dependency_manifest.is_file() {
1287 return Err(CargoPathDependencyError::new(
1288 CargoPathDependencyErrorKind::MissingPathDependency,
1289 format!(
1290 "dependency manifest missing: {}",
1291 dependency_manifest.display()
1292 ),
1293 )
1294 .with_manifest_path(canonical_manifest.clone())
1295 .with_dependency_name(dependency.dependency_name.clone())
1296 .with_dependency_path(dependency_manifest));
1297 }
1298
1299 partial.add_edge(
1300 package_root.clone(),
1301 dependency_root.clone(),
1302 dependency.dependency_name.clone(),
1303 );
1304 visit_manifest_recursive(
1305 &dependency_manifest,
1306 policy,
1307 workspace_root,
1308 workspace_member_manifests,
1309 workspace_path_dependencies,
1310 patch_path_dependencies,
1311 false,
1312 partial,
1313 manifest_cache,
1314 states,
1315 stack,
1316 )?;
1317 }
1318
1319 stack.pop();
1320 states.insert(package_root, VisitState::Visited);
1321 Ok(canonical_manifest
1322 .parent()
1323 .unwrap_or_else(|| Path::new("/"))
1324 .to_path_buf())
1325}
1326
1327fn resolve_dependency_candidate(base_root: &Path, raw_dependency_path: &str) -> PathBuf {
1328 let raw = PathBuf::from(raw_dependency_path);
1329 let resolved = if raw.is_absolute() {
1330 raw
1331 } else {
1332 base_root.join(raw)
1333 };
1334 if resolved
1335 .file_name()
1336 .is_some_and(|file_name| file_name == "Cargo.toml")
1337 {
1338 resolved.parent().map(Path::to_path_buf).unwrap_or(resolved)
1339 } else {
1340 resolved
1341 }
1342}
1343
1344fn resolve_manifest_dependency_candidate(
1345 package_root: &Path,
1346 workspace_root: &Path,
1347 dependency: &ManifestDependency,
1348 workspace_path_dependencies: &BTreeMap<String, String>,
1349 patch_path_dependencies: &BTreeMap<String, String>,
1350) -> Option<PathBuf> {
1351 if let Some(raw_dependency_path) = dependency.dependency_path.as_deref() {
1352 return Some(resolve_dependency_candidate(
1353 package_root,
1354 raw_dependency_path,
1355 ));
1356 }
1357
1358 if dependency.uses_workspace_inheritance
1359 && let Some(raw_dependency_path) =
1360 workspace_path_dependencies.get(&dependency.dependency_name)
1361 {
1362 return Some(resolve_dependency_candidate(
1363 workspace_root,
1364 raw_dependency_path,
1365 ));
1366 }
1367
1368 patch_path_dependencies
1369 .get(&dependency.dependency_name)
1370 .map(|raw_dependency_path| {
1371 resolve_dependency_candidate(workspace_root, raw_dependency_path)
1372 })
1373}
1374
1375fn read_manifest_document(
1376 manifest_path: &Path,
1377) -> Result<ManifestDocument, CargoPathDependencyError> {
1378 let contents = std::fs::read_to_string(manifest_path).map_err(|error| {
1379 CargoPathDependencyError::new(
1380 CargoPathDependencyErrorKind::ManifestParseFailure,
1381 format!(
1382 "failed to read manifest {}: {error}",
1383 manifest_path.display()
1384 ),
1385 )
1386 .with_manifest_path(manifest_path)
1387 })?;
1388 let table = toml::from_str::<toml::Table>(&contents).map_err(|error| {
1389 CargoPathDependencyError::new(
1390 CargoPathDependencyErrorKind::ManifestParseFailure,
1391 format!(
1392 "failed to parse manifest {}: {error}",
1393 manifest_path.display()
1394 ),
1395 )
1396 .with_manifest_path(manifest_path)
1397 })?;
1398
1399 let package_name = table
1400 .get("package")
1401 .and_then(toml::Value::as_table)
1402 .and_then(|package| package.get("name"))
1403 .and_then(toml::Value::as_str)
1404 .map(ToOwned::to_owned);
1405 let has_workspace = table.contains_key("workspace");
1406 let workspace_members = table
1407 .get("workspace")
1408 .and_then(toml::Value::as_table)
1409 .and_then(|workspace| workspace.get("members"))
1410 .and_then(toml::Value::as_array)
1411 .map(|members| {
1412 members
1413 .iter()
1414 .filter_map(toml::Value::as_str)
1415 .map(ToOwned::to_owned)
1416 .collect::<Vec<_>>()
1417 })
1418 .unwrap_or_default();
1419 let workspace_path_dependencies = table
1420 .get("workspace")
1421 .and_then(toml::Value::as_table)
1422 .and_then(|workspace| workspace.get("dependencies"))
1423 .map(collect_named_path_dependencies)
1424 .unwrap_or_default();
1425 let patch_path_dependencies = table
1426 .get("patch")
1427 .and_then(toml::Value::as_table)
1428 .map(collect_patch_path_dependencies)
1429 .unwrap_or_default();
1430
1431 let mut path_dependencies = Vec::new();
1432 collect_dependency_specs(table.get("dependencies"), &mut path_dependencies);
1433 collect_dependency_specs(table.get("build-dependencies"), &mut path_dependencies);
1434
1435 if let Some(targets) = table.get("target").and_then(toml::Value::as_table) {
1436 for target_config in targets.values() {
1437 if let Some(target_table) = target_config.as_table() {
1438 collect_dependency_specs(target_table.get("dependencies"), &mut path_dependencies);
1439 collect_dependency_specs(
1440 target_table.get("build-dependencies"),
1441 &mut path_dependencies,
1442 );
1443 }
1444 }
1445 }
1446
1447 Ok(ManifestDocument {
1448 package_name,
1449 has_workspace,
1450 workspace_members,
1451 workspace_path_dependencies,
1452 patch_path_dependencies,
1453 path_dependencies,
1454 })
1455}
1456
1457fn collect_dependency_specs(
1458 maybe_table_value: Option<&toml::Value>,
1459 collector: &mut Vec<ManifestDependency>,
1460) {
1461 let Some(table) = maybe_table_value.and_then(toml::Value::as_table) else {
1462 return;
1463 };
1464 for (dependency_name, dependency_value) in table {
1465 match dependency_value {
1466 toml::Value::String(_) => {
1467 collector.push(ManifestDependency {
1468 dependency_name: dependency_name.clone(),
1469 dependency_path: None,
1470 uses_workspace_inheritance: false,
1471 });
1472 }
1473 toml::Value::Table(dependency_table) => {
1474 if dependency_table
1475 .get("optional")
1476 .and_then(toml::Value::as_bool)
1477 .unwrap_or(false)
1478 {
1479 continue;
1480 }
1481 collector.push(ManifestDependency {
1482 dependency_name: dependency_name.clone(),
1483 dependency_path: dependency_table
1484 .get("path")
1485 .and_then(toml::Value::as_str)
1486 .map(ToOwned::to_owned),
1487 uses_workspace_inheritance: dependency_table
1488 .get("workspace")
1489 .and_then(toml::Value::as_bool)
1490 .unwrap_or(false),
1491 });
1492 }
1493 _ => {}
1494 }
1495 }
1496}
1497
1498fn collect_named_path_dependencies(maybe_table_value: &toml::Value) -> BTreeMap<String, String> {
1499 let Some(table) = maybe_table_value.as_table() else {
1500 return BTreeMap::new();
1501 };
1502
1503 let mut dependencies = BTreeMap::new();
1504 for (dependency_name, dependency_value) in table {
1505 let Some(dependency_table) = dependency_value.as_table() else {
1506 continue;
1507 };
1508 let Some(path) = dependency_table.get("path").and_then(toml::Value::as_str) else {
1509 continue;
1510 };
1511 dependencies.insert(dependency_name.clone(), path.to_string());
1512 }
1513 dependencies
1514}
1515
1516fn collect_patch_path_dependencies(patch_table: &toml::value::Table) -> BTreeMap<String, String> {
1517 let mut dependencies = BTreeMap::new();
1518 for patch_source in patch_table.values() {
1519 dependencies.extend(collect_named_path_dependencies(patch_source));
1520 }
1521 dependencies
1522}
1523
1524fn expand_workspace_members(
1525 workspace_root: &Path,
1526 members: &[String],
1527 manifest_path: &Path,
1528) -> Result<Vec<PathBuf>, CargoPathDependencyError> {
1529 let mut manifests = BTreeSet::new();
1530 for member in members {
1531 let expanded_paths = expand_member_pattern(workspace_root, member).map_err(|error| {
1532 CargoPathDependencyError::new(
1533 CargoPathDependencyErrorKind::ManifestParseFailure,
1534 format!("failed to expand workspace member '{member}': {error}"),
1535 )
1536 .with_manifest_path(manifest_path)
1537 })?;
1538 for candidate in expanded_paths {
1539 let manifest_candidate = if candidate
1540 .file_name()
1541 .is_some_and(|file_name| file_name == "Cargo.toml")
1542 {
1543 candidate
1544 } else {
1545 candidate.join("Cargo.toml")
1546 };
1547 if !manifest_candidate.is_file() {
1548 return Err(CargoPathDependencyError::new(
1549 CargoPathDependencyErrorKind::MissingPathDependency,
1550 format!(
1551 "workspace member manifest missing: {}",
1552 manifest_candidate.display()
1553 ),
1554 )
1555 .with_manifest_path(manifest_path)
1556 .with_dependency_name(member.clone())
1557 .with_dependency_path(manifest_candidate));
1558 }
1559 manifests.insert(manifest_candidate);
1560 }
1561 }
1562 Ok(manifests.into_iter().collect())
1563}
1564
1565fn expand_member_pattern(base: &Path, pattern: &str) -> Result<Vec<PathBuf>, std::io::Error> {
1566 if !contains_glob(pattern) {
1567 return Ok(vec![base.join(pattern)]);
1568 }
1569
1570 let mut candidates = vec![base.to_path_buf()];
1571 let normalized_pattern = pattern.replace('\\', "/");
1572 for segment in normalized_pattern.split('/') {
1573 if segment.is_empty() || segment == "." {
1574 continue;
1575 }
1576 if segment == ".." {
1577 candidates = candidates
1578 .into_iter()
1579 .map(|candidate| {
1580 candidate
1581 .parent()
1582 .unwrap_or_else(|| Path::new("/"))
1583 .to_path_buf()
1584 })
1585 .collect();
1586 continue;
1587 }
1588
1589 let wildcard_segment = contains_wildcard(segment);
1590 let mut next_candidates = Vec::new();
1591 for candidate in &candidates {
1592 if wildcard_segment {
1593 if !candidate.is_dir() {
1594 continue;
1595 }
1596 for entry in std::fs::read_dir(candidate)? {
1597 let entry = entry?;
1598 let file_name = entry.file_name();
1599 let Some(file_name) = file_name.to_str() else {
1600 continue;
1601 };
1602 if wildcard_match(segment, file_name) {
1603 next_candidates.push(entry.path());
1604 }
1605 }
1606 } else {
1607 next_candidates.push(candidate.join(segment));
1608 }
1609 }
1610 candidates = next_candidates;
1611 }
1612
1613 Ok(candidates)
1614}
1615
1616fn contains_glob(pattern: &str) -> bool {
1617 pattern.chars().any(|ch| matches!(ch, '*' | '?' | '['))
1618}
1619
1620fn contains_wildcard(segment: &str) -> bool {
1621 segment.contains('*') || segment.contains('?')
1622}
1623
1624fn wildcard_match(pattern: &str, value: &str) -> bool {
1625 wildcard_match_bytes(pattern.as_bytes(), value.as_bytes())
1626}
1627
1628fn wildcard_match_bytes(pattern: &[u8], value: &[u8]) -> bool {
1629 if pattern.is_empty() {
1630 return value.is_empty();
1631 }
1632 if pattern[0] == b'*' {
1633 for index in 0..=value.len() {
1634 if wildcard_match_bytes(&pattern[1..], &value[index..]) {
1635 return true;
1636 }
1637 }
1638 return false;
1639 }
1640 if value.is_empty() {
1641 return false;
1642 }
1643 if pattern[0] == b'?' || pattern[0] == value[0] {
1644 return wildcard_match_bytes(&pattern[1..], &value[1..]);
1645 }
1646 false
1647}
1648
1649#[cfg(test)]
1650mod tests {
1651 use super::*;
1652 use crate::e2e::{MultiRepoFixtureConfig, reset_multi_repo_fixtures};
1653 use std::fs;
1654 use std::sync::atomic::{AtomicU64, Ordering};
1655
1656 #[cfg(unix)]
1657 use std::os::unix::fs::symlink;
1658
1659 static FIXTURE_COUNTER: AtomicU64 = AtomicU64::new(0);
1660
1661 #[cfg(unix)]
1662 struct TopologyFixture {
1663 root: PathBuf,
1664 canonical_root: PathBuf,
1665 alias_root: PathBuf,
1666 }
1667
1668 #[cfg(unix)]
1669 impl TopologyFixture {
1670 fn new(prefix: &str) -> Self {
1671 let id = FIXTURE_COUNTER.fetch_add(1, Ordering::SeqCst);
1672 let root = std::env::temp_dir().join(format!(
1673 "rch-cargo-path-deps-{}-{}-{}",
1674 prefix,
1675 std::process::id(),
1676 id
1677 ));
1678 let canonical_root = root.join("data/projects");
1679 let alias_root = root.join("dp");
1680 fs::create_dir_all(&canonical_root).expect("create canonical root");
1681 symlink(&canonical_root, &alias_root).expect("create alias symlink");
1682
1683 Self {
1684 root,
1685 canonical_root,
1686 alias_root,
1687 }
1688 }
1689
1690 fn policy(&self) -> PathTopologyPolicy {
1691 PathTopologyPolicy::new(self.canonical_root.clone(), self.alias_root.clone())
1692 }
1693 }
1694
1695 #[cfg(unix)]
1696 impl Drop for TopologyFixture {
1697 fn drop(&mut self) {
1698 let _ = fs::remove_dir_all(&self.root);
1699 }
1700 }
1701
1702 #[cfg(unix)]
1703 fn write_lib_crate(root: &Path, crate_name: &str, deps: &[(&str, &str)]) {
1704 fs::create_dir_all(root.join("src")).expect("create crate src");
1705 fs::write(root.join("Cargo.toml"), crate_manifest(crate_name, deps))
1706 .expect("write manifest");
1707 fs::write(
1708 root.join("src/lib.rs"),
1709 format!(
1710 "pub fn {}() -> &'static str {{ \"{}\" }}\n",
1711 crate_name, crate_name
1712 ),
1713 )
1714 .expect("write lib.rs");
1715 }
1716
1717 #[cfg(unix)]
1718 fn write_bin_crate(root: &Path, crate_name: &str, deps: &[(&str, &str)]) {
1719 fs::create_dir_all(root.join("src")).expect("create crate src");
1720 fs::write(root.join("Cargo.toml"), crate_manifest(crate_name, deps))
1721 .expect("write manifest");
1722 fs::write(
1723 root.join("src/main.rs"),
1724 format!("fn main() {{ println!(\"{}\"); }}\n", crate_name),
1725 )
1726 .expect("write main.rs");
1727 }
1728
1729 #[cfg(unix)]
1730 fn crate_manifest(crate_name: &str, deps: &[(&str, &str)]) -> String {
1731 let mut dependencies = String::new();
1732 for (name, path) in deps {
1733 dependencies.push_str(&format!("{name} = {{ path = \"{path}\" }}\n"));
1734 }
1735 format!(
1736 "[package]\nname = \"{crate_name}\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\n{dependencies}"
1737 )
1738 }
1739
1740 #[cfg(unix)]
1741 #[test]
1742 fn resolves_workspace_transitive_path_dependencies() {
1743 let fixture = TopologyFixture::new("workspace");
1744 let scenario_root = fixture.canonical_root.join("workspace_transitive");
1745 let workspace_root = scenario_root.join("workspace");
1746 let shared_root = scenario_root.join("shared/shared_lib");
1747 let util_root = workspace_root.join("crates/util");
1748 let app_root = workspace_root.join("crates/app");
1749
1750 write_lib_crate(&shared_root, "workspace_shared", &[]);
1751 write_lib_crate(
1752 &util_root,
1753 "workspace_util",
1754 &[("workspace_shared", "../../../shared/shared_lib")],
1755 );
1756 write_bin_crate(&app_root, "workspace_app", &[("workspace_util", "../util")]);
1757 fs::create_dir_all(&workspace_root).expect("create workspace root");
1758 fs::write(
1759 workspace_root.join("Cargo.toml"),
1760 "[workspace]\nmembers = [\"crates/app\", \"crates/util\"]\nresolver = \"3\"\n",
1761 )
1762 .expect("write workspace manifest");
1763
1764 let graph =
1765 resolve_cargo_path_dependency_graph_with_policy(&workspace_root, &fixture.policy())
1766 .expect("resolve workspace graph");
1767
1768 let app_root = app_root.canonicalize().expect("canonical app root");
1769 let util_root = util_root.canonicalize().expect("canonical util root");
1770 let shared_root = shared_root.canonicalize().expect("canonical shared root");
1771 assert_eq!(
1772 graph.root_packages,
1773 vec![app_root.clone(), util_root.clone()]
1774 );
1775 let package_roots = graph
1776 .packages
1777 .iter()
1778 .map(|package| package.package_root.clone())
1779 .collect::<Vec<_>>();
1780 assert!(
1781 package_roots
1782 .windows(2)
1783 .all(|window| window[0] <= window[1]),
1784 "packages should be deterministically sorted"
1785 );
1786 assert_eq!(
1787 package_roots.into_iter().collect::<BTreeSet<_>>(),
1788 BTreeSet::from([app_root.clone(), shared_root.clone(), util_root.clone()])
1789 );
1790 assert_eq!(
1791 graph.edges,
1792 vec![
1793 CargoPathDependencyEdge {
1794 from: app_root,
1795 to: util_root.clone(),
1796 dependency_name: "workspace_util".to_string(),
1797 },
1798 CargoPathDependencyEdge {
1799 from: util_root,
1800 to: shared_root,
1801 dependency_name: "workspace_shared".to_string(),
1802 },
1803 ]
1804 );
1805 }
1806
1807 #[cfg(unix)]
1808 #[test]
1809 fn resolves_virtual_workspace_members_from_alias_path() {
1810 let fixture = TopologyFixture::new("virtual-workspace");
1811 let scenario_root = fixture.canonical_root.join("virtual_workspace");
1812 let workspace_root = scenario_root.join("ws");
1813 let member_a = workspace_root.join("members/a");
1814 let member_b = workspace_root.join("members/b");
1815
1816 write_lib_crate(&member_b, "virtual_b", &[]);
1817 write_lib_crate(&member_a, "virtual_a", &[("virtual_b", "../b")]);
1818 fs::create_dir_all(&workspace_root).expect("create workspace root");
1819 fs::write(
1820 workspace_root.join("Cargo.toml"),
1821 "[workspace]\nmembers = [\"members/a\", \"members/b\"]\nresolver = \"3\"\n",
1822 )
1823 .expect("write workspace manifest");
1824
1825 let relative = workspace_root
1826 .strip_prefix(&fixture.canonical_root)
1827 .expect("workspace under canonical root");
1828 let alias_workspace = fixture.alias_root.join(relative);
1829
1830 let graph =
1831 resolve_cargo_path_dependency_graph_with_policy(&alias_workspace, &fixture.policy())
1832 .expect("resolve virtual workspace graph");
1833
1834 let member_a = member_a.canonicalize().expect("canonical member a");
1835 let member_b = member_b.canonicalize().expect("canonical member b");
1836 assert_eq!(
1837 graph.workspace_root,
1838 Some(
1839 workspace_root
1840 .canonicalize()
1841 .expect("canonical workspace root")
1842 )
1843 );
1844 assert_eq!(
1845 graph.root_packages,
1846 vec![member_a.clone(), member_b.clone()]
1847 );
1848 assert_eq!(
1849 graph.edges,
1850 vec![CargoPathDependencyEdge {
1851 from: member_a,
1852 to: member_b,
1853 dependency_name: "virtual_b".to_string(),
1854 }]
1855 );
1856 }
1857
1858 #[cfg(unix)]
1859 #[test]
1860 fn resolves_nested_manifest_transitive_closure() {
1861 let fixture = TopologyFixture::new("nested");
1862 let scenario_root = fixture.canonical_root.join("nested_manifests");
1863 let app_root = scenario_root.join("app");
1864 let util_root = scenario_root.join("libs/util");
1865 let core_root = scenario_root.join("libs/core");
1866
1867 write_lib_crate(&core_root, "nested_core", &[]);
1868 write_lib_crate(&util_root, "nested_util", &[("nested_core", "../core")]);
1869 write_bin_crate(&app_root, "nested_app", &[("nested_util", "../libs/util")]);
1870
1871 let graph = resolve_cargo_path_dependency_graph_with_policy(
1872 &app_root.join("Cargo.toml"),
1873 &fixture.policy(),
1874 )
1875 .expect("resolve nested manifest graph");
1876
1877 let app_root = app_root.canonicalize().expect("canonical app");
1878 let util_root = util_root.canonicalize().expect("canonical util");
1879 let core_root = core_root.canonicalize().expect("canonical core");
1880 assert_eq!(graph.root_packages, vec![app_root.clone()]);
1881 assert_eq!(
1882 graph.edges,
1883 vec![
1884 CargoPathDependencyEdge {
1885 from: app_root,
1886 to: util_root.clone(),
1887 dependency_name: "nested_util".to_string(),
1888 },
1889 CargoPathDependencyEdge {
1890 from: util_root,
1891 to: core_root,
1892 dependency_name: "nested_core".to_string(),
1893 },
1894 ]
1895 );
1896 }
1897
1898 #[cfg(unix)]
1899 #[test]
1900 fn malformed_metadata_uses_manifest_fallback() {
1901 let fixture = TopologyFixture::new("malformed-metadata");
1902 let scenario_root = fixture.canonical_root.join("metadata_fallback");
1903 let app_root = scenario_root.join("app");
1904 let dep_root = scenario_root.join("dep");
1905
1906 write_lib_crate(&dep_root, "fallback_dep", &[]);
1907 write_bin_crate(&app_root, "fallback_app", &[("fallback_dep", "../dep")]);
1908
1909 let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
1910 &app_root,
1911 &fixture.policy(),
1912 |_| Ok("{not-json".to_string()),
1913 )
1914 .expect("resolver should recover using fallback");
1915
1916 let app_root = app_root.canonicalize().expect("canonical app");
1917 let dep_root = dep_root.canonicalize().expect("canonical dep");
1918 assert_eq!(graph.root_packages, vec![app_root.clone()]);
1919 assert_eq!(
1920 graph.edges,
1921 vec![CargoPathDependencyEdge {
1922 from: app_root,
1923 to: dep_root,
1924 dependency_name: "fallback_dep".to_string(),
1925 }]
1926 );
1927 }
1928
1929 #[cfg(unix)]
1930 #[test]
1931 fn malformed_manifest_reports_manifest_parse_failure() {
1932 let fixture = TopologyFixture::new("manifest-error");
1933 let config = MultiRepoFixtureConfig::new(
1934 fixture.canonical_root.clone(),
1935 fixture.alias_root.clone(),
1936 "resolver_manifest_error",
1937 );
1938 let fixtures = reset_multi_repo_fixtures(&config).expect("generate fixture set");
1939 let invalid = fixtures
1940 .fixture("fail_invalid_manifest")
1941 .expect("invalid fixture metadata");
1942
1943 let error = resolve_cargo_path_dependency_graph_with_policy(
1944 &invalid.canonical_entrypoint,
1945 &fixture.policy(),
1946 )
1947 .expect_err("invalid manifest must fail");
1948 assert_eq!(
1949 error.kind(),
1950 &CargoPathDependencyErrorKind::ManifestParseFailure
1951 );
1952 }
1953
1954 #[cfg(unix)]
1955 #[test]
1956 fn missing_path_reports_missing_dependency_kind() {
1957 let fixture = TopologyFixture::new("missing-path");
1958 let config = MultiRepoFixtureConfig::new(
1959 fixture.canonical_root.clone(),
1960 fixture.alias_root.clone(),
1961 "resolver_missing_path",
1962 );
1963 let fixtures = reset_multi_repo_fixtures(&config).expect("generate fixture set");
1964 let missing = fixtures
1965 .fixture("fail_missing_path_dep")
1966 .expect("missing fixture metadata");
1967
1968 let error = resolve_cargo_path_dependency_graph_with_policy(
1969 &missing.canonical_entrypoint,
1970 &fixture.policy(),
1971 )
1972 .expect_err("missing dependency must fail");
1973 assert_eq!(
1974 error.kind(),
1975 &CargoPathDependencyErrorKind::MissingPathDependency
1976 );
1977 }
1978
1979 #[cfg(unix)]
1980 #[test]
1981 fn outside_root_reports_path_policy_violation() {
1982 let fixture = TopologyFixture::new("outside-root");
1983 let config = MultiRepoFixtureConfig::new(
1984 fixture.canonical_root.clone(),
1985 fixture.alias_root.clone(),
1986 "resolver_outside_root",
1987 );
1988 let fixtures = reset_multi_repo_fixtures(&config).expect("generate fixture set");
1989 let outside = fixtures
1990 .fixture("fail_outside_canonical_dep")
1991 .expect("outside fixture metadata");
1992
1993 let error = resolve_cargo_path_dependency_graph_with_policy(
1994 &outside.canonical_entrypoint,
1995 &fixture.policy(),
1996 )
1997 .expect_err("outside root dependency must fail");
1998 assert_eq!(
1999 error.kind(),
2000 &CargoPathDependencyErrorKind::PathPolicyViolation
2001 );
2002 }
2003
2004 #[cfg(unix)]
2005 #[test]
2006 fn resolved_alias_target_is_allowed_for_absolute_dependency_scope() {
2007 let fixture = TopologyFixture::new("resolved-alias-target");
2008 let alias_target_root = fixture
2009 .alias_root
2010 .canonicalize()
2011 .expect("resolve alias target root");
2012 let dependency_candidate = alias_target_root.join("repo/crate_dep");
2013
2014 validate_absolute_dependency_scope(
2015 &dependency_candidate,
2016 &fixture.policy(),
2017 Path::new("/tmp/Cargo.toml"),
2018 "crate_dep",
2019 "metadata dependency path policy violation",
2020 )
2021 .expect("resolved alias target should be accepted");
2022 }
2023
2024 #[cfg(unix)]
2025 #[test]
2026 fn cyclic_path_dependencies_report_cycle_kind() {
2027 let fixture = TopologyFixture::new("cycle");
2028 let scenario_root = fixture.canonical_root.join("cycle");
2029 let crate_a = scenario_root.join("a");
2030 let crate_b = scenario_root.join("b");
2031
2032 write_lib_crate(&crate_a, "cycle_a", &[("cycle_b", "../b")]);
2033 write_lib_crate(&crate_b, "cycle_b", &[("cycle_a", "../a")]);
2034
2035 let error = resolve_cargo_path_dependency_graph_with_policy(&crate_a, &fixture.policy())
2036 .expect_err("cyclic path dependencies must fail");
2037 assert_eq!(
2038 error.kind(),
2039 &CargoPathDependencyErrorKind::CyclicDependency
2040 );
2041 assert!(
2042 error.cycle().len() >= 3,
2043 "cycle path should include repeated terminal node"
2044 );
2045 }
2046
2047 #[cfg(unix)]
2048 #[test]
2049 fn optional_path_dependency_cycle_is_ignored_for_active_closure() {
2050 let fixture = TopologyFixture::new("optional-cycle");
2051 let scenario_root = fixture.canonical_root.join("optional_cycle");
2052 let app_root = scenario_root.join("app");
2053 let real_dep_root = scenario_root.join("real_dep");
2054 let optional_a_root = scenario_root.join("optional_a");
2055 let optional_b_root = scenario_root.join("optional_b");
2056
2057 write_lib_crate(&real_dep_root, "real_dep", &[]);
2058 write_lib_crate(
2059 &optional_a_root,
2060 "optional_a",
2061 &[("optional_b", "../optional_b")],
2062 );
2063 write_lib_crate(
2064 &optional_b_root,
2065 "optional_b",
2066 &[("optional_a", "../optional_a")],
2067 );
2068
2069 fs::create_dir_all(app_root.join("src")).expect("create app src");
2070 fs::write(
2071 app_root.join("Cargo.toml"),
2072 r#"[package]
2073name = "optional_cycle_app"
2074version = "0.1.0"
2075edition = "2024"
2076
2077[dependencies]
2078real_dep = { path = "../real_dep" }
2079optional_a = { path = "../optional_a", optional = true }
2080"#,
2081 )
2082 .expect("write app manifest");
2083 fs::write(app_root.join("src/main.rs"), "fn main() {}\n").expect("write app main");
2084
2085 let graph = resolve_cargo_path_dependency_graph_with_policy(&app_root, &fixture.policy())
2086 .expect("optional cycle should not poison active dependency closure");
2087
2088 let app_root = app_root.canonicalize().expect("canonical app");
2089 let real_dep_root = real_dep_root.canonicalize().expect("canonical real dep");
2090 assert_eq!(graph.root_packages, vec![app_root.clone()]);
2091 assert_eq!(
2092 graph.edges,
2093 vec![CargoPathDependencyEdge {
2094 from: app_root,
2095 to: real_dep_root,
2096 dependency_name: "real_dep".to_string(),
2097 }]
2098 );
2099 }
2100
2101 #[test]
2104 fn error_kind_display_all_variants() {
2105 let variants = [
2106 (
2107 CargoPathDependencyErrorKind::ManifestParseFailure,
2108 "manifest parse failure",
2109 ),
2110 (
2111 CargoPathDependencyErrorKind::MissingPathDependency,
2112 "missing path dependency",
2113 ),
2114 (
2115 CargoPathDependencyErrorKind::CyclicDependency,
2116 "cyclic path dependency",
2117 ),
2118 (
2119 CargoPathDependencyErrorKind::PathPolicyViolation,
2120 "path policy violation",
2121 ),
2122 (
2123 CargoPathDependencyErrorKind::MetadataParseFailure,
2124 "metadata parse failure",
2125 ),
2126 (
2127 CargoPathDependencyErrorKind::MetadataInvocationFailure,
2128 "metadata invocation failure",
2129 ),
2130 ];
2131 for (kind, expected) in variants {
2132 assert_eq!(kind.to_string(), expected, "Display for {kind:?}");
2133 }
2134 }
2135
2136 #[test]
2137 fn error_accessors_return_builder_values() {
2138 let error = CargoPathDependencyError::new(
2139 CargoPathDependencyErrorKind::MissingPathDependency,
2140 "test detail",
2141 )
2142 .with_manifest_path("/a/Cargo.toml")
2143 .with_dependency_name("dep_x")
2144 .with_dependency_path("/b/dep_x");
2145
2146 assert_eq!(
2147 error.kind(),
2148 &CargoPathDependencyErrorKind::MissingPathDependency
2149 );
2150 assert_eq!(error.detail(), "test detail");
2151 assert_eq!(error.manifest_path(), Some(Path::new("/a/Cargo.toml")));
2152 assert_eq!(error.dependency_name(), Some("dep_x"));
2153 assert_eq!(error.dependency_path(), Some(Path::new("/b/dep_x")));
2154 assert!(error.cycle().is_empty());
2155 assert!(error.diagnostics().is_empty());
2156 }
2157
2158 #[test]
2159 fn error_display_includes_all_fields() {
2160 let error = CargoPathDependencyError::new(
2161 CargoPathDependencyErrorKind::MissingPathDependency,
2162 "not found",
2163 )
2164 .with_manifest_path("/a/Cargo.toml")
2165 .with_dependency_name("missing_dep")
2166 .with_dependency_path("/b/missing");
2167
2168 let display = error.to_string();
2169 assert!(
2170 display.contains("missing path dependency"),
2171 "should contain kind"
2172 );
2173 assert!(display.contains("not found"), "should contain detail");
2174 assert!(
2175 display.contains("/a/Cargo.toml"),
2176 "should contain manifest path"
2177 );
2178 assert!(
2179 display.contains("missing_dep"),
2180 "should contain dependency name"
2181 );
2182 assert!(
2183 display.contains("/b/missing"),
2184 "should contain dependency path"
2185 );
2186 }
2187
2188 #[test]
2189 fn error_diagnostics_accumulate() {
2190 let error = CargoPathDependencyError::new(
2191 CargoPathDependencyErrorKind::MetadataParseFailure,
2192 "parse error",
2193 )
2194 .with_diagnostic("line 1")
2195 .with_diagnostics(["line 2", "line 3"]);
2196
2197 assert_eq!(error.diagnostics().len(), 3);
2198 assert_eq!(error.diagnostics()[0], "line 1");
2199 assert_eq!(error.diagnostics()[1], "line 2");
2200 assert_eq!(error.diagnostics()[2], "line 3");
2201 }
2202
2203 #[test]
2204 fn error_implements_std_error() {
2205 let error = CargoPathDependencyError::new(
2206 CargoPathDependencyErrorKind::ManifestParseFailure,
2207 "bad toml",
2208 );
2209 let _: &dyn std::error::Error = &error;
2210 }
2211
2212 #[test]
2215 fn default_package_name_uses_last_component() {
2216 assert_eq!(default_package_name(Path::new("/a/b/my_crate")), "my_crate");
2217 assert_eq!(default_package_name(Path::new("/single")), "single");
2218 }
2219
2220 #[test]
2221 fn default_package_name_root_path_uses_display() {
2222 let name = default_package_name(Path::new("/"));
2223 assert!(
2224 !name.is_empty(),
2225 "root path should produce a non-empty name"
2226 );
2227 }
2228
2229 #[test]
2230 fn resolve_dependency_candidate_relative_path() {
2231 let result = resolve_dependency_candidate(Path::new("/project/app"), "../lib");
2232 assert_eq!(result, PathBuf::from("/project/app/../lib"));
2233 }
2234
2235 #[test]
2236 fn resolve_dependency_candidate_absolute_path() {
2237 let result = resolve_dependency_candidate(Path::new("/project/app"), "/other/lib");
2238 assert_eq!(result, PathBuf::from("/other/lib"));
2239 }
2240
2241 #[test]
2242 fn resolve_dependency_candidate_strips_cargo_toml_suffix() {
2243 let result = resolve_dependency_candidate(Path::new("/project/app"), "../lib/Cargo.toml");
2244 assert_eq!(result, PathBuf::from("/project/app/../lib"));
2245 }
2246
2247 #[test]
2248 fn resolve_dependency_candidate_preserves_non_cargo_toml() {
2249 let result = resolve_dependency_candidate(Path::new("/project/app"), "../lib/src");
2250 assert_eq!(result, PathBuf::from("/project/app/../lib/src"));
2251 }
2252
2253 #[test]
2256 fn contains_glob_detects_patterns() {
2257 assert!(contains_glob("crates/*"));
2258 assert!(contains_glob("lib?"));
2259 assert!(contains_glob("[abc]"));
2260 assert!(!contains_glob("plain_path"));
2261 assert!(!contains_glob(""));
2262 }
2263
2264 #[test]
2265 fn contains_wildcard_detects_star_and_question() {
2266 assert!(contains_wildcard("*"));
2267 assert!(contains_wildcard("foo*"));
2268 assert!(contains_wildcard("fo?"));
2269 assert!(!contains_wildcard("plain"));
2270 assert!(!contains_wildcard("[abc]"));
2271 }
2272
2273 #[test]
2274 fn wildcard_match_exact() {
2275 assert!(wildcard_match("hello", "hello"));
2276 assert!(!wildcard_match("hello", "world"));
2277 }
2278
2279 #[test]
2280 fn wildcard_match_star_patterns() {
2281 assert!(wildcard_match("*", "anything"));
2282 assert!(wildcard_match("*", ""));
2283 assert!(wildcard_match("he*o", "hello"));
2284 assert!(wildcard_match("he*o", "heo"));
2285 assert!(!wildcard_match("he*o", "hex"));
2286 assert!(wildcard_match("*.*", "file.rs"));
2287 assert!(!wildcard_match("*.*", "nodot"));
2288 }
2289
2290 #[test]
2291 fn wildcard_match_question_mark() {
2292 assert!(wildcard_match("h?llo", "hello"));
2293 assert!(wildcard_match("h?llo", "hallo"));
2294 assert!(!wildcard_match("h?llo", "hllo"));
2295 assert!(!wildcard_match("?", ""));
2296 }
2297
2298 #[test]
2299 fn wildcard_match_combined() {
2300 assert!(wildcard_match("rch-*", "rch-common"));
2301 assert!(wildcard_match("rch-*", "rch-"));
2302 assert!(!wildcard_match("rch-*", "rch"));
2303 }
2304
2305 #[test]
2306 fn wildcard_match_empty_pattern_and_value() {
2307 assert!(wildcard_match("", ""));
2308 assert!(!wildcard_match("", "x"));
2309 assert!(wildcard_match("*", ""));
2310 }
2311
2312 #[test]
2315 fn cycle_from_stack_extracts_cycle_segment() {
2316 let stack = vec![
2317 PathBuf::from("/a"),
2318 PathBuf::from("/b"),
2319 PathBuf::from("/c"),
2320 ];
2321 let cycle = cycle_from_stack(&stack, Path::new("/b"));
2322 assert_eq!(
2323 cycle,
2324 vec![
2325 PathBuf::from("/b"),
2326 PathBuf::from("/c"),
2327 PathBuf::from("/b"),
2328 ]
2329 );
2330 }
2331
2332 #[test]
2333 fn cycle_from_stack_terminal_not_in_stack() {
2334 let stack = vec![PathBuf::from("/a")];
2335 let cycle = cycle_from_stack(&stack, Path::new("/z"));
2336 assert_eq!(cycle, vec![PathBuf::from("/z")]);
2337 }
2338
2339 #[test]
2340 fn cycle_from_stack_single_node_self_cycle() {
2341 let stack = vec![PathBuf::from("/a")];
2342 let cycle = cycle_from_stack(&stack, Path::new("/a"));
2343 assert_eq!(cycle, vec![PathBuf::from("/a"), PathBuf::from("/a")]);
2344 }
2345
2346 #[test]
2347 fn cycle_from_stack_empty_stack() {
2348 let stack: Vec<PathBuf> = vec![];
2349 let cycle = cycle_from_stack(&stack, Path::new("/a"));
2350 assert_eq!(cycle, vec![PathBuf::from("/a")]);
2351 }
2352
2353 #[test]
2356 fn finalize_empty_graph() {
2357 let partial = PartialGraph::default();
2358 let graph =
2359 finalize_graph(PathBuf::from("/fake/Cargo.toml"), partial).expect("should succeed");
2360 assert!(graph.packages.is_empty());
2361 assert!(graph.edges.is_empty());
2362 assert!(graph.root_packages.is_empty());
2363 assert!(graph.workspace_root.is_none());
2364 }
2365
2366 #[test]
2367 fn finalize_graph_single_root_no_edges() {
2368 let mut partial = PartialGraph::default();
2369 partial.add_root(PathBuf::from("/project"));
2370 partial.add_package(
2371 PathBuf::from("/project"),
2372 PathBuf::from("/project/Cargo.toml"),
2373 "my_project".to_string(),
2374 true,
2375 );
2376
2377 let graph =
2378 finalize_graph(PathBuf::from("/project/Cargo.toml"), partial).expect("should succeed");
2379 assert_eq!(graph.packages.len(), 1);
2380 assert_eq!(graph.packages[0].package_name, "my_project");
2381 assert!(graph.packages[0].workspace_member);
2382 assert!(graph.edges.is_empty());
2383 assert_eq!(graph.root_packages, vec![PathBuf::from("/project")]);
2384 }
2385
2386 #[test]
2387 fn finalize_graph_unreachable_packages_excluded() {
2388 let mut partial = PartialGraph::default();
2389 partial.add_root(PathBuf::from("/root"));
2390 partial.add_package(
2391 PathBuf::from("/root"),
2392 PathBuf::from("/root/Cargo.toml"),
2393 "root_pkg".to_string(),
2394 true,
2395 );
2396 partial.add_package(
2398 PathBuf::from("/orphan"),
2399 PathBuf::from("/orphan/Cargo.toml"),
2400 "orphan_pkg".to_string(),
2401 false,
2402 );
2403
2404 let graph =
2405 finalize_graph(PathBuf::from("/root/Cargo.toml"), partial).expect("should succeed");
2406 assert_eq!(graph.packages.len(), 1, "orphan should be excluded");
2407 assert_eq!(graph.packages[0].package_name, "root_pkg");
2408 }
2409
2410 #[test]
2411 fn finalize_graph_detects_cycle() {
2412 let mut partial = PartialGraph::default();
2413 partial.add_root(PathBuf::from("/a"));
2414 partial.add_package(
2415 PathBuf::from("/a"),
2416 PathBuf::from("/a/Cargo.toml"),
2417 "a".to_string(),
2418 true,
2419 );
2420 partial.add_package(
2421 PathBuf::from("/b"),
2422 PathBuf::from("/b/Cargo.toml"),
2423 "b".to_string(),
2424 false,
2425 );
2426 partial.add_edge(PathBuf::from("/a"), PathBuf::from("/b"), "b".to_string());
2427 partial.add_edge(PathBuf::from("/b"), PathBuf::from("/a"), "a".to_string());
2428
2429 let error = finalize_graph(PathBuf::from("/a/Cargo.toml"), partial)
2430 .expect_err("should detect cycle");
2431 assert_eq!(
2432 error.kind(),
2433 &CargoPathDependencyErrorKind::CyclicDependency
2434 );
2435 assert!(
2436 error.cycle().len() >= 2,
2437 "cycle should include at least the two nodes"
2438 );
2439 }
2440
2441 #[test]
2442 fn finalize_graph_diamond_reachable() {
2443 let mut partial = PartialGraph::default();
2445 partial.add_root(PathBuf::from("/a"));
2446 for (name, root) in [("a", "/a"), ("b", "/b"), ("c", "/c"), ("d", "/d")] {
2447 partial.add_package(
2448 PathBuf::from(root),
2449 PathBuf::from(format!("{root}/Cargo.toml")),
2450 name.to_string(),
2451 name == "a",
2452 );
2453 }
2454 partial.add_edge(PathBuf::from("/a"), PathBuf::from("/b"), "b".to_string());
2455 partial.add_edge(PathBuf::from("/a"), PathBuf::from("/c"), "c".to_string());
2456 partial.add_edge(PathBuf::from("/b"), PathBuf::from("/d"), "d".to_string());
2457 partial.add_edge(PathBuf::from("/c"), PathBuf::from("/d"), "d".to_string());
2458
2459 let graph =
2460 finalize_graph(PathBuf::from("/a/Cargo.toml"), partial).expect("should succeed");
2461 assert_eq!(graph.packages.len(), 4, "all 4 nodes reachable in diamond");
2462 assert_eq!(graph.edges.len(), 4, "all 4 edges present");
2463 let names: Vec<_> = graph
2465 .packages
2466 .iter()
2467 .map(|p| p.package_name.as_str())
2468 .collect();
2469 assert_eq!(names, vec!["a", "b", "c", "d"]);
2470 }
2471
2472 #[test]
2475 fn graph_serialization_round_trip() {
2476 let graph = CargoPathDependencyGraph {
2477 entry_manifest_path: PathBuf::from("/project/Cargo.toml"),
2478 workspace_root: Some(PathBuf::from("/project")),
2479 root_packages: vec![PathBuf::from("/project/app")],
2480 packages: vec![
2481 CargoPathDependencyPackage {
2482 package_root: PathBuf::from("/project/app"),
2483 manifest_path: PathBuf::from("/project/app/Cargo.toml"),
2484 package_name: "app".to_string(),
2485 workspace_member: true,
2486 },
2487 CargoPathDependencyPackage {
2488 package_root: PathBuf::from("/project/lib"),
2489 manifest_path: PathBuf::from("/project/lib/Cargo.toml"),
2490 package_name: "lib".to_string(),
2491 workspace_member: true,
2492 },
2493 ],
2494 edges: vec![CargoPathDependencyEdge {
2495 from: PathBuf::from("/project/app"),
2496 to: PathBuf::from("/project/lib"),
2497 dependency_name: "lib".to_string(),
2498 }],
2499 };
2500
2501 let json = serde_json::to_string(&graph).expect("serialize");
2502 let deserialized: CargoPathDependencyGraph =
2503 serde_json::from_str(&json).expect("deserialize");
2504 assert_eq!(graph, deserialized);
2505 }
2506
2507 #[test]
2510 fn edge_ordering_is_deterministic() {
2511 let edge_a = CargoPathDependencyEdge {
2512 from: PathBuf::from("/a"),
2513 to: PathBuf::from("/b"),
2514 dependency_name: "b".to_string(),
2515 };
2516 let edge_b = CargoPathDependencyEdge {
2517 from: PathBuf::from("/a"),
2518 to: PathBuf::from("/c"),
2519 dependency_name: "c".to_string(),
2520 };
2521 let edge_c = CargoPathDependencyEdge {
2522 from: PathBuf::from("/b"),
2523 to: PathBuf::from("/c"),
2524 dependency_name: "c".to_string(),
2525 };
2526
2527 let mut edges = vec![edge_c.clone(), edge_a.clone(), edge_b.clone()];
2528 edges.sort();
2529 assert_eq!(edges, vec![edge_a, edge_b, edge_c]);
2530 }
2531
2532 #[cfg(unix)]
2535 #[test]
2536 fn metadata_error_then_fallback_success() {
2537 let fixture = TopologyFixture::new("meta-err-fallback");
2538 let scenario_root = fixture.canonical_root.join("meta_fallback");
2539 let app_root = scenario_root.join("app");
2540 let dep_root = scenario_root.join("dep");
2541
2542 write_lib_crate(&dep_root, "fb_dep", &[]);
2543 write_bin_crate(&app_root, "fb_app", &[("fb_dep", "../dep")]);
2544
2545 let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
2547 &app_root,
2548 &fixture.policy(),
2549 |_| {
2550 Err(CargoPathDependencyError::new(
2551 CargoPathDependencyErrorKind::MetadataInvocationFailure,
2552 "simulated cargo metadata failure",
2553 ))
2554 },
2555 )
2556 .expect("should recover via manifest fallback");
2557
2558 let app_root = app_root.canonicalize().expect("canonical app");
2559 let dep_root = dep_root.canonicalize().expect("canonical dep");
2560 assert_eq!(graph.root_packages, vec![app_root.clone()]);
2561 assert_eq!(graph.edges.len(), 1);
2562 assert_eq!(graph.edges[0].from, app_root);
2563 assert_eq!(graph.edges[0].to, dep_root);
2564 }
2565
2566 #[cfg(unix)]
2567 #[test]
2568 fn metadata_provider_with_synthetic_json() {
2569 let fixture = TopologyFixture::new("synthetic-meta");
2570 let scenario_root = fixture.canonical_root.join("synth_meta");
2571 let app_root = scenario_root.join("app");
2572 let dep_root = scenario_root.join("dep");
2573
2574 write_lib_crate(&dep_root, "synth_dep", &[]);
2575 write_bin_crate(&app_root, "synth_app", &[("synth_dep", "../dep")]);
2576
2577 let app_canonical = app_root.canonicalize().expect("canonical app");
2579 let dep_canonical = dep_root.canonicalize().expect("canonical dep");
2580 let app_manifest = app_canonical.join("Cargo.toml");
2581 let dep_manifest = dep_canonical.join("Cargo.toml");
2582
2583 let metadata_json = format!(
2584 r#"{{
2585 "packages": [
2586 {{
2587 "id": "synth_app 0.1.0 (path+file://{})",
2588 "name": "synth_app",
2589 "manifest_path": "{}",
2590 "dependencies": [
2591 {{"name": "synth_dep", "path": "{}"}}
2592 ]
2593 }},
2594 {{
2595 "id": "synth_dep 0.1.0 (path+file://{})",
2596 "name": "synth_dep",
2597 "manifest_path": "{}",
2598 "dependencies": []
2599 }}
2600 ],
2601 "workspace_members": [],
2602 "workspace_root": null,
2603 "resolve": {{
2604 "root": "synth_app 0.1.0 (path+file://{})"
2605 }}
2606 }}"#,
2607 app_canonical.display(),
2608 app_manifest.display(),
2609 dep_canonical.display(),
2610 dep_canonical.display(),
2611 dep_manifest.display(),
2612 app_canonical.display(),
2613 );
2614
2615 let json_clone = metadata_json.clone();
2616 let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
2617 &app_root,
2618 &fixture.policy(),
2619 move |_| Ok(json_clone.clone()),
2620 )
2621 .expect("synthetic metadata should resolve");
2622
2623 assert_eq!(graph.root_packages, vec![app_canonical.clone()]);
2624 assert_eq!(graph.edges.len(), 1);
2625 assert_eq!(graph.edges[0].dependency_name, "synth_dep");
2626 }
2627
2628 #[cfg(unix)]
2629 #[test]
2630 fn metadata_resolve_ignores_pure_dev_path_edges() {
2631 let fixture = TopologyFixture::new("synthetic-meta-dev-filter");
2632 let scenario_root = fixture.canonical_root.join("synth_meta_dev_filter");
2633 let app_root = scenario_root.join("app");
2634 let dep_root = scenario_root.join("dep");
2635 let dev_dep_root = scenario_root.join("dev_dep");
2636
2637 write_lib_crate(&dep_root, "runtime_dep", &[]);
2638 write_lib_crate(&dev_dep_root, "dev_only_dep", &[]);
2639 write_bin_crate(&app_root, "dev_filter_app", &[]);
2640
2641 let app_canonical = app_root.canonicalize().expect("canonical app");
2642 let dep_canonical = dep_root.canonicalize().expect("canonical dep");
2643 let dev_dep_canonical = dev_dep_root.canonicalize().expect("canonical dev dep");
2644 let app_manifest = app_canonical.join("Cargo.toml");
2645 let dep_manifest = dep_canonical.join("Cargo.toml");
2646 let dev_dep_manifest = dev_dep_canonical.join("Cargo.toml");
2647
2648 let metadata_json = format!(
2649 r#"{{
2650 "packages": [
2651 {{
2652 "id": "dev_filter_app 0.1.0 (path+file://{app_root})",
2653 "name": "dev_filter_app",
2654 "manifest_path": "{app_manifest}",
2655 "dependencies": [
2656 {{"name": "runtime_dep", "path": "{dep_root}"}},
2657 {{"name": "dev_only_dep", "path": "{dev_dep_root}"}}
2658 ]
2659 }},
2660 {{
2661 "id": "runtime_dep 0.1.0 (path+file://{dep_root})",
2662 "name": "runtime_dep",
2663 "manifest_path": "{dep_manifest}",
2664 "dependencies": []
2665 }},
2666 {{
2667 "id": "dev_only_dep 0.1.0 (path+file://{dev_dep_root})",
2668 "name": "dev_only_dep",
2669 "manifest_path": "{dev_dep_manifest}",
2670 "dependencies": []
2671 }}
2672 ],
2673 "workspace_members": [],
2674 "workspace_root": null,
2675 "resolve": {{
2676 "root": "dev_filter_app 0.1.0 (path+file://{app_root})",
2677 "nodes": [
2678 {{
2679 "id": "dev_filter_app 0.1.0 (path+file://{app_root})",
2680 "deps": [
2681 {{
2682 "name": "runtime_dep",
2683 "pkg": "runtime_dep 0.1.0 (path+file://{dep_root})",
2684 "dep_kinds": [{{"kind": null, "target": null}}]
2685 }},
2686 {{
2687 "name": "dev_only_dep",
2688 "pkg": "dev_only_dep 0.1.0 (path+file://{dev_dep_root})",
2689 "dep_kinds": [{{"kind": "dev", "target": null}}]
2690 }}
2691 ]
2692 }},
2693 {{
2694 "id": "runtime_dep 0.1.0 (path+file://{dep_root})",
2695 "deps": []
2696 }},
2697 {{
2698 "id": "dev_only_dep 0.1.0 (path+file://{dev_dep_root})",
2699 "deps": []
2700 }}
2701 ]
2702 }}
2703 }}"#,
2704 app_root = app_canonical.display(),
2705 app_manifest = app_manifest.display(),
2706 dep_root = dep_canonical.display(),
2707 dep_manifest = dep_manifest.display(),
2708 dev_dep_root = dev_dep_canonical.display(),
2709 dev_dep_manifest = dev_dep_manifest.display(),
2710 );
2711
2712 let json_clone = metadata_json.clone();
2713 let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
2714 &app_root,
2715 &fixture.policy(),
2716 move |_| Ok(json_clone.clone()),
2717 )
2718 .expect("synthetic metadata with dev-only edge should resolve");
2719
2720 assert_eq!(graph.root_packages, vec![app_canonical.clone()]);
2721 assert_eq!(
2722 graph.edges.len(),
2723 1,
2724 "pure dev-only path edges must be ignored"
2725 );
2726 assert_eq!(graph.edges[0].dependency_name, "runtime_dep");
2727 assert_eq!(graph.edges[0].from, app_canonical);
2728 assert_eq!(graph.edges[0].to, dep_canonical);
2729 assert!(
2730 graph
2731 .packages
2732 .iter()
2733 .all(|pkg| pkg.package_root != dev_dep_canonical),
2734 "dev-only dependency package should not be pulled into runtime closure"
2735 );
2736 }
2737
2738 #[cfg(unix)]
2739 #[test]
2740 fn metadata_resolve_skips_non_local_packages_and_keeps_local_edges() {
2741 let fixture = TopologyFixture::new("synthetic-meta-nonlocal");
2742 let scenario_root = fixture.canonical_root.join("synth_meta_nonlocal");
2743 let app_root = scenario_root.join("app");
2744 let dep_root = scenario_root.join("dep");
2745 let external_root = fixture.root.join("external_registry/serde");
2746
2747 write_lib_crate(&dep_root, "local_dep", &[]);
2748 write_bin_crate(&app_root, "meta_nonlocal_app", &[]);
2749 write_lib_crate(&external_root, "serde", &[]);
2750
2751 let app_canonical = app_root.canonicalize().expect("canonical app");
2752 let dep_canonical = dep_root.canonicalize().expect("canonical dep");
2753 let external_canonical = external_root
2754 .canonicalize()
2755 .expect("canonical external dep");
2756 let app_manifest = app_canonical.join("Cargo.toml");
2757 let dep_manifest = dep_canonical.join("Cargo.toml");
2758 let external_manifest = external_canonical.join("Cargo.toml");
2759
2760 let metadata_json = format!(
2761 r#"{{
2762 "packages": [
2763 {{
2764 "id": "meta_nonlocal_app 0.1.0 (path+file://{app_root})",
2765 "name": "meta_nonlocal_app",
2766 "manifest_path": "{app_manifest}",
2767 "dependencies": [
2768 {{"name": "local_dep", "path": "{dep_root}"}},
2769 {{"name": "serde", "path": null}}
2770 ]
2771 }},
2772 {{
2773 "id": "local_dep 0.1.0 (path+file://{dep_root})",
2774 "name": "local_dep",
2775 "manifest_path": "{dep_manifest}",
2776 "dependencies": []
2777 }},
2778 {{
2779 "id": "serde 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)",
2780 "name": "serde",
2781 "manifest_path": "{external_manifest}",
2782 "dependencies": []
2783 }}
2784 ],
2785 "workspace_members": [
2786 "meta_nonlocal_app 0.1.0 (path+file://{app_root})"
2787 ],
2788 "workspace_root": null,
2789 "resolve": {{
2790 "root": "meta_nonlocal_app 0.1.0 (path+file://{app_root})",
2791 "nodes": [
2792 {{
2793 "id": "meta_nonlocal_app 0.1.0 (path+file://{app_root})",
2794 "deps": [
2795 {{
2796 "name": "local_dep",
2797 "pkg": "local_dep 0.1.0 (path+file://{dep_root})",
2798 "dep_kinds": [{{"kind": null, "target": null}}]
2799 }},
2800 {{
2801 "name": "serde",
2802 "pkg": "serde 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)",
2803 "dep_kinds": [{{"kind": null, "target": null}}]
2804 }}
2805 ]
2806 }},
2807 {{
2808 "id": "local_dep 0.1.0 (path+file://{dep_root})",
2809 "deps": []
2810 }},
2811 {{
2812 "id": "serde 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)",
2813 "deps": []
2814 }}
2815 ]
2816 }}
2817 }}"#,
2818 app_root = app_canonical.display(),
2819 app_manifest = app_manifest.display(),
2820 dep_root = dep_canonical.display(),
2821 dep_manifest = dep_manifest.display(),
2822 external_manifest = external_manifest.display(),
2823 );
2824
2825 let json_clone = metadata_json.clone();
2826 let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
2827 &app_root,
2828 &fixture.policy(),
2829 move |_| Ok(json_clone.clone()),
2830 )
2831 .expect("synthetic metadata with non-local packages should still resolve");
2832
2833 assert_eq!(graph.root_packages, vec![app_canonical.clone()]);
2834 assert_eq!(
2835 graph.edges,
2836 vec![CargoPathDependencyEdge {
2837 from: app_canonical.clone(),
2838 to: dep_canonical.clone(),
2839 dependency_name: "local_dep".to_string(),
2840 }]
2841 );
2842 assert!(
2843 graph
2844 .packages
2845 .iter()
2846 .all(|package| package.package_root != external_canonical),
2847 "non-local registry packages must not be pulled into the sync closure"
2848 );
2849 }
2850
2851 #[cfg(unix)]
2852 #[test]
2853 fn manifest_fallback_ignores_pure_dev_path_edges() {
2854 let fixture = TopologyFixture::new("manifest-fallback-dev-filter");
2855 let scenario_root = fixture.canonical_root.join("manifest_fallback_dev_filter");
2856 let app_root = scenario_root.join("app");
2857 let dep_root = scenario_root.join("dep");
2858 let dev_dep_root = scenario_root.join("dev_dep");
2859
2860 write_lib_crate(&dep_root, "runtime_dep", &[]);
2861 write_lib_crate(&dev_dep_root, "dev_only_dep", &[]);
2862
2863 std::fs::create_dir_all(app_root.join("src")).expect("create app src");
2864 std::fs::write(
2865 app_root.join("Cargo.toml"),
2866 r#"[package]
2867name = "manifest_dev_filter_app"
2868version = "0.1.0"
2869edition = "2024"
2870
2871[dependencies]
2872runtime_dep = { path = "../dep" }
2873
2874[dev-dependencies]
2875dev_only_dep = { path = "../dev_dep" }
2876"#,
2877 )
2878 .expect("write app manifest");
2879 std::fs::write(app_root.join("src/main.rs"), "fn main() {}\n").expect("write app main");
2880
2881 let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
2882 &app_root,
2883 &fixture.policy(),
2884 |_| {
2885 Err(CargoPathDependencyError::new(
2886 CargoPathDependencyErrorKind::MetadataInvocationFailure,
2887 "force manifest fallback",
2888 ))
2889 },
2890 )
2891 .expect("manifest fallback should ignore pure dev-only path edges");
2892
2893 let app_canonical = app_root.canonicalize().expect("canonical app");
2894 let dep_canonical = dep_root.canonicalize().expect("canonical dep");
2895 let dev_dep_canonical = dev_dep_root.canonicalize().expect("canonical dev dep");
2896
2897 assert_eq!(graph.root_packages, vec![app_canonical.clone()]);
2898 assert_eq!(graph.edges.len(), 1);
2899 assert_eq!(graph.edges[0].dependency_name, "runtime_dep");
2900 assert_eq!(graph.edges[0].from, app_canonical);
2901 assert_eq!(graph.edges[0].to, dep_canonical);
2902 assert!(
2903 graph
2904 .packages
2905 .iter()
2906 .all(|pkg| pkg.package_root != dev_dep_canonical),
2907 "manifest fallback must not pull pure dev-only path deps into runtime closure"
2908 );
2909 }
2910
2911 #[cfg(unix)]
2912 #[test]
2913 fn manifest_fallback_resolves_workspace_shared_path_dependencies() {
2914 let fixture = TopologyFixture::new("manifest-workspace-shared");
2915 let scenario_root = fixture.canonical_root.join("manifest_workspace_shared");
2916 let workspace_root = scenario_root.join("workspace");
2917 let app_root = workspace_root.join("crates/app");
2918 let shared_root = scenario_root.join("shared/shared_dep");
2919
2920 write_lib_crate(&shared_root, "shared_dep", &[]);
2921 std::fs::create_dir_all(app_root.join("src")).expect("create app src");
2922 std::fs::write(
2923 app_root.join("Cargo.toml"),
2924 r#"[package]
2925name = "workspace_shared_app"
2926version = "0.1.0"
2927edition = "2024"
2928
2929[dependencies]
2930shared_dep = { workspace = true }
2931"#,
2932 )
2933 .expect("write app manifest");
2934 std::fs::write(app_root.join("src/main.rs"), "fn main() {}\n").expect("write app main");
2935 std::fs::create_dir_all(&workspace_root).expect("create workspace root");
2936 std::fs::write(
2937 workspace_root.join("Cargo.toml"),
2938 r#"[workspace]
2939members = ["crates/app"]
2940resolver = "3"
2941
2942[workspace.dependencies]
2943shared_dep = { path = "../shared/shared_dep" }
2944"#,
2945 )
2946 .expect("write workspace manifest");
2947
2948 let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
2949 &workspace_root,
2950 &fixture.policy(),
2951 |_| {
2952 Err(CargoPathDependencyError::new(
2953 CargoPathDependencyErrorKind::MetadataInvocationFailure,
2954 "force manifest fallback",
2955 ))
2956 },
2957 )
2958 .expect("manifest fallback should resolve workspace-shared path dependencies");
2959
2960 let app_canonical = app_root.canonicalize().expect("canonical app");
2961 let shared_canonical = shared_root.canonicalize().expect("canonical shared dep");
2962 assert_eq!(graph.root_packages, vec![app_canonical.clone()]);
2963 assert_eq!(
2964 graph.edges,
2965 vec![CargoPathDependencyEdge {
2966 from: app_canonical,
2967 to: shared_canonical,
2968 dependency_name: "shared_dep".to_string(),
2969 }]
2970 );
2971 }
2972
2973 #[cfg(unix)]
2974 #[test]
2975 fn manifest_fallback_resolves_patch_path_dependencies() {
2976 let fixture = TopologyFixture::new("manifest-patch-shared");
2977 let scenario_root = fixture.canonical_root.join("manifest_patch_shared");
2978 let workspace_root = scenario_root.join("workspace");
2979 let app_root = workspace_root.join("app");
2980 let patched_root = scenario_root.join("patched/patched_dep");
2981
2982 write_lib_crate(&patched_root, "patched_dep", &[]);
2983 std::fs::create_dir_all(app_root.join("src")).expect("create app src");
2984 std::fs::write(
2985 app_root.join("Cargo.toml"),
2986 r#"[package]
2987name = "patched_app"
2988version = "0.1.0"
2989edition = "2024"
2990
2991[dependencies]
2992patched_dep = "0.1"
2993"#,
2994 )
2995 .expect("write app manifest");
2996 std::fs::write(app_root.join("src/main.rs"), "fn main() {}\n").expect("write app main");
2997 std::fs::create_dir_all(&workspace_root).expect("create workspace root");
2998 std::fs::write(
2999 workspace_root.join("Cargo.toml"),
3000 r#"[workspace]
3001members = ["app"]
3002resolver = "3"
3003
3004[patch.crates-io]
3005patched_dep = { path = "../patched/patched_dep" }
3006"#,
3007 )
3008 .expect("write workspace manifest");
3009
3010 let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
3011 &workspace_root,
3012 &fixture.policy(),
3013 |_| {
3014 Err(CargoPathDependencyError::new(
3015 CargoPathDependencyErrorKind::MetadataInvocationFailure,
3016 "force manifest fallback",
3017 ))
3018 },
3019 )
3020 .expect("manifest fallback should resolve patch path dependencies");
3021
3022 let app_canonical = app_root.canonicalize().expect("canonical app");
3023 let patched_canonical = patched_root.canonicalize().expect("canonical patched dep");
3024 assert_eq!(graph.root_packages, vec![app_canonical.clone()]);
3025 assert_eq!(
3026 graph.edges,
3027 vec![CargoPathDependencyEdge {
3028 from: app_canonical,
3029 to: patched_canonical,
3030 dependency_name: "patched_dep".to_string(),
3031 }]
3032 );
3033 }
3034
3035 #[cfg(unix)]
3036 #[test]
3037 fn standalone_crate_no_dependencies() {
3038 let fixture = TopologyFixture::new("standalone");
3039 let crate_root = fixture.canonical_root.join("standalone_crate");
3040
3041 write_bin_crate(&crate_root, "standalone", &[]);
3042
3043 let graph = resolve_cargo_path_dependency_graph_with_policy_and_provider(
3044 &crate_root,
3045 &fixture.policy(),
3046 |_| Ok("{not-json".to_string()), )
3048 .expect("standalone crate should resolve");
3049
3050 assert_eq!(graph.packages.len(), 1);
3051 assert_eq!(graph.packages[0].package_name, "standalone");
3052 assert!(graph.edges.is_empty());
3053 }
3054
3055 #[test]
3058 fn partial_graph_deduplicates_edges() {
3059 let mut partial = PartialGraph::default();
3060 partial.add_edge(PathBuf::from("/a"), PathBuf::from("/b"), "b".to_string());
3061 partial.add_edge(PathBuf::from("/a"), PathBuf::from("/b"), "b".to_string());
3062
3063 let edges = partial.adjacency.get(Path::new("/a")).unwrap();
3064 assert_eq!(
3065 edges.len(),
3066 1,
3067 "duplicate edge should be deduplicated by BTreeSet"
3068 );
3069 }
3070
3071 #[test]
3072 fn partial_graph_add_package_updates_name_from_default() {
3073 let mut partial = PartialGraph::default();
3074 let root = PathBuf::from("/project/my_crate");
3075
3076 partial.add_package(
3078 root.clone(),
3079 root.join("Cargo.toml"),
3080 default_package_name(&root),
3081 false,
3082 );
3083 assert_eq!(
3084 partial.packages.get(&root).unwrap().package_name,
3085 "my_crate"
3086 );
3087
3088 partial.add_package(
3090 root.clone(),
3091 root.join("Cargo.toml"),
3092 "real_name".to_string(),
3093 false,
3094 );
3095 assert_eq!(
3096 partial.packages.get(&root).unwrap().package_name,
3097 "real_name"
3098 );
3099 }
3100
3101 #[test]
3102 fn partial_graph_add_package_or_promotes_workspace_member() {
3103 let mut partial = PartialGraph::default();
3104 let root = PathBuf::from("/project");
3105
3106 partial.add_package(
3107 root.clone(),
3108 root.join("Cargo.toml"),
3109 "pkg".to_string(),
3110 false,
3111 );
3112 assert!(!partial.packages.get(&root).unwrap().workspace_member);
3113
3114 partial.add_package(
3116 root.clone(),
3117 root.join("Cargo.toml"),
3118 "pkg".to_string(),
3119 true,
3120 );
3121 assert!(partial.packages.get(&root).unwrap().workspace_member);
3122 }
3123
3124 #[cfg(unix)]
3132 #[test]
3133 fn invoke_cargo_metadata_handles_output_larger_than_pipe_buffer() {
3134 let fixture = TopologyFixture::new("metadata-large");
3135 let project_root = fixture.canonical_root.join("metadata_pipe");
3136 fs::create_dir_all(project_root.join("src")).expect("create src");
3137
3138 let mut deps = String::new();
3142 for name in [
3143 "serde",
3144 "serde_json",
3145 "anyhow",
3146 "thiserror",
3147 "tokio",
3148 "futures",
3149 "log",
3150 "tracing",
3151 "regex",
3152 "chrono",
3153 "uuid",
3154 "rand",
3155 "base64",
3156 "hex",
3157 "url",
3158 "bytes",
3159 "clap",
3160 "rusqlite",
3161 "blake3",
3162 "sha2",
3163 "reqwest",
3164 "tempfile",
3165 "directories",
3166 "dirs",
3167 "shellexpand",
3168 "shell-escape",
3169 "which",
3170 "openssh",
3171 "schemars",
3172 "indexmap",
3173 "lazy_static",
3174 "once_cell",
3175 "parking_lot",
3176 "toml",
3177 "shell-words",
3178 "globset",
3179 "walkdir",
3180 "ignore",
3181 "crossbeam-channel",
3182 "rayon",
3183 "memchr",
3184 "smallvec",
3185 "ahash",
3186 "fnv",
3187 "itertools",
3188 "num_cpus",
3189 "humantime",
3190 "humansize",
3191 "ratatui",
3192 "crossterm",
3193 ] {
3194 deps.push_str(&format!("{name} = \"*\"\n"));
3195 }
3196
3197 fs::write(
3198 project_root.join("Cargo.toml"),
3199 format!(
3200 "[package]\nname = \"metadata_pipe\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n{deps}",
3201 ),
3202 )
3203 .expect("write manifest");
3204 fs::write(project_root.join("src/lib.rs"), "").expect("write lib.rs");
3205
3206 let manifest_path = project_root.join("Cargo.toml");
3207 let start = std::time::Instant::now();
3208 match invoke_cargo_metadata(&manifest_path) {
3209 Ok(json) => {
3210 let elapsed = start.elapsed();
3211 assert!(
3215 elapsed < CARGO_METADATA_TIMEOUT,
3216 "metadata took {elapsed:?}, near the {CARGO_METADATA_TIMEOUT:?} timeout"
3217 );
3218 assert!(
3219 json.len() > 64 * 1024,
3220 "test fixture too small to exercise the pipe-buffer path: {} bytes",
3221 json.len()
3222 );
3223 assert!(
3224 json.starts_with('{'),
3225 "metadata stdout should be JSON; got: {:.120}",
3226 json
3227 );
3228 }
3229 Err(e) => {
3230 let detail = e.detail().to_lowercase();
3234 let offline = detail.contains("network")
3235 || detail.contains("dns")
3236 || detail.contains("registry")
3237 || detail.contains("connection")
3238 || detail.contains("not found")
3239 || detail.contains("offline")
3240 || detail.contains("could not")
3241 || detail.contains("failed to fetch")
3242 || detail.contains("no such file");
3243 assert!(
3244 offline,
3245 "invoke_cargo_metadata failed for non-network reason: {e}"
3246 );
3247 }
3248 }
3249 }
3250}