1use alloc::{
2 borrow::ToOwned,
3 collections::{BTreeMap, BTreeSet},
4 format,
5 string::{String, ToString},
6 sync::Arc,
7 vec::Vec,
8};
9use std::{
10 path::{Path, PathBuf},
11 process::Command,
12};
13
14use miden_assembly_syntax::{
15 Report,
16 debuginfo::{DefaultSourceManager, SourceManager, Uri},
17};
18use miden_core::utils::{DisplayHex, hash_string_to_word};
19use miden_mast_package::Package as MastPackage;
20use miden_package_registry::{
21 InMemoryPackageRegistry, PackageId, PackageRecord, PackageRegistry, PackageResolver, Version,
22};
23
24use crate::{
25 Dependency, DependencyVersionScheme, GitRevision, Linkage, Package, SemVer, VersionRequirement,
26};
27
28#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct ProjectDependencyGraph {
44 root: PackageId,
45 nodes: BTreeMap<PackageId, ProjectDependencyNode>,
46}
47
48impl ProjectDependencyGraph {
49 pub fn root(&self) -> &PackageId {
51 &self.root
52 }
53
54 pub fn nodes(&self) -> &BTreeMap<PackageId, ProjectDependencyNode> {
56 &self.nodes
57 }
58
59 pub fn get(&self, package: &PackageId) -> Option<&ProjectDependencyNode> {
61 self.nodes.get(package)
62 }
63
64 fn insert_node(&mut self, node: ProjectDependencyNode) -> Result<bool, Report> {
65 match self.nodes.get(&node.name) {
66 Some(existing) if existing.same_identity(&node) => Ok(false),
67 Some(existing) => Err(Report::msg(format!(
68 "dependency conflict for '{}': existing node {:?} conflicts with {:?}",
69 node.name, existing.provenance, node.provenance
70 ))),
71 None => {
72 self.nodes.insert(node.name.clone(), node);
73 Ok(true)
74 },
75 }
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct ProjectDependencyNode {
82 pub name: PackageId,
84 pub version: SemVer,
86 pub dependencies: Vec<ProjectDependencyEdge>,
88 pub provenance: ProjectDependencyNodeProvenance,
90}
91
92impl ProjectDependencyNode {
93 fn same_identity(&self, other: &Self) -> bool {
95 self.name == other.name
96 && self.version == other.version
97 && self.provenance == other.provenance
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct ProjectDependencyEdge {
104 pub dependency: PackageId,
106 pub linkage: Linkage,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
112pub enum ProjectDependencyNodeProvenance {
113 Source(ProjectSource),
115 Registry {
117 requirement: VersionRequirement,
119 selected: Version,
121 },
122 Preassembled {
124 path: PathBuf,
126 selected: Version,
128 },
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum ProjectSource {
134 Virtual {
135 origin: ProjectSourceOrigin,
136 },
137 Real {
138 origin: ProjectSourceOrigin,
140 manifest_path: PathBuf,
142 project_root: PathBuf,
144 workspace_root: Option<PathBuf>,
146 library_path: Option<PathBuf>,
151 },
152}
153
154#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum ProjectSourceOrigin {
157 Root,
159 Path,
161 Git {
163 repo: Uri,
165 revision: GitRevision,
167 checkout_path: PathBuf,
169 resolved_revision: Arc<str>,
173 },
174}
175
176struct CollectedDependencyGraph {
177 root: PackageId,
178 nodes: BTreeMap<PackageId, CollectedDependencyNode>,
179 registry_requirements: BTreeMap<PackageId, VersionRequirement>,
180}
181
182impl CollectedDependencyGraph {
183 fn insert_node(&mut self, node: CollectedDependencyNode) -> Result<bool, Report> {
184 match self.nodes.get(node.name()) {
185 Some(existing) if existing.same_identity(&node) => Ok(false),
186 Some(existing) => Err(Report::msg(format!(
187 "dependency conflict for '{}': existing node {:?} conflicts with {:?}",
188 node.name(),
189 existing.provenance(),
190 node.provenance()
191 ))),
192 None => {
193 self.nodes.insert(node.name().clone(), node);
194 Ok(true)
195 },
196 }
197 }
198
199 fn set_dependencies(
200 &mut self,
201 package: &PackageId,
202 dependencies: Vec<ProjectDependencyEdge>,
203 solver_dependencies: BTreeMap<PackageId, VersionRequirement>,
204 ) -> Result<(), Report> {
205 let node = self
206 .nodes
207 .get_mut(package)
208 .ok_or_else(|| Report::msg(format!("missing dependency node '{package}'")))?;
209 node.graph_node.dependencies = dependencies;
210 node.solver_dependencies = solver_dependencies;
211 Ok(())
212 }
213
214 fn record_registry_requirement(&mut self, package: PackageId, requirement: VersionRequirement) {
215 self.registry_requirements.entry(package).or_insert(requirement);
216 }
217
218 fn root_version(&self) -> Result<SemVer, Report> {
219 self.nodes
220 .get(&self.root)
221 .map(|node| node.graph_node.version.clone())
222 .ok_or_else(|| Report::msg(format!("missing dependency node '{}'", self.root)))
223 }
224
225 fn local_packages(&self) -> BTreeSet<PackageId> {
226 self.nodes.keys().cloned().collect()
227 }
228}
229
230struct CollectedDependencyNode {
231 graph_node: ProjectDependencyNode,
232 solver_dependencies: BTreeMap<PackageId, VersionRequirement>,
233}
234
235impl CollectedDependencyNode {
236 fn name(&self) -> &PackageId {
237 &self.graph_node.name
238 }
239
240 fn provenance(&self) -> &ProjectDependencyNodeProvenance {
241 &self.graph_node.provenance
242 }
243
244 fn same_identity(&self, other: &Self) -> bool {
245 self.graph_node.same_identity(&other.graph_node)
246 }
247
248 fn selected_version(&self) -> Version {
249 match &self.graph_node.provenance {
250 ProjectDependencyNodeProvenance::Source(_) => {
251 Version::from(self.graph_node.version.clone())
252 },
253 ProjectDependencyNodeProvenance::Preassembled { selected, .. } => selected.clone(),
254 ProjectDependencyNodeProvenance::Registry { .. } => {
255 panic!("collected nodes do not store registry provenance")
256 },
257 }
258 }
259}
260
261pub struct ProjectDependencyGraphBuilder<'a, R: PackageRegistry + ?Sized> {
263 registry: &'a R,
264 source_manager: Arc<dyn SourceManager>,
265 git_cache_root: PathBuf,
266}
267
268impl<'a, R: PackageRegistry + ?Sized> ProjectDependencyGraphBuilder<'a, R> {
269 pub fn new(registry: &'a R) -> Self {
272 let git_cache_root = std::env::var_os("MIDENUP_HOME")
273 .map(PathBuf::from)
274 .map(|path| path.join("git").join("checkouts"))
275 .unwrap_or_else(|| std::env::temp_dir().join("midenup").join("git").join("checkouts"));
276 Self {
277 registry,
278 source_manager: Arc::new(DefaultSourceManager::default()),
279 git_cache_root,
280 }
281 }
282
283 pub fn with_source_manager(mut self, source_manager: Arc<dyn SourceManager>) -> Self {
285 self.source_manager = source_manager;
286 self
287 }
288
289 pub fn with_git_cache_root(mut self, git_cache_root: impl AsRef<Path>) -> Self {
296 self.git_cache_root = git_cache_root.as_ref().to_path_buf();
297 self
298 }
299
300 pub fn build_from_path(
303 &self,
304 manifest_path: impl AsRef<Path>,
305 ) -> Result<ProjectDependencyGraph, Report> {
306 let loaded = self.load_package_from_manifest(manifest_path.as_ref())?;
307 self.build_from_loaded_package(loaded)
308 }
309
310 pub fn build(&self, package: Arc<Package>) -> Result<ProjectDependencyGraph, Report> {
312 let loaded = self.loaded_package_from_arc(package, None)?;
313 self.build_from_loaded_package(loaded)
314 }
315
316 fn build_from_loaded_package(
317 &self,
318 loaded: LoadedSourcePackage,
319 ) -> Result<ProjectDependencyGraph, Report> {
320 let graph = self.collect_dependency_graph(loaded)?;
321 let selected = self.solve_dependency_graph(&graph)?;
322 self.materialize_dependency_graph(graph, &selected)
323 }
324
325 fn collect_dependency_graph(
326 &self,
327 loaded: LoadedSourcePackage,
328 ) -> Result<CollectedDependencyGraph, Report> {
329 let root = loaded.package.name().into_inner();
330 let mut graph = CollectedDependencyGraph {
331 root: root.clone(),
332 nodes: BTreeMap::new(),
333 registry_requirements: BTreeMap::new(),
334 };
335 let mut visited = BTreeSet::new();
336 self.collect_source_package(
337 &mut graph,
338 &mut visited,
339 loaded,
340 ProjectSourceOrigin::Root,
341 true,
342 )?;
343 Ok(graph)
344 }
345
346 fn collect_source_package(
347 &self,
348 graph: &mut CollectedDependencyGraph,
349 visited: &mut BTreeSet<PackageId>,
350 package: LoadedSourcePackage,
351 origin: ProjectSourceOrigin,
352 allow_missing_library: bool,
353 ) -> Result<PackageId, Report> {
354 let package_id = package.package.name().into_inner();
355 let node = CollectedDependencyNode {
356 graph_node: ProjectDependencyNode {
357 dependencies: Vec::new(),
358 name: package_id.clone(),
359 provenance: ProjectDependencyNodeProvenance::Source(
360 match package.manifest_path.as_ref() {
361 Some(manifest_path) => ProjectSource::Real {
362 library_path: self.library_path(
363 &package.package,
364 manifest_path,
365 allow_missing_library,
366 )?,
367 manifest_path: manifest_path.to_path_buf(),
368 origin,
369 project_root: package.project_root.clone().unwrap(),
370 workspace_root: package.workspace_root.clone(),
371 },
372 None => ProjectSource::Virtual { origin },
373 },
374 ),
375 version: package.package.version().into_inner().clone(),
376 },
377 solver_dependencies: BTreeMap::new(),
378 };
379
380 let is_new = graph.insert_node(node)?;
381 if !is_new || !visited.insert(package_id.clone()) {
382 return Ok(package_id);
383 }
384
385 let mut edges = Vec::new();
386 let mut solver_dependencies = BTreeMap::new();
387 for dependency in package.package.dependencies() {
388 let resolved = self.resolve_dependency(dependency, &package)?;
389 let dependency_name = resolved.name();
390 edges.push(ProjectDependencyEdge {
391 dependency: dependency_name.clone(),
392 linkage: dependency.linkage(),
393 });
394 solver_dependencies.insert(dependency_name, resolved.solver_requirement());
395
396 match resolved {
397 ResolvedDependencyNode::Source { package, origin } => {
398 self.collect_source_package(graph, visited, package, origin, false)?;
399 },
400 ResolvedDependencyNode::Local(node) => {
401 graph.insert_node(node)?;
402 },
403 ResolvedDependencyNode::Registry { package, requirement } => {
404 graph.record_registry_requirement(package, requirement);
405 },
406 }
407 }
408
409 graph.set_dependencies(&package_id, edges, solver_dependencies)?;
410 Ok(package_id)
411 }
412
413 fn solve_dependency_graph(
414 &self,
415 graph: &CollectedDependencyGraph,
416 ) -> Result<BTreeMap<PackageId, Version>, Report> {
417 let registry = self.build_resolution_registry(graph)?;
418 let selected =
419 PackageResolver::for_package(graph.root.clone(), graph.root_version()?, ®istry)
420 .resolve()
421 .map_err(|error| Report::msg(error.to_string()))?;
422 Ok(selected.into_iter().collect())
423 }
424
425 fn build_resolution_registry(
426 &self,
427 graph: &CollectedDependencyGraph,
428 ) -> Result<InMemoryPackageRegistry, Report> {
429 let mut registry = InMemoryPackageRegistry::default();
430 let local_packages = graph.local_packages();
431
432 for node in graph.nodes.values() {
433 let record = PackageRecord::new(
434 node.selected_version(),
435 node.solver_dependencies
436 .iter()
437 .map(|(package, requirement)| (package.clone(), requirement.clone())),
438 );
439 registry
440 .insert_record(node.name().clone(), record)
441 .map_err(|error| Report::msg(error.to_string()))?;
442 }
443
444 let mut pending = BTreeSet::new();
445 for node in graph.nodes.values() {
446 for dependency in node.solver_dependencies.keys() {
447 if !local_packages.contains(dependency) {
448 pending.insert(dependency.clone());
449 }
450 }
451 }
452
453 self.populate_resolution_registry(&mut registry, &local_packages, pending)?;
454 Ok(registry)
455 }
456
457 fn populate_resolution_registry(
458 &self,
459 registry: &mut InMemoryPackageRegistry,
460 local_packages: &BTreeSet<PackageId>,
461 mut pending: BTreeSet<PackageId>,
462 ) -> Result<(), Report> {
463 let mut copied = BTreeSet::new();
464
465 while let Some(package) = pending.pop_first() {
466 if local_packages.contains(&package) {
467 return Err(Report::msg(format!(
468 "dependency conflict for '{package}': local source or preassembled dependency conflicts with a registry dependency"
469 )));
470 }
471
472 if !copied.insert(package.clone()) {
473 continue;
474 }
475
476 let Some(versions) = self.registry.available_versions(&package) else {
477 continue;
478 };
479
480 for record in versions.values() {
481 registry
482 .insert_record(package.clone(), record.clone())
483 .map_err(|error| Report::msg(error.to_string()))?;
484
485 for dependency in record.dependencies().keys() {
486 if local_packages.contains(dependency) {
487 return Err(Report::msg(format!(
488 "dependency conflict for '{dependency}': local source or preassembled dependency conflicts with a registry dependency"
489 )));
490 }
491 if !copied.contains(dependency) {
492 pending.insert(dependency.clone());
493 }
494 }
495 }
496 }
497
498 Ok(())
499 }
500
501 fn materialize_dependency_graph(
502 &self,
503 collected: CollectedDependencyGraph,
504 selected: &BTreeMap<PackageId, Version>,
505 ) -> Result<ProjectDependencyGraph, Report> {
506 let CollectedDependencyGraph { root, nodes, registry_requirements } = collected;
507 let local_packages = nodes.keys().cloned().collect::<BTreeSet<_>>();
508 let mut graph = ProjectDependencyGraph {
509 root: root.clone(),
510 nodes: BTreeMap::new(),
511 };
512
513 let direct_registry_dependencies = nodes
514 .values()
515 .flat_map(|node| {
516 node.graph_node.dependencies.iter().map(|edge| edge.dependency.clone())
517 })
518 .filter(|package| !local_packages.contains(package))
519 .collect::<BTreeSet<_>>();
520
521 for node in nodes.into_values() {
522 graph.insert_node(node.graph_node)?;
523 }
524
525 for package in direct_registry_dependencies {
526 let selected_version = selected.get(&package).ok_or_else(|| {
527 Report::msg(format!(
528 "dependency resolution did not select a version for direct dependency '{package}'"
529 ))
530 })?;
531 let record = self.registry.get_by_version(&package, selected_version).ok_or_else(|| {
532 Report::msg(format!(
533 "resolved registry dependency '{package}@{selected_version}' is not available"
534 ))
535 })?;
536 let requirement = registry_requirements
537 .get(&package)
538 .cloned()
539 .unwrap_or_else(|| VersionRequirement::from(record.version().clone()));
540 graph.insert_node(ProjectDependencyNode {
541 dependencies: Vec::new(),
542 name: package,
543 provenance: ProjectDependencyNodeProvenance::Registry {
544 requirement,
545 selected: record.version().clone(),
546 },
547 version: record.semantic_version().clone(),
548 })?;
549 }
550
551 Ok(graph)
552 }
553
554 fn resolve_dependency(
555 &self,
556 dependency: &Dependency,
557 parent: &LoadedSourcePackage,
558 ) -> Result<ResolvedDependencyNode, Report> {
559 match dependency.scheme() {
560 DependencyVersionScheme::Registry(requirement) => {
561 Ok(ResolvedDependencyNode::Registry {
562 package: PackageId::from(dependency.name().clone()),
563 requirement: requirement.clone(),
564 })
565 },
566 DependencyVersionScheme::Workspace { member, .. } => {
567 let workspace_root = parent.workspace_root.as_ref().ok_or_else(|| {
568 Report::msg(format!(
569 "workspace dependency '{}' cannot be resolved outside of a workspace",
570 dependency.name()
571 ))
572 })?;
573 let path = crate::absolutize_path(Path::new(member.path()), workspace_root)
574 .map_err(|error| Report::msg(error.to_string()))?;
575 let package = self.load_dependency_source(&path, dependency.name().as_ref())?;
576 self.validate_source_dependency(dependency, &package.package)?;
577 Ok(ResolvedDependencyNode::Source {
578 origin: ProjectSourceOrigin::Path,
579 package,
580 })
581 },
582 DependencyVersionScheme::WorkspacePath { path, version } => {
583 let workspace_root = parent.workspace_root.as_ref().ok_or_else(|| {
584 Report::msg(format!(
585 "workspace dependency '{}' cannot be resolved outside of a workspace",
586 dependency.name()
587 ))
588 })?;
589 let resolved_path = crate::absolutize_path(Path::new(path.path()), workspace_root)
590 .map_err(|error| Report::msg(error.to_string()))?;
591 if resolved_path.extension().is_some_and(|extension| extension == "masp") {
592 let node = self.load_preassembled_dependency(
593 &resolved_path,
594 dependency.name().as_ref(),
595 version.as_ref(),
596 )?;
597 Ok(ResolvedDependencyNode::Local(node))
598 } else {
599 let package =
600 self.load_dependency_source(&resolved_path, dependency.name().as_ref())?;
601 if let Some(requirement) = version.as_ref() {
602 self.ensure_version_satisfies(
603 dependency.name(),
604 requirement,
605 Version::from(package.package.version().into_inner().clone()),
606 )?;
607 }
608 Ok(ResolvedDependencyNode::Source {
609 origin: ProjectSourceOrigin::Path,
610 package,
611 })
612 }
613 },
614 DependencyVersionScheme::Path { path, version } => {
615 let Some(parent_manifest_path) = parent.manifest_path.as_ref() else {
616 return Err(Report::msg(format!(
617 "package '{}' is missing a manifest path",
618 parent.package.name().inner()
619 )));
620 };
621 let resolved_path = self.resolve_dependency_path(parent_manifest_path, path)?;
622 if resolved_path.extension().is_some_and(|extension| extension == "masp") {
623 let node = self.load_preassembled_dependency(
624 &resolved_path,
625 dependency.name().as_ref(),
626 version.as_ref(),
627 )?;
628 Ok(ResolvedDependencyNode::Local(node))
629 } else {
630 let package =
631 self.load_dependency_source(&resolved_path, dependency.name().as_ref())?;
632 if let Some(requirement) = version.as_ref() {
633 self.ensure_version_satisfies(
634 dependency.name(),
635 requirement,
636 Version::from(package.package.version().into_inner().clone()),
637 )?;
638 }
639 Ok(ResolvedDependencyNode::Source {
640 origin: ProjectSourceOrigin::Path,
641 package,
642 })
643 }
644 },
645 DependencyVersionScheme::Git { repo, revision, version } => {
646 let checkout = self.checkout_git_dependency(repo.inner(), revision)?;
647 let package = self
648 .load_dependency_source(&checkout.manifest_path, dependency.name().as_ref())?;
649 self.ensure_dependency_name(
650 dependency.name(),
651 package.package.name().into_inner().as_ref(),
652 Some(&checkout.manifest_path),
653 )?;
654 if let Some(requirement) = version.as_ref() {
655 self.ensure_version_req_matches(
656 dependency.name(),
657 requirement.inner(),
658 package.package.version().into_inner(),
659 )?;
660 }
661 Ok(ResolvedDependencyNode::Source {
662 origin: ProjectSourceOrigin::Git {
663 checkout_path: checkout.checkout_path,
664 repo: repo.inner().clone(),
665 resolved_revision: checkout.resolved_revision,
666 revision: revision.inner().clone(),
667 },
668 package,
669 })
670 },
671 }
672 }
673
674 fn load_dependency_source(
675 &self,
676 path: &Path,
677 expected_name: &str,
678 ) -> Result<LoadedSourcePackage, Report> {
679 let loaded = self.load_project_reference(path, expected_name)?;
680 self.ensure_dependency_name(
681 expected_name,
682 loaded.package.name().into_inner().as_ref(),
683 loaded.manifest_path.as_deref(),
684 )?;
685 Ok(loaded)
686 }
687
688 fn load_project_reference(
689 &self,
690 path: &Path,
691 expected_name: &str,
692 ) -> Result<LoadedSourcePackage, Report> {
693 let project =
694 crate::Project::load_project_reference(expected_name, path, &self.source_manager)?;
695
696 match project {
697 crate::Project::Package(package) => self.loaded_package_from_arc(package, None),
698 crate::Project::WorkspacePackage { package, workspace } => {
699 let workspace_root = workspace.workspace_root().map(|path| path.to_path_buf());
700 self.loaded_package_from_arc(package, workspace_root)
701 },
702 }
703 }
704
705 fn load_package_from_manifest(
706 &self,
707 manifest_path: &Path,
708 ) -> Result<LoadedSourcePackage, Report> {
709 let project = crate::Project::load(manifest_path, &self.source_manager)?;
710
711 match project {
712 crate::Project::Package(package) => self.loaded_package_from_arc(package, None),
713 crate::Project::WorkspacePackage { package, workspace } => {
714 let workspace_root = workspace.workspace_root().map(|path| path.to_path_buf());
715 self.loaded_package_from_arc(package, workspace_root)
716 },
717 }
718 }
719
720 fn loaded_package_from_arc(
721 &self,
722 package: Arc<Package>,
723 workspace_root: Option<PathBuf>,
724 ) -> Result<LoadedSourcePackage, Report> {
725 let manifest_path = package.manifest_path().map(|path| path.to_path_buf());
726 let project_root = match manifest_path.as_ref() {
727 Some(manifest_path) => Some(
728 manifest_path
729 .parent()
730 .ok_or_else(|| {
731 Report::msg(format!(
732 "manifest '{}' has no parent directory",
733 manifest_path.display()
734 ))
735 })?
736 .to_path_buf(),
737 ),
738 None => None,
739 };
740
741 Ok(LoadedSourcePackage {
742 manifest_path,
743 package,
744 project_root,
745 workspace_root,
746 })
747 }
748
749 fn load_preassembled_dependency(
750 &self,
751 path: &Path,
752 expected_name: &str,
753 requirement: Option<&VersionRequirement>,
754 ) -> Result<CollectedDependencyNode, Report> {
755 use miden_core::serde::Deserializable;
756
757 let path = path.canonicalize().map_err(|error| Report::msg(error.to_string()))?;
758 let bytes = std::fs::read(&path).map_err(|error| Report::msg(error.to_string()))?;
759 let package =
760 MastPackage::read_from_bytes(&bytes).map_err(|error| Report::msg(error.to_string()))?;
761 self.ensure_dependency_name(expected_name, &package.name, Some(&path))?;
762 let semver = package.version.clone();
763 let selected = Version::new(semver, package.digest());
764 if let Some(requirement) = requirement {
765 self.ensure_version_satisfies(expected_name, requirement, selected.clone())?;
766 }
767
768 Ok(CollectedDependencyNode {
769 graph_node: ProjectDependencyNode {
770 dependencies: Vec::new(),
771 name: PackageId::from(expected_name),
772 provenance: ProjectDependencyNodeProvenance::Preassembled {
773 path,
774 selected: selected.clone(),
775 },
776 version: selected.version.clone(),
777 },
778 solver_dependencies: BTreeMap::new(),
779 })
780 }
781
782 fn resolve_dependency_path(&self, manifest_path: &Path, uri: &Uri) -> Result<PathBuf, Report> {
783 if let Some(scheme) = uri.scheme()
784 && scheme != "file"
785 {
786 return Err(Report::msg(format!(
787 "unsupported path dependency scheme '{scheme}' in '{}'",
788 manifest_path.display()
789 )));
790 }
791 let base = manifest_path.parent().ok_or_else(|| {
792 Report::msg(format!("manifest '{}' has no parent directory", manifest_path.display()))
793 })?;
794 crate::absolutize_path(Path::new(uri.path()), base)
795 .map_err(|error| Report::msg(error.to_string()))
796 }
797
798 fn library_path(
799 &self,
800 package: &Package,
801 manifest_path: &Path,
802 allow_missing: bool,
803 ) -> Result<Option<PathBuf>, Report> {
804 let target = match package.library_target() {
805 Some(target) => target,
806 None if allow_missing => return Ok(None),
807 None => {
808 return Err(Report::msg(format!(
809 "dependency '{}' must define a library target",
810 package.name().inner()
811 )));
812 },
813 };
814
815 Ok(target.path.as_ref().map(|path| {
816 manifest_path.parent().expect("manifest path has a parent").join(path.path())
817 }))
818 }
819
820 fn ensure_dependency_name(
821 &self,
822 expected_name: &str,
823 actual_name: &str,
824 location: Option<&Path>,
825 ) -> Result<(), Report> {
826 if expected_name == actual_name {
827 Ok(())
828 } else if let Some(location) = location {
829 Err(Report::msg(format!(
830 "dependency '{}' resolved to package '{}' at '{}'",
831 expected_name,
832 actual_name,
833 location.display()
834 )))
835 } else {
836 Err(Report::msg(format!(
837 "dependency '{}' resolved to package '{}'",
838 expected_name, actual_name,
839 )))
840 }
841 }
842
843 fn ensure_version_satisfies(
844 &self,
845 dependency_name: impl AsRef<str>,
846 requirement: &VersionRequirement,
847 actual: Version,
848 ) -> Result<(), Report> {
849 if actual.satisfies(requirement) {
850 Ok(())
851 } else {
852 Err(Report::msg(format!(
853 "dependency '{}' requires '{}', but resolved version was '{}'",
854 dependency_name.as_ref(),
855 requirement,
856 actual
857 )))
858 }
859 }
860
861 fn ensure_version_req_matches(
862 &self,
863 dependency_name: impl AsRef<str>,
864 requirement: &miden_package_registry::VersionReq,
865 actual: &SemVer,
866 ) -> Result<(), Report> {
867 if requirement.matches(actual) {
868 Ok(())
869 } else {
870 Err(Report::msg(format!(
871 "dependency '{}' requires '{}', but resolved version was '{}'",
872 dependency_name.as_ref(),
873 requirement,
874 actual
875 )))
876 }
877 }
878
879 fn validate_source_dependency(
880 &self,
881 dependency: &Dependency,
882 package: &Package,
883 ) -> Result<(), Report> {
884 let requirement = dependency.required_version();
885 self.ensure_version_satisfies(
886 dependency.name(),
887 &requirement,
888 Version::from(package.version().into_inner().clone()),
889 )?;
890 Ok(())
891 }
892}
893
894impl<'a, R: PackageRegistry + ?Sized> ProjectDependencyGraphBuilder<'a, R> {
896 fn checkout_git_dependency(
897 &self,
898 repo: &Uri,
899 revision: &GitRevision,
900 ) -> Result<GitCheckout, Report> {
901 use alloc::vec;
902
903 std::fs::create_dir_all(&self.git_cache_root)
904 .map_err(|error| Report::msg(error.to_string()))?;
905 let cache_key = format!("{repo}@{revision}");
906 let key = hash_string_to_word(cache_key.as_str());
907 let checkout_path =
908 self.git_cache_root.join(format!("0x{}", DisplayHex::new(&key.as_bytes())));
909 if !checkout_path.exists() {
910 let mut args = vec!["clone"];
911 match revision {
912 GitRevision::Branch(name) => {
913 args.extend_from_slice(&["--branch", name.as_ref()]);
914 },
915 GitRevision::Commit(_) => (),
916 };
917 args.push(repo.as_str());
918 let checkout_path = checkout_path.to_string_lossy();
919 args.push(checkout_path.as_ref());
920 self.run_git(&args)?;
921 } else {
922 self.run_git_in(&checkout_path, &["fetch", "--all", "--tags", "--force"])?;
923 }
924
925 let target = match revision {
926 GitRevision::Branch(branch) => format!("origin/{branch}"),
927 GitRevision::Commit(commit) => commit.to_string(),
928 };
929 self.run_git_in(&checkout_path, &["checkout", "--force", &target])?;
930 let resolved_revision = self.run_git_capture(&checkout_path, &["rev-parse", "HEAD"])?;
931 let manifest_path = checkout_path.join("miden-project.toml");
932
933 Ok(GitCheckout {
934 checkout_path,
935 manifest_path,
936 resolved_revision: resolved_revision.trim().to_owned().into(),
937 })
938 }
939
940 fn run_git(&self, args: &[&str]) -> Result<(), Report> {
941 let status = Command::new("git")
942 .args(args)
943 .status()
944 .map_err(|error| Report::msg(error.to_string()))?;
945 if status.success() {
946 Ok(())
947 } else {
948 Err(Report::msg(format!("git command failed: git {}", args.join(" "))))
949 }
950 }
951
952 fn run_git_in(&self, dir: &Path, args: &[&str]) -> Result<(), Report> {
953 let output = Command::new("git")
954 .current_dir(dir)
955 .args(args)
956 .output()
957 .map_err(|error| Report::msg(error.to_string()))?;
958 if output.status.success() {
959 Ok(())
960 } else {
961 Err(Report::msg(format!(
962 "git command failed in '{}': git {}: {}",
963 dir.display(),
964 args.join(" "),
965 String::from_utf8_lossy(&output.stderr)
966 )))
967 }
968 }
969
970 fn run_git_capture(&self, dir: &Path, args: &[&str]) -> Result<String, Report> {
971 let output = Command::new("git")
972 .current_dir(dir)
973 .args(args)
974 .output()
975 .map_err(|error| Report::msg(error.to_string()))?;
976 if output.status.success() {
977 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
978 } else {
979 Err(Report::msg(format!(
980 "git command failed in '{}': git {}: {}",
981 dir.display(),
982 args.join(" "),
983 String::from_utf8_lossy(&output.stderr)
984 )))
985 }
986 }
987}
988
989enum ResolvedDependencyNode {
990 Source {
991 package: LoadedSourcePackage,
992 origin: ProjectSourceOrigin,
993 },
994 Local(CollectedDependencyNode),
995 Registry {
996 package: PackageId,
997 requirement: VersionRequirement,
998 },
999}
1000
1001impl ResolvedDependencyNode {
1002 fn name(&self) -> PackageId {
1003 match self {
1004 Self::Source { package, .. } => package.package.name().into_inner(),
1005 Self::Local(node) => node.name().clone(),
1006 Self::Registry { package, .. } => package.clone(),
1007 }
1008 }
1009
1010 fn solver_requirement(&self) -> VersionRequirement {
1011 match self {
1012 Self::Source { package, .. } => VersionRequirement::from(Version::from(
1013 package.package.version().into_inner().clone(),
1014 )),
1015 Self::Local(node) => VersionRequirement::from(node.selected_version()),
1016 Self::Registry { requirement, .. } => requirement.clone(),
1017 }
1018 }
1019}
1020
1021struct LoadedSourcePackage {
1022 manifest_path: Option<PathBuf>,
1023 package: Arc<Package>,
1024 project_root: Option<PathBuf>,
1025 workspace_root: Option<PathBuf>,
1026}
1027
1028struct GitCheckout {
1029 checkout_path: PathBuf,
1030 manifest_path: PathBuf,
1031 resolved_revision: Arc<str>,
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036 use alloc::{boxed::Box, string::ToString};
1037 use std::{collections::BTreeMap, fs, sync::Arc};
1038
1039 use miden_assembly_syntax::{
1040 ast::Path as AstPath,
1041 debuginfo::{DefaultSourceManager, SourceManagerExt, Span},
1042 };
1043 use miden_core::{assert_matches, serde::Serializable, utils::hash_string_to_word};
1044 use miden_mast_package::{Package as MastPackage, TargetType};
1045 use miden_package_registry::{PackageIndex, PackageRecord, PackageRegistry, PackageVersions};
1046 use tempfile::TempDir;
1047
1048 use super::*;
1049 use crate::Target;
1050
1051 #[derive(Default)]
1053 struct TestRegistry {
1054 packages: BTreeMap<PackageId, PackageVersions>,
1055 }
1056
1057 impl TestRegistry {
1058 fn insert(&mut self, name: &str, version: Version) {
1059 let record_version = version.clone();
1060 self.insert_record(
1061 PackageId::from(name),
1062 PackageRecord::new(record_version, std::iter::empty()),
1063 )
1064 .expect("failed to insert test package");
1065 }
1066
1067 fn insert_record(&mut self, id: PackageId, record: PackageRecord) -> Result<(), Report> {
1068 use std::collections::btree_map::Entry;
1069
1070 let semver = record.semantic_version().clone();
1071 match self.packages.entry(id.clone()).or_default().entry(semver.clone()) {
1072 Entry::Vacant(entry) => {
1073 entry.insert(record);
1074 Ok(())
1075 },
1076 Entry::Occupied(_) => Err(Report::msg(format!(
1077 "package '{}' version '{}' is already registered",
1078 id, semver
1079 ))),
1080 }
1081 }
1082 }
1083
1084 impl PackageRegistry for TestRegistry {
1085 fn available_versions(&self, package: &PackageId) -> Option<&PackageVersions> {
1086 self.packages.get(package)
1087 }
1088 }
1089
1090 impl PackageIndex for TestRegistry {
1091 type Error = Report;
1092
1093 fn register(&mut self, name: PackageId, record: PackageRecord) -> Result<(), Self::Error> {
1094 self.insert_record(name, record)
1095 }
1096 }
1097
1098 #[test]
1099 fn builds_path_dependency_graph() {
1100 let tempdir = TempDir::new().unwrap();
1101 let dependency_dir = tempdir.path().join("dep");
1102 write_package(&dependency_dir, "dep", "1.0.0", Some("export.foo\nend\n"), []);
1103
1104 let root_dir = tempdir.path().join("root");
1105 let root_manifest = write_package(
1106 &root_dir,
1107 "root",
1108 "1.0.0",
1109 Some("export.foo\nend\n"),
1110 [Dependency::new(
1111 Span::unknown("dep".into()),
1112 DependencyVersionScheme::Path {
1113 path: Span::unknown(Uri::new("../dep")),
1114 version: None,
1115 },
1116 Linkage::Dynamic,
1117 )],
1118 );
1119
1120 let registry = TestRegistry::default();
1121 let graph = builder(®istry, &tempdir.path().join("git"))
1122 .build_from_path(&root_manifest)
1123 .unwrap();
1124
1125 assert!(graph.get(&PackageId::from("root")).is_some());
1126 assert!(graph.get(&PackageId::from("dep")).is_some());
1127 assert_eq!(graph.get(&PackageId::from("root")).unwrap().dependencies.len(), 1);
1128 }
1129
1130 #[test]
1131 fn path_dependency_without_version_uses_referenced_source_version() {
1132 let tempdir = TempDir::new().unwrap();
1133 let dependency_dir = tempdir.path().join("dep");
1134 write_package(&dependency_dir, "dep", "9.9.9", Some("export.foo\nend\n"), []);
1135
1136 let root_dir = tempdir.path().join("root");
1137 let root_manifest = write_package(
1138 &root_dir,
1139 "root",
1140 "1.0.0",
1141 Some("export.foo\nend\n"),
1142 [Dependency::new(
1143 Span::unknown("dep".into()),
1144 DependencyVersionScheme::Path {
1145 path: Span::unknown(Uri::new("../dep")),
1146 version: None,
1147 },
1148 Linkage::Dynamic,
1149 )],
1150 );
1151
1152 let registry = TestRegistry::default();
1153 let graph = builder(®istry, &tempdir.path().join("git"))
1154 .build_from_path(&root_manifest)
1155 .unwrap();
1156 let dep = graph.get(&PackageId::from("dep")).unwrap();
1157
1158 assert_eq!(dep.version, "9.9.9".parse().unwrap());
1159 }
1160
1161 #[test]
1162 fn path_dependency_version_requirement_must_match_source_version() {
1163 let tempdir = TempDir::new().unwrap();
1164 let dependency_dir = tempdir.path().join("dep");
1165 write_package(&dependency_dir, "dep", "1.0.0", Some("export.foo\nend\n"), []);
1166
1167 let root_dir = tempdir.path().join("root");
1168 let root_manifest = write_package(
1169 &root_dir,
1170 "root",
1171 "1.0.0",
1172 Some("export.foo\nend\n"),
1173 [Dependency::new(
1174 Span::unknown("dep".into()),
1175 DependencyVersionScheme::Path {
1176 path: Span::unknown(Uri::new("../dep")),
1177 version: Some(VersionRequirement::Semantic(Span::unknown(
1178 "=2.0.0".parse().unwrap(),
1179 ))),
1180 },
1181 Linkage::Dynamic,
1182 )],
1183 );
1184
1185 let registry = TestRegistry::default();
1186 let error = builder(®istry, &tempdir.path().join("git"))
1187 .build_from_path(&root_manifest)
1188 .expect_err("mismatched path dependency version should fail");
1189
1190 assert!(error.to_string().contains("requires '=2.0.0'"));
1191 }
1192
1193 #[test]
1194 fn path_source_dependency_rejects_digest_requirement() {
1195 let tempdir = TempDir::new().unwrap();
1196 let dependency_dir = tempdir.path().join("dep");
1197 write_package(&dependency_dir, "dep", "1.0.0", Some("export.foo\nend\n"), []);
1198
1199 let root_dir = tempdir.path().join("root");
1200 let root_manifest = write_package(
1201 &root_dir,
1202 "root",
1203 "1.0.0",
1204 Some("export.foo\nend\n"),
1205 [Dependency::new(
1206 Span::unknown("dep".into()),
1207 DependencyVersionScheme::Path {
1208 path: Span::unknown(Uri::new("../dep")),
1209 version: Some(VersionRequirement::Digest(Span::unknown(hash_string_to_word(
1210 "dep-digest",
1211 )))),
1212 },
1213 Linkage::Dynamic,
1214 )],
1215 );
1216
1217 let registry = TestRegistry::default();
1218 let error = builder(®istry, &tempdir.path().join("git"))
1219 .build_from_path(&root_manifest)
1220 .expect_err("digest requirements on source paths should fail");
1221
1222 assert!(error.to_string().contains("resolved version was '1.0.0'"));
1223 }
1224
1225 #[test]
1226 fn path_source_dependency_rejects_exact_published_requirement() {
1227 let tempdir = TempDir::new().unwrap();
1228 let dependency_dir = tempdir.path().join("dep");
1229 write_package(&dependency_dir, "dep", "1.0.0", Some("export.foo\nend\n"), []);
1230
1231 let root_dir = tempdir.path().join("root");
1232 let root_manifest = write_package(
1233 &root_dir,
1234 "root",
1235 "1.0.0",
1236 Some("export.foo\nend\n"),
1237 [Dependency::new(
1238 Span::unknown("dep".into()),
1239 DependencyVersionScheme::Path {
1240 path: Span::unknown(Uri::new("../dep")),
1241 version: Some(VersionRequirement::Exact(Version::new(
1242 "1.0.0".parse().unwrap(),
1243 hash_string_to_word("dep-digest"),
1244 ))),
1245 },
1246 Linkage::Dynamic,
1247 )],
1248 );
1249
1250 let registry = TestRegistry::default();
1251 let error = builder(®istry, &tempdir.path().join("git"))
1252 .build_from_path(&root_manifest)
1253 .expect_err("exact published requirements on source paths should fail");
1254
1255 assert!(error.to_string().contains("resolved version was '1.0.0'"));
1256 }
1257
1258 #[test]
1259 fn resolves_workspace_root_by_dependency_name() {
1260 let tempdir = TempDir::new().unwrap();
1261 let workspace_root = tempdir.path().join("workspace");
1262 write_file(
1263 &workspace_root.join("miden-project.toml"),
1264 "[workspace]\nmembers = [\"dep\"]\n",
1265 );
1266 write_package(&workspace_root.join("dep"), "dep", "1.0.0", Some("export.foo\nend\n"), []);
1267
1268 let root_dir = tempdir.path().join("root");
1269 let root_manifest = write_package(
1270 &root_dir,
1271 "root",
1272 "1.0.0",
1273 Some("export.foo\nend\n"),
1274 [Dependency::new(
1275 Span::unknown("dep".into()),
1276 DependencyVersionScheme::Path {
1277 path: Span::unknown(Uri::new("../workspace")),
1278 version: None,
1279 },
1280 Linkage::Dynamic,
1281 )],
1282 );
1283
1284 let registry = TestRegistry::default();
1285 let graph = builder(®istry, &tempdir.path().join("git"))
1286 .build_from_path(&root_manifest)
1287 .unwrap();
1288
1289 assert!(graph.get(&PackageId::from("dep")).is_some());
1290 }
1291
1292 #[test]
1293 fn resolves_registry_semver_leaf() {
1294 let tempdir = TempDir::new().unwrap();
1295 let root_dir = tempdir.path().join("root");
1296 let root_manifest = write_package(
1297 &root_dir,
1298 "root",
1299 "1.0.0",
1300 Some("export.foo\nend\n"),
1301 [Dependency::new(
1302 Span::unknown("dep".into()),
1303 DependencyVersionScheme::Registry(VersionRequirement::Semantic(Span::unknown(
1304 "^1.0.0".parse().unwrap(),
1305 ))),
1306 Linkage::Dynamic,
1307 )],
1308 );
1309
1310 let mut registry = TestRegistry::default();
1311 registry.insert("dep", "1.2.0".parse().unwrap());
1312
1313 let graph = builder(®istry, &tempdir.path().join("git"))
1314 .build_from_path(&root_manifest)
1315 .unwrap();
1316 let dep = graph.get(&PackageId::from("dep")).unwrap();
1317 assert_eq!(dep.version, "1.2.0".parse().unwrap());
1318 assert!(matches!(dep.provenance, ProjectDependencyNodeProvenance::Registry { .. }));
1319 }
1320
1321 #[test]
1322 fn resolves_registry_digest_leaf() {
1323 let tempdir = TempDir::new().unwrap();
1324 let digest = hash_string_to_word("dep");
1325 let root_dir = tempdir.path().join("root");
1326 let root_manifest = write_package(
1327 &root_dir,
1328 "root",
1329 "1.0.0",
1330 Some("export.foo\nend\n"),
1331 [Dependency::new(
1332 Span::unknown("dep".into()),
1333 DependencyVersionScheme::Registry(VersionRequirement::Digest(Span::unknown(
1334 digest,
1335 ))),
1336 Linkage::Dynamic,
1337 )],
1338 );
1339
1340 let mut registry = TestRegistry::default();
1341 registry.insert("dep", Version::new("1.2.0".parse().unwrap(), digest));
1342
1343 let graph = builder(®istry, &tempdir.path().join("git"))
1344 .build_from_path(&root_manifest)
1345 .unwrap();
1346 let dep = graph.get(&PackageId::from("dep")).unwrap();
1347 assert_eq!(dep.version, "1.2.0".parse().unwrap());
1348 }
1349
1350 #[test]
1351 fn resolves_shared_registry_version_across_source_dependencies() {
1352 let tempdir = TempDir::new().unwrap();
1353 let depa_dir = tempdir.path().join("depa");
1354 let depb_dir = tempdir.path().join("depb");
1355 write_package(
1356 &depa_dir,
1357 "depa",
1358 "1.0.0",
1359 Some("export.call_shared\nend\n"),
1360 [Dependency::new(
1361 Span::unknown("shared".into()),
1362 DependencyVersionScheme::Registry(VersionRequirement::Semantic(Span::unknown(
1363 "^1.0.0".parse().unwrap(),
1364 ))),
1365 Linkage::Dynamic,
1366 )],
1367 );
1368 write_package(
1369 &depb_dir,
1370 "depb",
1371 "1.0.0",
1372 Some("export.call_shared\nend\n"),
1373 [Dependency::new(
1374 Span::unknown("shared".into()),
1375 DependencyVersionScheme::Registry(VersionRequirement::Semantic(Span::unknown(
1376 "=1.2.0".parse().unwrap(),
1377 ))),
1378 Linkage::Dynamic,
1379 )],
1380 );
1381
1382 let root_dir = tempdir.path().join("root");
1383 let root_manifest = write_package(
1384 &root_dir,
1385 "root",
1386 "1.0.0",
1387 Some("export.entry\nend\n"),
1388 [
1389 Dependency::new(
1390 Span::unknown("depa".into()),
1391 DependencyVersionScheme::Path {
1392 path: Span::unknown(Uri::new("../depa")),
1393 version: None,
1394 },
1395 Linkage::Dynamic,
1396 ),
1397 Dependency::new(
1398 Span::unknown("depb".into()),
1399 DependencyVersionScheme::Path {
1400 path: Span::unknown(Uri::new("../depb")),
1401 version: None,
1402 },
1403 Linkage::Dynamic,
1404 ),
1405 ],
1406 );
1407
1408 let mut registry = TestRegistry::default();
1409 registry.insert("shared", "1.0.0".parse().unwrap());
1410 registry.insert("shared", "1.2.0".parse().unwrap());
1411 registry.insert("shared", "1.3.0".parse().unwrap());
1412
1413 let graph = builder(®istry, &tempdir.path().join("git"))
1414 .build_from_path(&root_manifest)
1415 .expect("compatible source dependency requirements should resolve");
1416 let shared = graph.get(&PackageId::from("shared")).expect("shared dependency missing");
1417 assert_eq!(shared.version, "1.2.0".parse().unwrap());
1418 assert_matches!(
1419 shared.provenance,
1420 ProjectDependencyNodeProvenance::Registry { ref selected, .. }
1421 if selected.version == "1.2.0".parse().unwrap()
1422 );
1423 }
1424
1425 #[test]
1426 fn rejects_incompatible_shared_registry_version_requirements() {
1427 let tempdir = TempDir::new().unwrap();
1428 let depa_dir = tempdir.path().join("depa");
1429 let depb_dir = tempdir.path().join("depb");
1430 write_package(
1431 &depa_dir,
1432 "depa",
1433 "1.0.0",
1434 Some("export.call_shared\nend\n"),
1435 [Dependency::new(
1436 Span::unknown("shared".into()),
1437 DependencyVersionScheme::Registry(VersionRequirement::Semantic(Span::unknown(
1438 "=1.0.0".parse().unwrap(),
1439 ))),
1440 Linkage::Dynamic,
1441 )],
1442 );
1443 write_package(
1444 &depb_dir,
1445 "depb",
1446 "1.0.0",
1447 Some("export.call_shared\nend\n"),
1448 [Dependency::new(
1449 Span::unknown("shared".into()),
1450 DependencyVersionScheme::Registry(VersionRequirement::Semantic(Span::unknown(
1451 "=2.0.0".parse().unwrap(),
1452 ))),
1453 Linkage::Dynamic,
1454 )],
1455 );
1456
1457 let root_dir = tempdir.path().join("root");
1458 let root_manifest = write_package(
1459 &root_dir,
1460 "root",
1461 "1.0.0",
1462 Some("export.entry\nend\n"),
1463 [
1464 Dependency::new(
1465 Span::unknown("depa".into()),
1466 DependencyVersionScheme::Path {
1467 path: Span::unknown(Uri::new("../depa")),
1468 version: None,
1469 },
1470 Linkage::Dynamic,
1471 ),
1472 Dependency::new(
1473 Span::unknown("depb".into()),
1474 DependencyVersionScheme::Path {
1475 path: Span::unknown(Uri::new("../depb")),
1476 version: None,
1477 },
1478 Linkage::Dynamic,
1479 ),
1480 ],
1481 );
1482
1483 let mut registry = TestRegistry::default();
1484 registry.insert("shared", "1.0.0".parse().unwrap());
1485 registry.insert("shared", "2.0.0".parse().unwrap());
1486
1487 let error = builder(®istry, &tempdir.path().join("git"))
1488 .build_from_path(&root_manifest)
1489 .expect_err("incompatible source dependency requirements should fail");
1490 let error = error.to_string();
1491 assert!(error.contains("dependency resolution failed"));
1492 assert!(error.contains("shared"));
1493 assert!(error.contains("1.0.0"));
1494 assert!(error.contains("2.0.0"));
1495 }
1496
1497 #[test]
1498 fn records_missing_library_source_path() {
1499 let tempdir = TempDir::new().unwrap();
1500 let dependency_dir = tempdir.path().join("dep");
1501 write_package(&dependency_dir, "dep", "1.0.0", None, []);
1502
1503 let root_dir = tempdir.path().join("root");
1504 let root_manifest = write_package(
1505 &root_dir,
1506 "root",
1507 "1.0.0",
1508 Some("export.foo\nend\n"),
1509 [Dependency::new(
1510 Span::unknown("dep".into()),
1511 DependencyVersionScheme::Path {
1512 path: Span::unknown(Uri::new("../dep")),
1513 version: None,
1514 },
1515 Linkage::Dynamic,
1516 )],
1517 );
1518
1519 let registry = TestRegistry::default();
1520 let graph = builder(®istry, &tempdir.path().join("git"))
1521 .build_from_path(&root_manifest)
1522 .unwrap();
1523 let dep = graph.get(&PackageId::from("dep")).unwrap();
1524 match &dep.provenance {
1525 ProjectDependencyNodeProvenance::Source(source) => {
1526 assert_matches!(source, ProjectSource::Real { library_path, .. } if library_path.is_none());
1527 },
1528 _ => panic!("expected source provenance"),
1529 }
1530 }
1531
1532 #[test]
1533 fn path_to_masp_is_leaf() {
1534 let tempdir = TempDir::new().unwrap();
1535 let package = build_registry_test_package("dep", "1.0.0");
1536 let package_path = tempdir.path().join("dep.masp");
1537 fs::write(&package_path, package.to_bytes()).unwrap();
1538
1539 let root_dir = tempdir.path().join("root");
1540 let root_manifest = write_package(
1541 &root_dir,
1542 "root",
1543 "1.0.0",
1544 Some("export.foo\nend\n"),
1545 [Dependency::new(
1546 Span::unknown("dep".into()),
1547 DependencyVersionScheme::Path {
1548 path: Span::unknown(Uri::from(package_path.as_path())),
1549 version: None,
1550 },
1551 Linkage::Dynamic,
1552 )],
1553 );
1554
1555 let registry = TestRegistry::default();
1556 let graph = builder(®istry, &tempdir.path().join("git"))
1557 .build_from_path(&root_manifest)
1558 .unwrap();
1559 let dep = graph.get(&PackageId::from("dep")).unwrap();
1560 assert!(dep.dependencies.is_empty());
1561 assert!(matches!(dep.provenance, ProjectDependencyNodeProvenance::Preassembled { .. }));
1562 }
1563
1564 #[test]
1565 fn preassembled_path_dependency_accepts_exact_published_requirement() {
1566 let tempdir = TempDir::new().unwrap();
1567 let package = build_registry_test_package("dep", "1.0.0");
1568 let digest = package.digest();
1569 let package_path = tempdir.path().join("dep.masp");
1570 fs::write(&package_path, package.to_bytes()).unwrap();
1571
1572 let root_dir = tempdir.path().join("root");
1573 let root_manifest = write_package(
1574 &root_dir,
1575 "root",
1576 "1.0.0",
1577 Some("export.foo\nend\n"),
1578 [Dependency::new(
1579 Span::unknown("dep".into()),
1580 DependencyVersionScheme::Path {
1581 path: Span::unknown(Uri::from(package_path.as_path())),
1582 version: Some(VersionRequirement::Exact(Version::new(
1583 "1.0.0".parse().unwrap(),
1584 digest,
1585 ))),
1586 },
1587 Linkage::Dynamic,
1588 )],
1589 );
1590
1591 let registry = TestRegistry::default();
1592 let graph = builder(®istry, &tempdir.path().join("git"))
1593 .build_from_path(&root_manifest)
1594 .unwrap();
1595 let dep = graph.get(&PackageId::from("dep")).unwrap();
1596
1597 assert_eq!(dep.version, "1.0.0".parse().unwrap());
1598 assert_matches!(
1599 dep.provenance,
1600 ProjectDependencyNodeProvenance::Preassembled {
1601 ref path,
1602 ref selected,
1603 } if path == &package_path.canonicalize().unwrap()
1604 && *selected == Version::new("1.0.0".parse().unwrap(), digest)
1605 );
1606 }
1607
1608 #[test]
1609 fn preassembled_path_dependency_validates_digest_requirement_against_artifact_digest() {
1610 let tempdir = TempDir::new().unwrap();
1611 let package = build_registry_test_package("dep", "1.0.0");
1612 let digest = package.digest();
1613 let package_path = tempdir.path().join("dep.masp");
1614 fs::write(&package_path, package.to_bytes()).unwrap();
1615
1616 let ok_root_dir = tempdir.path().join("root-ok");
1617 let ok_manifest = write_package(
1618 &ok_root_dir,
1619 "root-ok",
1620 "1.0.0",
1621 Some("export.foo\nend\n"),
1622 [Dependency::new(
1623 Span::unknown("dep".into()),
1624 DependencyVersionScheme::Path {
1625 path: Span::unknown(Uri::from(package_path.as_path())),
1626 version: Some(VersionRequirement::Digest(Span::unknown(digest))),
1627 },
1628 Linkage::Dynamic,
1629 )],
1630 );
1631
1632 let registry = TestRegistry::default();
1633 let graph = builder(®istry, &tempdir.path().join("git"))
1634 .build_from_path(&ok_manifest)
1635 .unwrap();
1636 let dep = graph.get(&PackageId::from("dep")).unwrap();
1637 assert_eq!(dep.version, "1.0.0".parse().unwrap());
1638
1639 let bad_root_dir = tempdir.path().join("root-bad");
1640 let bad_manifest = write_package(
1641 &bad_root_dir,
1642 "root-bad",
1643 "1.0.0",
1644 Some("export.foo\nend\n"),
1645 [Dependency::new(
1646 Span::unknown("dep".into()),
1647 DependencyVersionScheme::Path {
1648 path: Span::unknown(Uri::from(package_path.as_path())),
1649 version: Some(VersionRequirement::Digest(Span::unknown(hash_string_to_word(
1650 "wrong-digest",
1651 )))),
1652 },
1653 Linkage::Dynamic,
1654 )],
1655 );
1656
1657 let error = builder(®istry, &tempdir.path().join("git"))
1658 .build_from_path(&bad_manifest)
1659 .expect_err("mismatched digest requirement should fail for preassembled packages");
1660
1661 assert!(error.to_string().contains("resolved version was '1.0.0#"));
1662 }
1663
1664 #[test]
1665 fn validates_bin_path_is_required() {
1666 let tempdir = TempDir::new().unwrap();
1667 let manifest_path = tempdir.path().join("miden-project.toml");
1668 write_file(
1669 &manifest_path,
1670 "[package]\nname = \"root\"\nversion = \"1.0.0\"\n\n[[bin]]\nname = \"cli\"\n",
1671 );
1672
1673 let source_manager = Arc::new(DefaultSourceManager::default());
1674 let source = source_manager.load_file(&manifest_path).unwrap();
1675 let error = Package::load(source).expect_err("manifest should be rejected");
1676 assert!(error.to_string().contains("invalid build target configuration"));
1677 }
1678
1679 #[test]
1680 fn resolves_git_dependency_using_local_repo() {
1681 let tempdir = TempDir::new().unwrap();
1682 let repo_dir = tempdir.path().join("repo");
1683 fs::create_dir_all(&repo_dir).unwrap();
1684 write_package(&repo_dir, "dep", "1.0.0", Some("export.foo\nend\n"), []);
1685 run_git(&repo_dir, &["init", "-b", "main"]);
1686 run_git(&repo_dir, &["config", "user.email", "test@example.com"]);
1687 run_git(&repo_dir, &["config", "user.name", "Test"]);
1688 run_git(&repo_dir, &["config", "commit.gpgsign", "false"]);
1689 run_git(&repo_dir, &["add", "."]);
1690 run_git(&repo_dir, &["commit", "-m", "init"]);
1691
1692 let root_dir = tempdir.path().join("root");
1693 let root_manifest = write_package(
1694 &root_dir,
1695 "root",
1696 "1.0.0",
1697 Some("export.foo\nend\n"),
1698 [Dependency::new(
1699 Span::unknown("dep".into()),
1700 DependencyVersionScheme::Git {
1701 repo: Span::unknown(Uri::from(repo_dir.as_path())),
1702 revision: Span::unknown(GitRevision::Branch("main".into())),
1703 version: None,
1704 },
1705 Linkage::Dynamic,
1706 )],
1707 );
1708
1709 let registry = TestRegistry::default();
1710 let graph = builder(®istry, &tempdir.path().join("git-cache"))
1711 .build_from_path(&root_manifest)
1712 .unwrap();
1713 let dep = graph.get(&PackageId::from("dep")).unwrap();
1714 assert_matches!(
1715 dep.provenance,
1716 ProjectDependencyNodeProvenance::Source(ProjectSource::Real {
1717 origin: ProjectSourceOrigin::Git { .. },
1718 ..
1719 })
1720 );
1721 }
1722
1723 #[test]
1724 fn resolves_commit_pinned_git_dependency_after_repo_advances() {
1725 let tempdir = TempDir::new().unwrap();
1726 let repo_dir = tempdir.path().join("repo");
1727 fs::create_dir_all(&repo_dir).unwrap();
1728 write_package(&repo_dir, "dep", "1.0.0", Some("export.foo\nend\n"), []);
1729 run_git(&repo_dir, &["init", "-b", "main"]);
1730 run_git(&repo_dir, &["config", "user.email", "test@example.com"]);
1731 run_git(&repo_dir, &["config", "user.name", "Test"]);
1732 run_git(&repo_dir, &["config", "commit.gpgsign", "false"]);
1733 run_git(&repo_dir, &["add", "."]);
1734 run_git(&repo_dir, &["commit", "-m", "init"]);
1735 let initial_revision = run_git_capture(&repo_dir, &["rev-parse", "HEAD"]);
1736
1737 write_package(&repo_dir, "dep", "2.0.0", Some("export.foo\nend\n"), []);
1738 run_git(&repo_dir, &["add", "."]);
1739 run_git(&repo_dir, &["commit", "-m", "change"]);
1740
1741 let root_dir = tempdir.path().join("root");
1742 let root_manifest = write_package(
1743 &root_dir,
1744 "root",
1745 "1.0.0",
1746 Some("export.foo\nend\n"),
1747 [Dependency::new(
1748 Span::unknown("dep".into()),
1749 DependencyVersionScheme::Git {
1750 repo: Span::unknown(Uri::from(repo_dir.as_path())),
1751 revision: Span::unknown(GitRevision::Commit(initial_revision.clone().into())),
1752 version: None,
1753 },
1754 Linkage::Dynamic,
1755 )],
1756 );
1757
1758 let registry = TestRegistry::default();
1759 let graph = builder(®istry, &tempdir.path().join("git-cache"))
1760 .build_from_path(&root_manifest)
1761 .unwrap();
1762 let dep = graph.get(&PackageId::from("dep")).unwrap();
1763
1764 assert_eq!(dep.version, "1.0.0".parse().unwrap());
1765 assert_matches!(
1766 &dep.provenance,
1767 ProjectDependencyNodeProvenance::Source(ProjectSource::Real {
1768 origin: ProjectSourceOrigin::Git {
1769 revision,
1770 resolved_revision,
1771 ..
1772 },
1773 ..
1774 }) if *revision == GitRevision::Commit(initial_revision.clone().into())
1775 && resolved_revision.as_ref() == initial_revision
1776 );
1777 }
1778
1779 #[test]
1780 fn git_dependency_without_version_uses_checked_out_source_version() {
1781 let tempdir = TempDir::new().unwrap();
1782 let repo_dir = tempdir.path().join("repo");
1783 fs::create_dir_all(&repo_dir).unwrap();
1784 write_package(&repo_dir, "dep", "9.9.9", Some("export.foo\nend\n"), []);
1785 run_git(&repo_dir, &["init", "-b", "main"]);
1786 run_git(&repo_dir, &["config", "user.email", "test@example.com"]);
1787 run_git(&repo_dir, &["config", "user.name", "Test"]);
1788 run_git(&repo_dir, &["config", "commit.gpgsign", "false"]);
1789 run_git(&repo_dir, &["add", "."]);
1790 run_git(&repo_dir, &["commit", "-m", "init"]);
1791
1792 let root_dir = tempdir.path().join("root");
1793 let root_manifest = write_package(
1794 &root_dir,
1795 "root",
1796 "1.0.0",
1797 Some("export.foo\nend\n"),
1798 [Dependency::new(
1799 Span::unknown("dep".into()),
1800 DependencyVersionScheme::Git {
1801 repo: Span::unknown(Uri::from(repo_dir.as_path())),
1802 revision: Span::unknown(GitRevision::Branch("main".into())),
1803 version: None,
1804 },
1805 Linkage::Dynamic,
1806 )],
1807 );
1808
1809 let registry = TestRegistry::default();
1810 let graph = builder(®istry, &tempdir.path().join("git-cache"))
1811 .build_from_path(&root_manifest)
1812 .unwrap();
1813 let dep = graph.get(&PackageId::from("dep")).unwrap();
1814
1815 assert_eq!(dep.version, "9.9.9".parse().unwrap());
1816 }
1817
1818 #[test]
1819 fn git_dependency_version_requirement_must_match_checked_out_source_version() {
1820 let tempdir = TempDir::new().unwrap();
1821 let repo_dir = tempdir.path().join("repo");
1822 fs::create_dir_all(&repo_dir).unwrap();
1823 write_package(&repo_dir, "dep", "1.0.0", Some("export.foo\nend\n"), []);
1824 run_git(&repo_dir, &["init", "-b", "main"]);
1825 run_git(&repo_dir, &["config", "user.email", "test@example.com"]);
1826 run_git(&repo_dir, &["config", "user.name", "Test"]);
1827 run_git(&repo_dir, &["config", "commit.gpgsign", "false"]);
1828 run_git(&repo_dir, &["add", "."]);
1829 run_git(&repo_dir, &["commit", "-m", "init"]);
1830
1831 let root_dir = tempdir.path().join("root");
1832 let root_manifest = write_package(
1833 &root_dir,
1834 "root",
1835 "1.0.0",
1836 Some("export.foo\nend\n"),
1837 [Dependency::new(
1838 Span::unknown("dep".into()),
1839 DependencyVersionScheme::Git {
1840 repo: Span::unknown(Uri::from(repo_dir.as_path())),
1841 revision: Span::unknown(GitRevision::Branch("main".into())),
1842 version: Some(Span::unknown("=2.0.0".parse().unwrap())),
1843 },
1844 Linkage::Dynamic,
1845 )],
1846 );
1847
1848 let registry = TestRegistry::default();
1849 let error = builder(®istry, &tempdir.path().join("git-cache"))
1850 .build_from_path(&root_manifest)
1851 .expect_err("mismatched git dependency version should fail");
1852
1853 assert!(error.to_string().contains("requires '=2.0.0'"));
1854 }
1855
1856 #[test]
1857 fn workspace_dependency_stays_on_the_workspace_member_version() {
1858 let tempdir = TempDir::new().unwrap();
1859 let root_dir = tempdir.path().join("workspace-dep");
1860 fs::create_dir_all(&root_dir).unwrap();
1861 fs::create_dir_all(root_dir.join("dep")).unwrap();
1862 fs::create_dir_all(root_dir.join("app")).unwrap();
1863
1864 write_file(
1865 &root_dir.join("miden-project.toml"),
1866 "[workspace]\nmembers = [\"dep\", \"app\"]\n\n[workspace.dependencies]\ndep = { path = \"dep\" }\n",
1867 );
1868 write_file(
1869 &root_dir.join("dep").join("miden-project.toml"),
1870 "[package]\nname = \"dep\"\nversion = \"0.2.0\"\n",
1871 );
1872 let app_manifest = root_dir.join("app").join("miden-project.toml");
1873 write_file(
1874 &app_manifest,
1875 "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\ndep.workspace = true\n",
1876 );
1877
1878 let mut registry = TestRegistry::default();
1879 let dep_id = PackageId::from("dep");
1880 let version010 = "0.1.0".parse::<miden_package_registry::SemVer>().unwrap();
1881 let version999 = "9.9.9".parse::<miden_package_registry::SemVer>().unwrap();
1882 registry.insert(&dep_id, Version::from(version010.clone()));
1883 registry.insert(&dep_id, Version::from(version999.clone()));
1884 let graph = builder(®istry, &tempdir.path().join("git-cache"))
1885 .build_from_path(&app_manifest)
1886 .unwrap();
1887 let dep = graph.get(&PackageId::from("dep")).unwrap();
1888 assert_eq!(dep.version.to_string(), "0.2.0");
1889 }
1890
1891 #[test]
1892 fn workspace_dependency_rejects_mismatched_workspace_requirement() {
1893 let tempdir = TempDir::new().unwrap();
1894 let root_dir = tempdir.path().join("workspace-dep");
1895 fs::create_dir_all(&root_dir).unwrap();
1896 fs::create_dir_all(root_dir.join("dep")).unwrap();
1897 fs::create_dir_all(root_dir.join("app")).unwrap();
1898
1899 write_file(
1900 &root_dir.join("miden-project.toml"),
1901 "[workspace]\nmembers = [\"dep\", \"app\"]\n\n[workspace.dependencies]\ndep = { path = \"dep\", version = \"=0.1.0\" }\n",
1902 );
1903 write_file(
1904 &root_dir.join("dep").join("miden-project.toml"),
1905 "[package]\nname = \"dep\"\nversion = \"0.2.0\"\n",
1906 );
1907 let app_manifest = root_dir.join("app").join("miden-project.toml");
1908 write_file(
1909 &app_manifest,
1910 "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\ndep.workspace = true\n",
1911 );
1912
1913 let registry = TestRegistry::default();
1914 let error = builder(®istry, &tempdir.path().join("git-cache"))
1915 .build_from_path(&app_manifest)
1916 .expect_err("mismatched workspace dependency version should fail");
1917 assert!(error.to_string().contains("requires '=0.1.0'"));
1918 assert!(error.to_string().contains("resolved version was '0.2.0'"));
1919 }
1920
1921 #[test]
1922 fn non_member_path_dependency_inside_workspace_root_is_resolved_by_path() {
1923 let tempdir = TempDir::new().unwrap();
1924 let root_dir = tempdir.path().join("workspace-dep");
1925 let app_dir = root_dir.join("app");
1926 let dep_dir = root_dir.join("vendor").join("dep");
1927 fs::create_dir_all(&app_dir).unwrap();
1928 fs::create_dir_all(&dep_dir).unwrap();
1929
1930 write_file(
1931 &root_dir.join("miden-project.toml"),
1932 "[workspace]\nmembers = [\"app\"]\n\n[workspace.dependencies]\ndep = { path = \"vendor/dep\" }\n",
1933 );
1934 write_package(&dep_dir, "dep", "0.3.0", Some("export.foo\nend\n"), []);
1935 let app_manifest = app_dir.join("miden-project.toml");
1936 write_file(
1937 &app_manifest,
1938 "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\ndep.workspace = true\n",
1939 );
1940
1941 let registry = TestRegistry::default();
1942 let graph = builder(®istry, &tempdir.path().join("git-cache"))
1943 .build_from_path(&app_manifest)
1944 .unwrap();
1945 let dep = graph.get(&PackageId::from("dep")).unwrap();
1946
1947 assert_eq!(dep.version.to_string(), "0.3.0");
1948 assert_matches!(
1949 dep.provenance,
1950 ProjectDependencyNodeProvenance::Source(ProjectSource::Real {
1951 origin: ProjectSourceOrigin::Path,
1952 workspace_root: None,
1953 ..
1954 })
1955 );
1956 }
1957
1958 #[test]
1959 fn preassembled_path_dependency_inside_workspace_root_is_not_treated_as_workspace_member() {
1960 let tempdir = TempDir::new().unwrap();
1961 let root_dir = tempdir.path().join("workspace-dep");
1962 let app_dir = root_dir.join("app");
1963 let artifacts_dir = root_dir.join("artifacts");
1964 fs::create_dir_all(&app_dir).unwrap();
1965 fs::create_dir_all(&artifacts_dir).unwrap();
1966
1967 write_file(
1968 &root_dir.join("miden-project.toml"),
1969 "[workspace]\nmembers = [\"app\"]\n\n[workspace.dependencies]\ndep = { path = \"artifacts/dep.masp\" }\n",
1970 );
1971 let dep_package = build_registry_test_package("dep", "1.0.0");
1972 let dep_package_path = artifacts_dir.join("dep.masp");
1973 fs::write(&dep_package_path, dep_package.to_bytes()).unwrap();
1974 let app_manifest = app_dir.join("miden-project.toml");
1975 write_file(
1976 &app_manifest,
1977 "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\ndep.workspace = true\n",
1978 );
1979
1980 let registry = TestRegistry::default();
1981 let graph = builder(®istry, &tempdir.path().join("git-cache"))
1982 .build_from_path(&app_manifest)
1983 .unwrap();
1984 let dep = graph.get(&PackageId::from("dep")).unwrap();
1985
1986 assert_eq!(dep.version.to_string(), "1.0.0");
1987 assert_matches!(
1988 dep.provenance,
1989 ProjectDependencyNodeProvenance::Preassembled { ref path, .. }
1990 if path == &dep_package_path.canonicalize().unwrap()
1991 );
1992 }
1993
1994 fn build_registry_test_package(name: &str, version: &str) -> Box<MastPackage> {
1997 MastPackage::generate(name.into(), version.parse().unwrap(), TargetType::Library, [])
1998 }
1999
2000 fn write_package(
2001 dir: &Path,
2002 name: &str,
2003 version: &str,
2004 module_body: Option<&str>,
2005 dependencies: impl IntoIterator<Item = Dependency>,
2006 ) -> PathBuf {
2007 let target = if module_body.is_some() {
2008 Target::library(AstPath::new(name)).with_path("lib/mod.masm")
2009 } else {
2010 Target::library(AstPath::new(name))
2011 };
2012 let manifest = Package::new(name, target)
2013 .with_version(version.parse().unwrap())
2014 .with_dependencies(dependencies);
2015
2016 let manifest = manifest.to_toml().unwrap();
2017 let manifest_path = dir.join("miden-project.toml");
2018 write_file(&manifest_path, &manifest);
2019 if let Some(module_body) = module_body {
2020 write_file(&dir.join("lib/mod.masm"), module_body);
2021 }
2022 manifest_path
2023 }
2024
2025 fn run_git(dir: &Path, args: &[&str]) {
2026 let output = Command::new("git").current_dir(dir).args(args).output().unwrap();
2027 assert!(
2028 output.status.success(),
2029 "git {} failed in '{}': {}",
2030 args.join(" "),
2031 dir.display(),
2032 String::from_utf8_lossy(&output.stderr)
2033 );
2034 }
2035
2036 fn run_git_capture(dir: &Path, args: &[&str]) -> String {
2037 let output = Command::new("git").current_dir(dir).args(args).output().unwrap();
2038 assert!(
2039 output.status.success(),
2040 "git {} failed in '{}': {}",
2041 args.join(" "),
2042 dir.display(),
2043 String::from_utf8_lossy(&output.stderr)
2044 );
2045 String::from_utf8(output.stdout).unwrap().trim().to_owned()
2046 }
2047
2048 fn write_file(path: &Path, contents: &str) {
2049 if let Some(parent) = path.parent() {
2050 fs::create_dir_all(parent).unwrap();
2051 }
2052 fs::write(path, contents).unwrap();
2053 }
2054
2055 fn builder<'a, R: PackageRegistry + ?Sized>(
2056 registry: &'a R,
2057 git_cache_root: &Path,
2058 ) -> ProjectDependencyGraphBuilder<'a, R> {
2059 ProjectDependencyGraphBuilder::new(registry)
2060 .with_git_cache_root(git_cache_root)
2061 .with_source_manager(Arc::new(DefaultSourceManager::default()))
2062 }
2063}