1use semver::{Version, VersionReq};
9use serde::Deserialize;
10use std::collections::{HashMap, HashSet, VecDeque};
11use std::path::{Path, PathBuf};
12
13use crate::project::DependencySpec;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum ResolvedDependencySource {
18 Path,
20 Git { url: String, rev: String },
22 Bundle,
24 Registry { registry: String },
26}
27
28#[derive(Debug, Clone)]
30pub struct ResolvedDependency {
31 pub name: String,
33 pub path: PathBuf,
35 pub version: String,
37 pub source: ResolvedDependencySource,
39 pub dependencies: Vec<String>,
41}
42
43#[derive(Debug, Clone, Deserialize)]
44struct RegistryIndexFile {
45 #[serde(default)]
46 package: Option<String>,
47 #[serde(default)]
48 versions: Vec<RegistryVersionRecord>,
49}
50
51#[derive(Debug, Clone, Deserialize)]
52struct RegistryVersionRecord {
53 version: String,
54 #[serde(default)]
55 yanked: bool,
56 #[serde(default)]
57 dependencies: HashMap<String, DependencySpec>,
58 #[serde(default)]
59 source: Option<RegistrySourceSpec>,
60}
61
62#[derive(Debug, Clone, Deserialize)]
63#[serde(tag = "type", rename_all = "lowercase")]
64enum RegistrySourceSpec {
65 Path {
66 path: String,
67 },
68 Bundle {
69 path: String,
70 },
71 Git {
72 url: String,
73 #[serde(default)]
74 rev: Option<String>,
75 #[serde(default)]
76 tag: Option<String>,
77 #[serde(default)]
78 branch: Option<String>,
79 },
80}
81
82#[derive(Debug, Clone)]
83struct RegistrySelection {
84 package: String,
85 version: Version,
86 dependencies: HashMap<String, DependencySpec>,
87 source: Option<RegistrySourceSpec>,
88 registry: String,
89}
90
91pub struct DependencyResolver {
93 project_root: PathBuf,
95 cache_dir: PathBuf,
97 registry_index_dir: PathBuf,
99 registry_src_dir: PathBuf,
101}
102
103impl DependencyResolver {
104 pub fn new(project_root: PathBuf) -> Option<Self> {
109 let home = dirs::home_dir()?;
110 let shape_home = home.join(".shape");
111 let cache_dir = shape_home.join("cache");
112 let default_registry_root = shape_home.join("registry");
113 let registry_index_dir = std::env::var_os("SHAPE_REGISTRY_INDEX")
114 .map(PathBuf::from)
115 .unwrap_or_else(|| default_registry_root.join("index"));
116 let registry_src_dir = std::env::var_os("SHAPE_REGISTRY_SRC")
117 .map(PathBuf::from)
118 .unwrap_or_else(|| default_registry_root.join("src"));
119 Some(Self {
120 project_root,
121 cache_dir,
122 registry_index_dir,
123 registry_src_dir,
124 })
125 }
126
127 pub fn with_cache_dir(project_root: PathBuf, cache_dir: PathBuf) -> Self {
129 let root = cache_dir
130 .parent()
131 .map(Path::to_path_buf)
132 .unwrap_or_else(|| cache_dir.clone());
133 let registry_root = root.join("registry");
134 Self {
135 project_root,
136 cache_dir,
137 registry_index_dir: registry_root.join("index"),
138 registry_src_dir: registry_root.join("src"),
139 }
140 }
141
142 pub fn with_paths(
144 project_root: PathBuf,
145 cache_dir: PathBuf,
146 registry_index_dir: PathBuf,
147 registry_src_dir: PathBuf,
148 ) -> Self {
149 Self {
150 project_root,
151 cache_dir,
152 registry_index_dir,
153 registry_src_dir,
154 }
155 }
156
157 pub fn resolve(
162 &self,
163 deps: &HashMap<String, DependencySpec>,
164 ) -> Result<Vec<ResolvedDependency>, String> {
165 let mut resolved_map: HashMap<String, ResolvedDependency> = HashMap::new();
166 let mut registry_constraints: HashMap<String, Vec<VersionReq>> = HashMap::new();
167
168 self.resolve_non_registry_graph(deps, &mut resolved_map, &mut registry_constraints)?;
169
170 if !registry_constraints.is_empty() {
171 let registry_deps = self.resolve_registry_packages(registry_constraints)?;
172 for dep in registry_deps {
173 if resolved_map.contains_key(&dep.name) {
174 return Err(format!(
175 "Dependency '{}' is declared from multiple sources (registry + non-registry)",
176 dep.name
177 ));
178 }
179 resolved_map.insert(dep.name.clone(), dep);
180 }
181 }
182
183 let resolved_vec: Vec<ResolvedDependency> = resolved_map.values().cloned().collect();
184
185 self.check_cycles(&resolved_vec)?;
187
188 let resolved_names: HashSet<String> = resolved_map.keys().cloned().collect();
190 let mut graph: HashMap<String, Vec<String>> = HashMap::new();
191 for name in &resolved_names {
192 graph.entry(name.clone()).or_default();
193 }
194 for dep in resolved_map.values() {
195 let edges = self.filtered_edges(dep, &resolved_names);
196 graph.insert(dep.name.clone(), edges);
197 }
198
199 let mut visited = HashSet::new();
201 let mut order = Vec::new();
202 for name in resolved_names {
203 if !visited.contains(&name) {
204 Self::topo_dfs(&name, &graph, &mut visited, &mut order);
205 }
206 }
207
208 let sorted: Vec<ResolvedDependency> = order
210 .into_iter()
211 .filter_map(|name| resolved_map.remove(&name))
212 .collect();
213
214 Ok(sorted)
215 }
216
217 fn resolve_non_registry_graph(
218 &self,
219 root_deps: &HashMap<String, DependencySpec>,
220 resolved_map: &mut HashMap<String, ResolvedDependency>,
221 registry_constraints: &mut HashMap<String, Vec<VersionReq>>,
222 ) -> Result<(), String> {
223 let mut pending: VecDeque<(PathBuf, String, DependencySpec)> = VecDeque::new();
224 for (name, spec) in root_deps {
225 pending.push_back((self.project_root.clone(), name.clone(), spec.clone()));
226 }
227
228 while let Some((owner_root, name, spec)) = pending.pop_front() {
229 if let Some(requirement) = Self::registry_requirement_for_spec(&spec)? {
230 let req = Self::parse_version_req(&name, &requirement)?;
231 let entry = registry_constraints.entry(name).or_default();
232 if !entry.iter().any(|existing| existing == &req) {
233 entry.push(req);
234 }
235 continue;
236 }
237
238 let dep = self.resolve_one_non_registry(&owner_root, &name, &spec)?;
239 if let Some(existing) = resolved_map.get(&name) {
240 Self::ensure_non_registry_compatible(existing, &dep)?;
241 continue;
242 }
243
244 let dep_path = dep.path.clone();
245 let source = dep.source.clone();
246 resolved_map.insert(name.clone(), dep);
247
248 if matches!(source, ResolvedDependencySource::Bundle) || !dep_path.is_dir() {
249 continue;
250 }
251 let Some(dep_specs) = self.read_dep_dependency_specs(&dep_path) else {
252 continue;
253 };
254 for (child_name, child_spec) in dep_specs {
255 pending.push_back((dep_path.clone(), child_name, child_spec));
256 }
257 }
258
259 Ok(())
260 }
261
262 fn ensure_non_registry_compatible(
263 existing: &ResolvedDependency,
264 candidate: &ResolvedDependency,
265 ) -> Result<(), String> {
266 if existing.path == candidate.path
267 && existing.version == candidate.version
268 && existing.source == candidate.source
269 {
270 return Ok(());
271 }
272 Err(format!(
273 "Dependency '{}' resolved to conflicting sources: '{}' ({:?}, {}) vs '{}' ({:?}, {})",
274 existing.name,
275 existing.path.display(),
276 existing.source,
277 existing.version,
278 candidate.path.display(),
279 candidate.source,
280 candidate.version
281 ))
282 }
283
284 fn filtered_edges(&self, dep: &ResolvedDependency, names: &HashSet<String>) -> Vec<String> {
285 if !dep.dependencies.is_empty() {
286 return dep
287 .dependencies
288 .iter()
289 .filter(|k| names.contains(*k))
290 .cloned()
291 .collect();
292 }
293
294 if dep.path.is_dir()
296 && let Some(deps) = self.read_dep_dependency_names(&dep.path)
297 {
298 return deps.into_iter().filter(|k| names.contains(k)).collect();
299 }
300
301 Vec::new()
302 }
303
304 fn topo_dfs(
306 node: &str,
307 graph: &HashMap<String, Vec<String>>,
308 visited: &mut HashSet<String>,
309 order: &mut Vec<String>,
310 ) {
311 visited.insert(node.to_string());
312 if let Some(neighbors) = graph.get(node) {
313 for neighbor in neighbors {
314 if !visited.contains(neighbor) {
315 Self::topo_dfs(neighbor, graph, visited, order);
316 }
317 }
318 }
319 order.push(node.to_string());
320 }
321
322 fn resolve_one_non_registry(
324 &self,
325 owner_root: &Path,
326 name: &str,
327 spec: &DependencySpec,
328 ) -> Result<ResolvedDependency, String> {
329 match spec {
330 DependencySpec::Version(version) => Err(format!(
331 "internal resolver error: registry dependency '{}@{}' reached non-registry path",
332 name, version
333 )),
334 DependencySpec::Detailed(detail) => {
335 if let Some(ref path_str) = detail.path {
336 self.resolve_path_dep(owner_root, name, path_str)
337 } else if let Some(ref git_url) = detail.git {
338 let git_ref = detail
339 .rev
340 .as_deref()
341 .or(detail.tag.as_deref())
342 .or(detail.branch.as_deref())
343 .unwrap_or("HEAD");
344 self.resolve_git_dep(name, git_url, git_ref)
345 } else if let Some(ref version) = detail.version {
346 Err(format!(
347 "internal resolver error: registry dependency '{}@{}' reached non-registry path",
348 name, version
349 ))
350 } else {
351 Err(format!(
352 "Dependency '{}' must specify 'path', 'git', or 'version'",
353 name
354 ))
355 }
356 }
357 }
358 }
359
360 fn resolve_path_dep(
366 &self,
367 owner_root: &Path,
368 name: &str,
369 path_str: &str,
370 ) -> Result<ResolvedDependency, String> {
371 let dep_path = owner_root.join(path_str);
372
373 if path_str.ends_with(".shapec") {
375 let canonical = dep_path.canonicalize().map_err(|e| {
376 format!(
377 "Bundle dependency '{}' at '{}' could not be resolved: {}",
378 name,
379 dep_path.display(),
380 e
381 )
382 })?;
383
384 if !canonical.exists() {
385 return Err(format!(
386 "Bundle dependency '{}' not found at '{}'",
387 name,
388 canonical.display()
389 ));
390 }
391
392 let bundle =
393 crate::package_bundle::PackageBundle::read_from_file(&canonical).map_err(|e| {
394 format!(
395 "Bundle dependency '{}' at '{}' is invalid: {}",
396 name,
397 canonical.display(),
398 e
399 )
400 })?;
401 if !bundle.metadata.bundle_kind.is_empty()
402 && bundle.metadata.bundle_kind != "portable-bytecode"
403 {
404 return Err(format!(
405 "Bundle dependency '{}' at '{}' has unsupported bundle_kind '{}'",
406 name,
407 canonical.display(),
408 bundle.metadata.bundle_kind
409 ));
410 }
411
412 let dependencies = bundle.dependencies.keys().cloned().collect();
413 return Ok(ResolvedDependency {
414 name: name.to_string(),
415 path: canonical,
416 version: bundle.metadata.version,
417 source: ResolvedDependencySource::Bundle,
418 dependencies,
419 });
420 }
421
422 let bundle_path = dep_path.with_extension("shapec");
424 if bundle_path.exists() {
425 let canonical = bundle_path.canonicalize().map_err(|e| {
426 format!(
427 "Bundle dependency '{}' at '{}' could not be resolved: {}",
428 name,
429 bundle_path.display(),
430 e
431 )
432 })?;
433 let bundle =
434 crate::package_bundle::PackageBundle::read_from_file(&canonical).map_err(|e| {
435 format!(
436 "Bundle dependency '{}' at '{}' is invalid: {}",
437 name,
438 canonical.display(),
439 e
440 )
441 })?;
442 if !bundle.metadata.bundle_kind.is_empty()
443 && bundle.metadata.bundle_kind != "portable-bytecode"
444 {
445 return Err(format!(
446 "Bundle dependency '{}' at '{}' has unsupported bundle_kind '{}'",
447 name,
448 canonical.display(),
449 bundle.metadata.bundle_kind
450 ));
451 }
452 let dependencies = bundle.dependencies.keys().cloned().collect();
453 return Ok(ResolvedDependency {
454 name: name.to_string(),
455 path: canonical,
456 version: bundle.metadata.version,
457 source: ResolvedDependencySource::Bundle,
458 dependencies,
459 });
460 }
461
462 let canonical = dep_path.canonicalize().map_err(|e| {
463 format!(
464 "Path dependency '{}' at '{}' could not be resolved: {}",
465 name,
466 dep_path.display(),
467 e
468 )
469 })?;
470
471 if !canonical.exists() {
472 return Err(format!(
473 "Path dependency '{}' not found at '{}'",
474 name,
475 canonical.display()
476 ));
477 }
478
479 let version = self
481 .read_dep_version(&canonical)
482 .unwrap_or_else(|| "local".to_string());
483 let dependencies = self
484 .read_dep_dependency_names(&canonical)
485 .unwrap_or_default();
486
487 Ok(ResolvedDependency {
488 name: name.to_string(),
489 path: canonical,
490 version,
491 source: ResolvedDependencySource::Path,
492 dependencies,
493 })
494 }
495
496 fn resolve_git_dep(
498 &self,
499 name: &str,
500 url: &str,
501 git_ref: &str,
502 ) -> Result<ResolvedDependency, String> {
503 use sha2::{Digest, Sha256};
505 let mut hasher = Sha256::new();
506 hasher.update(url.as_bytes());
507 let url_hash = format!("{:x}", hasher.finalize());
508 let short_hash = &url_hash[..16];
509
510 let git_cache = self
511 .cache_dir
512 .join("git")
513 .join(format!("{}-{}", name, short_hash));
514
515 if git_cache.join(".git").exists() {
517 let status = std::process::Command::new("git")
519 .args(["fetch", "--all"])
520 .current_dir(&git_cache)
521 .status()
522 .map_err(|e| format!("Failed to fetch git dep '{}': {}", name, e))?;
523
524 if !status.success() {
525 return Err(format!("git fetch failed for dependency '{}'", name));
526 }
527 } else {
528 std::fs::create_dir_all(&git_cache)
530 .map_err(|e| format!("Failed to create git cache dir for '{}': {}", name, e))?;
531
532 let status = std::process::Command::new("git")
533 .args(["clone", url, &git_cache.to_string_lossy()])
534 .status()
535 .map_err(|e| format!("Failed to clone git dep '{}': {}", name, e))?;
536
537 if !status.success() {
538 return Err(format!("git clone failed for dependency '{}'", name));
539 }
540 }
541
542 let status = std::process::Command::new("git")
544 .args(["checkout", git_ref])
545 .current_dir(&git_cache)
546 .status()
547 .map_err(|e| format!("Failed to checkout '{}' for dep '{}': {}", git_ref, name, e))?;
548
549 if !status.success() {
550 return Err(format!(
551 "git checkout '{}' failed for dependency '{}'",
552 git_ref, name
553 ));
554 }
555
556 let rev_output = std::process::Command::new("git")
558 .args(["rev-parse", "HEAD"])
559 .current_dir(&git_cache)
560 .output()
561 .map_err(|e| format!("Failed to get git rev for dep '{}': {}", name, e))?;
562
563 let rev = String::from_utf8_lossy(&rev_output.stdout)
564 .trim()
565 .to_string();
566 let dependencies = self
567 .read_dep_dependency_names(&git_cache)
568 .unwrap_or_default();
569
570 Ok(ResolvedDependency {
571 name: name.to_string(),
572 path: git_cache,
573 version: rev.clone(),
574 source: ResolvedDependencySource::Git {
575 url: url.to_string(),
576 rev,
577 },
578 dependencies,
579 })
580 }
581
582 fn read_dep_version(&self, dep_path: &Path) -> Option<String> {
584 let toml_path = dep_path.join("shape.toml");
585 let content = std::fs::read_to_string(toml_path).ok()?;
586 let config = crate::project::parse_shape_project_toml(&content).ok()?;
587 if config.project.version.is_empty() {
588 None
589 } else {
590 Some(config.project.version)
591 }
592 }
593
594 fn read_dep_dependency_specs(
596 &self,
597 dep_path: &Path,
598 ) -> Option<HashMap<String, DependencySpec>> {
599 let toml_path = dep_path.join("shape.toml");
600 let content = std::fs::read_to_string(toml_path).ok()?;
601 let config = crate::project::parse_shape_project_toml(&content).ok()?;
602 Some(config.dependencies)
603 }
604
605 fn read_dep_dependency_names(&self, dep_path: &Path) -> Option<Vec<String>> {
607 self.read_dep_dependency_specs(dep_path)
608 .map(|deps| deps.into_keys().collect())
609 }
610
611 fn registry_requirement_for_spec(spec: &DependencySpec) -> Result<Option<String>, String> {
612 match spec {
613 DependencySpec::Version(version) => Ok(Some(version.clone())),
614 DependencySpec::Detailed(detail) => {
615 if detail.path.is_some() || detail.git.is_some() {
616 return Ok(None);
618 }
619 Ok(detail.version.clone())
620 }
621 }
622 }
623
624 fn parse_version_req(name: &str, req: &str) -> Result<VersionReq, String> {
625 VersionReq::parse(req).map_err(|err| {
626 format!(
627 "Invalid semver requirement for dependency '{}': '{}': {}",
628 name, req, err
629 )
630 })
631 }
632
633 fn resolve_registry_packages(
634 &self,
635 mut constraints: HashMap<String, Vec<VersionReq>>,
636 ) -> Result<Vec<ResolvedDependency>, String> {
637 let mut selected: HashMap<String, RegistrySelection> = HashMap::new();
638 self.solve_registry_constraints(&mut constraints, &mut selected)?;
639
640 let mut resolved = Vec::with_capacity(selected.len());
641 for selection in selected.into_values() {
642 resolved.push(self.materialize_registry_selection(selection)?);
643 }
644 Ok(resolved)
645 }
646
647 fn solve_registry_constraints(
648 &self,
649 constraints: &mut HashMap<String, Vec<VersionReq>>,
650 selected: &mut HashMap<String, RegistrySelection>,
651 ) -> Result<(), String> {
652 loop {
653 for (pkg, reqs) in constraints.iter() {
654 if let Some(chosen) = selected.get(pkg)
655 && !reqs.iter().all(|req| req.matches(&chosen.version))
656 {
657 return Err(format!(
658 "Selected registry version '{}' for '{}' does not satisfy constraints [{}]",
659 chosen.version,
660 pkg,
661 reqs.iter()
662 .map(ToString::to_string)
663 .collect::<Vec<_>>()
664 .join(", ")
665 ));
666 }
667 }
668
669 let mut changed = false;
670 let snapshot: Vec<(String, Version, HashMap<String, DependencySpec>)> = selected
671 .iter()
672 .map(|(name, selection)| {
673 (
674 name.clone(),
675 selection.version.clone(),
676 selection.dependencies.clone(),
677 )
678 })
679 .collect();
680
681 for (pkg_name, pkg_version, deps) in snapshot {
682 for (dep_name, dep_spec) in deps {
683 let Some(dep_req_str) = Self::registry_requirement_for_spec(&dep_spec)? else {
684 return Err(format!(
685 "Registry package '{}@{}' declares non-registry dependency '{}' (path/git dependencies inside registry index are not supported)",
686 pkg_name, pkg_version, dep_name
687 ));
688 };
689 let dep_req = Self::parse_version_req(&dep_name, &dep_req_str)?;
690 let reqs = constraints.entry(dep_name).or_default();
691 if !reqs.iter().any(|existing| existing == &dep_req) {
692 reqs.push(dep_req);
693 changed = true;
694 }
695 }
696 }
697
698 if !changed {
699 break;
700 }
701 }
702
703 let unresolved: Vec<String> = constraints
704 .keys()
705 .filter(|name| !selected.contains_key(*name))
706 .cloned()
707 .collect();
708 if unresolved.is_empty() {
709 return Ok(());
710 }
711
712 let mut choice: Option<(String, Vec<RegistrySelection>)> = None;
713 for package in unresolved {
714 let reqs = constraints.get(&package).cloned().unwrap_or_default();
715 let candidates = self.registry_candidates_for(&package, &reqs)?;
716 if candidates.is_empty() {
717 return Err(format!(
718 "No registry versions satisfy constraints for '{}': [{}]",
719 package,
720 reqs.iter()
721 .map(ToString::to_string)
722 .collect::<Vec<_>>()
723 .join(", ")
724 ));
725 }
726 if choice
727 .as_ref()
728 .map(|(_, current)| candidates.len() < current.len())
729 .unwrap_or(true)
730 {
731 choice = Some((package, candidates));
732 }
733 }
734
735 let (package, candidates) =
736 choice.ok_or_else(|| "registry solver failed to choose a package".to_string())?;
737 let mut last_err: Option<String> = None;
738 for candidate in candidates {
739 let mut next_constraints = constraints.clone();
740 let mut next_selected = selected.clone();
741 next_selected.insert(package.clone(), candidate);
742 match self.solve_registry_constraints(&mut next_constraints, &mut next_selected) {
743 Ok(()) => {
744 *constraints = next_constraints;
745 *selected = next_selected;
746 return Ok(());
747 }
748 Err(err) => {
749 last_err = Some(err);
750 }
751 }
752 }
753
754 Err(last_err.unwrap_or_else(|| {
755 format!(
756 "Unable to resolve registry package '{}' with current constraints",
757 package
758 )
759 }))
760 }
761
762 fn registry_candidates_for(
763 &self,
764 package: &str,
765 reqs: &[VersionReq],
766 ) -> Result<Vec<RegistrySelection>, String> {
767 let index = self.load_registry_index(package)?;
768 if index
769 .package
770 .as_deref()
771 .is_some_and(|declared| declared != package)
772 {
773 return Err(format!(
774 "Registry index entry '{}' does not match requested package '{}'",
775 index.package.unwrap_or_default(),
776 package
777 ));
778 }
779
780 let mut out = Vec::new();
781 for version in index.versions {
782 if version.yanked {
783 continue;
784 }
785 let parsed = Version::parse(&version.version).map_err(|err| {
786 format!(
787 "Registry package '{}' contains invalid version '{}': {}",
788 package, version.version, err
789 )
790 })?;
791 if reqs.iter().all(|req| req.matches(&parsed)) {
792 out.push(RegistrySelection {
793 package: package.to_string(),
794 version: parsed,
795 dependencies: version.dependencies,
796 source: version.source,
797 registry: "default".to_string(),
798 });
799 }
800 }
801
802 out.sort_by(|a, b| b.version.cmp(&a.version));
803 Ok(out)
804 }
805
806 fn load_registry_index(&self, package: &str) -> Result<RegistryIndexFile, String> {
807 let toml_path = self.registry_index_dir.join(format!("{package}.toml"));
808 let json_path = self.registry_index_dir.join(format!("{package}.json"));
809
810 if toml_path.exists() {
811 let content = std::fs::read_to_string(&toml_path).map_err(|err| {
812 format!(
813 "Failed to read registry index '{}': {}",
814 toml_path.display(),
815 err
816 )
817 })?;
818 return toml::from_str(&content).map_err(|err| {
819 format!(
820 "Failed to parse registry index '{}': {}",
821 toml_path.display(),
822 err
823 )
824 });
825 }
826
827 if json_path.exists() {
828 let content = std::fs::read_to_string(&json_path).map_err(|err| {
829 format!(
830 "Failed to read registry index '{}': {}",
831 json_path.display(),
832 err
833 )
834 })?;
835 return serde_json::from_str(&content).map_err(|err| {
836 format!(
837 "Failed to parse registry index '{}': {}",
838 json_path.display(),
839 err
840 )
841 });
842 }
843
844 Err(format!(
845 "Registry package '{}' not found in index '{}' (expected {}.toml or {}.json)",
846 package,
847 self.registry_index_dir.display(),
848 package,
849 package
850 ))
851 }
852
853 fn resolve_registry_source_path(&self, raw: &str) -> PathBuf {
854 let path = PathBuf::from(raw);
855 if path.is_absolute() {
856 return path;
857 }
858 let registry_root = self
859 .registry_index_dir
860 .parent()
861 .map(Path::to_path_buf)
862 .unwrap_or_else(|| self.registry_index_dir.clone());
863 registry_root.join(path)
864 }
865
866 fn materialize_registry_selection(
867 &self,
868 selection: RegistrySelection,
869 ) -> Result<ResolvedDependency, String> {
870 let package_name = selection.package.clone();
871 let package_version = selection.version.to_string();
872 let dependency_names: Vec<String> = selection.dependencies.keys().cloned().collect();
873
874 let resolved_path = match selection.source.clone() {
875 Some(RegistrySourceSpec::Path { path }) => {
876 let concrete = self.resolve_registry_source_path(&path);
877 concrete.canonicalize().map_err(|err| {
878 format!(
879 "Registry dependency '{}@{}' path '{}' could not be resolved: {}",
880 package_name,
881 package_version,
882 concrete.display(),
883 err
884 )
885 })?
886 }
887 Some(RegistrySourceSpec::Bundle { path }) => {
888 let concrete = self.resolve_registry_source_path(&path);
889 let canonical = concrete.canonicalize().map_err(|err| {
890 format!(
891 "Registry bundle '{}@{}' path '{}' could not be resolved: {}",
892 package_name,
893 package_version,
894 concrete.display(),
895 err
896 )
897 })?;
898 let bundle = crate::package_bundle::PackageBundle::read_from_file(&canonical)
899 .map_err(|err| {
900 format!(
901 "Registry bundle '{}@{}' at '{}' is invalid: {}",
902 package_name,
903 package_version,
904 canonical.display(),
905 err
906 )
907 })?;
908 if !bundle.metadata.bundle_kind.is_empty()
909 && bundle.metadata.bundle_kind != "portable-bytecode"
910 {
911 return Err(format!(
912 "Registry bundle '{}@{}' has unsupported bundle_kind '{}'",
913 package_name, package_version, bundle.metadata.bundle_kind
914 ));
915 }
916 canonical
917 }
918 Some(RegistrySourceSpec::Git {
919 url,
920 rev,
921 tag,
922 branch,
923 }) => {
924 let git_ref = rev.or(tag).or(branch).unwrap_or_else(|| "HEAD".to_string());
925 let dep = self.resolve_git_dep(&package_name, &url, &git_ref)?;
926 dep.path
927 }
928 None => {
929 let flattened = self
930 .registry_src_dir
931 .join(format!("{}-{}", package_name, package_version));
932 if flattened.exists() {
933 flattened.canonicalize().map_err(|err| {
934 format!(
935 "Registry source cache path '{}' could not be resolved: {}",
936 flattened.display(),
937 err
938 )
939 })?
940 } else {
941 let nested = self
942 .registry_src_dir
943 .join(&package_name)
944 .join(&package_version);
945 nested.canonicalize().map_err(|err| {
946 format!(
947 "Registry dependency '{}@{}' source not found in '{}': {}",
948 package_name,
949 package_version,
950 self.registry_src_dir.display(),
951 err
952 )
953 })?
954 }
955 }
956 };
957
958 Ok(ResolvedDependency {
959 name: package_name,
960 path: resolved_path,
961 version: package_version,
962 source: ResolvedDependencySource::Registry {
963 registry: selection.registry,
964 },
965 dependencies: dependency_names,
966 })
967 }
968
969 fn check_cycles(&self, resolved: &[ResolvedDependency]) -> Result<(), String> {
971 let mut graph: HashMap<String, Vec<String>> = HashMap::new();
973 let resolved_names: HashSet<String> = resolved.iter().map(|d| d.name.clone()).collect();
974
975 for dep in resolved {
976 let edges = self.filtered_edges(dep, &resolved_names);
977 graph.insert(dep.name.clone(), edges);
978 graph.entry(dep.name.clone()).or_default();
979 }
980
981 let mut visited = HashSet::new();
983 let mut in_stack = HashSet::new();
984
985 for name in graph.keys() {
986 if !visited.contains(name) {
987 if let Some(cycle) = Self::dfs_cycle(name, &graph, &mut visited, &mut in_stack) {
988 return Err(format!(
989 "Circular dependency detected: {}",
990 cycle.join(" -> ")
991 ));
992 }
993 }
994 }
995
996 Ok(())
997 }
998
999 fn dfs_cycle(
1000 node: &str,
1001 graph: &HashMap<String, Vec<String>>,
1002 visited: &mut HashSet<String>,
1003 in_stack: &mut HashSet<String>,
1004 ) -> Option<Vec<String>> {
1005 visited.insert(node.to_string());
1006 in_stack.insert(node.to_string());
1007
1008 if let Some(neighbors) = graph.get(node) {
1009 for neighbor in neighbors {
1010 if !visited.contains(neighbor) {
1011 if let Some(mut cycle) = Self::dfs_cycle(neighbor, graph, visited, in_stack) {
1012 cycle.insert(0, node.to_string());
1013 return Some(cycle);
1014 }
1015 } else if in_stack.contains(neighbor) {
1016 return Some(vec![node.to_string(), neighbor.clone()]);
1017 }
1018 }
1019 }
1020
1021 in_stack.remove(node);
1022 None
1023 }
1024}
1025
1026#[cfg(test)]
1027mod tests {
1028 use super::*;
1029 use crate::project::DetailedDependency;
1030
1031 fn make_path_dep(path: &str) -> DependencySpec {
1032 DependencySpec::Detailed(DetailedDependency {
1033 version: None,
1034 path: Some(path.to_string()),
1035 git: None,
1036 tag: None,
1037 branch: None,
1038 rev: None,
1039 permissions: None,
1040 })
1041 }
1042
1043 fn make_version_dep(req: &str) -> DependencySpec {
1044 DependencySpec::Version(req.to_string())
1045 }
1046
1047 #[test]
1048 fn test_resolve_path_dep() {
1049 let tmp = tempfile::tempdir().unwrap();
1050 let project_root = tmp.path().to_path_buf();
1051
1052 let dep_dir = tmp.path().join("my-utils");
1054 std::fs::create_dir_all(&dep_dir).unwrap();
1055 std::fs::write(dep_dir.join("index.shape"), "pub fn greet() { \"hello\" }").unwrap();
1056
1057 let resolver = DependencyResolver::with_cache_dir(project_root, tmp.path().join("cache"));
1058
1059 let mut deps = HashMap::new();
1060 deps.insert("my-utils".to_string(), make_path_dep("./my-utils"));
1061
1062 let resolved = resolver.resolve(&deps).unwrap();
1063 assert_eq!(resolved.len(), 1);
1064 assert_eq!(resolved[0].name, "my-utils");
1065 assert!(resolved[0].path.exists());
1066 assert_eq!(resolved[0].version, "local");
1067 }
1068
1069 #[test]
1070 fn test_resolve_path_dep_with_version() {
1071 let tmp = tempfile::tempdir().unwrap();
1072 let project_root = tmp.path().to_path_buf();
1073
1074 let dep_dir = tmp.path().join("my-lib");
1076 std::fs::create_dir_all(&dep_dir).unwrap();
1077 std::fs::write(
1078 dep_dir.join("shape.toml"),
1079 "[project]\nname = \"my-lib\"\nversion = \"0.3.1\"\n",
1080 )
1081 .unwrap();
1082
1083 let resolver = DependencyResolver::with_cache_dir(project_root, tmp.path().join("cache"));
1084
1085 let mut deps = HashMap::new();
1086 deps.insert("my-lib".to_string(), make_path_dep("./my-lib"));
1087
1088 let resolved = resolver.resolve(&deps).unwrap();
1089 assert_eq!(resolved[0].version, "0.3.1");
1090 }
1091
1092 #[test]
1093 fn test_resolve_transitive_path_dep_relative_to_owner_root() {
1094 let tmp = tempfile::tempdir().unwrap();
1095 let project_root = tmp.path().to_path_buf();
1096
1097 let dep_a = tmp.path().join("dep-a");
1098 let dep_b = dep_a.join("dep-b");
1099 std::fs::create_dir_all(&dep_b).unwrap();
1100 std::fs::write(
1101 dep_a.join("shape.toml"),
1102 r#"
1103[project]
1104name = "dep-a"
1105version = "0.1.0"
1106
1107[dependencies]
1108dep-b = { path = "./dep-b" }
1109"#,
1110 )
1111 .unwrap();
1112 std::fs::write(
1113 dep_b.join("shape.toml"),
1114 r#"
1115[project]
1116name = "dep-b"
1117version = "0.2.0"
1118"#,
1119 )
1120 .unwrap();
1121
1122 let resolver = DependencyResolver::with_cache_dir(project_root, tmp.path().join("cache"));
1123 let mut deps = HashMap::new();
1124 deps.insert("dep-a".to_string(), make_path_dep("./dep-a"));
1125
1126 let resolved = resolver
1127 .resolve(&deps)
1128 .expect("transitive path deps should resolve");
1129 let by_name: HashMap<_, _> = resolved
1130 .iter()
1131 .map(|dep| (dep.name.clone(), dep.path.clone()))
1132 .collect();
1133
1134 assert!(by_name.contains_key("dep-a"));
1135 let dep_b_path = by_name
1136 .get("dep-b")
1137 .expect("dep-b should be resolved transitively");
1138 assert!(
1139 dep_b_path.starts_with(dep_a.canonicalize().unwrap()),
1140 "dep-b path should resolve relative to dep-a root"
1141 );
1142 }
1143
1144 #[test]
1145 fn test_resolve_missing_path_dep() {
1146 let tmp = tempfile::tempdir().unwrap();
1147 let resolver =
1148 DependencyResolver::with_cache_dir(tmp.path().to_path_buf(), tmp.path().join("cache"));
1149
1150 let mut deps = HashMap::new();
1151 deps.insert("missing".to_string(), make_path_dep("./does-not-exist"));
1152
1153 let result = resolver.resolve(&deps);
1154 assert!(result.is_err());
1155 assert!(result.unwrap_err().contains("could not be resolved"));
1156 }
1157
1158 #[test]
1159 fn test_resolve_version_dep_requires_registry_entry() {
1160 let tmp = tempfile::tempdir().unwrap();
1161 let resolver =
1162 DependencyResolver::with_cache_dir(tmp.path().to_path_buf(), tmp.path().join("cache"));
1163
1164 let mut deps = HashMap::new();
1165 deps.insert("pkg".to_string(), make_version_dep("1.0.0"));
1166
1167 let result = resolver.resolve(&deps);
1168 assert!(result.is_err());
1169 assert!(
1170 result.unwrap_err().contains("Registry package 'pkg'"),
1171 "missing registry package should produce explicit error"
1172 );
1173 }
1174
1175 #[test]
1176 fn test_resolve_registry_dep_selects_highest_compatible_version() {
1177 let tmp = tempfile::tempdir().unwrap();
1178 let project_root = tmp.path().join("project");
1179 let cache_dir = tmp.path().join("cache");
1180 let registry_index = tmp.path().join("registry").join("index");
1181 let registry_src = tmp.path().join("registry").join("src");
1182 std::fs::create_dir_all(&project_root).unwrap();
1183 std::fs::create_dir_all(&cache_dir).unwrap();
1184 std::fs::create_dir_all(®istry_index).unwrap();
1185 std::fs::create_dir_all(®istry_src).unwrap();
1186
1187 let pkg_v1 = registry_src.join("pkg-1.0.0");
1188 let pkg_v12 = registry_src.join("pkg-1.2.0");
1189 std::fs::create_dir_all(&pkg_v1).unwrap();
1190 std::fs::create_dir_all(&pkg_v12).unwrap();
1191 std::fs::write(
1192 pkg_v1.join("shape.toml"),
1193 "[project]\nname = \"pkg\"\nversion = \"1.0.0\"\n",
1194 )
1195 .unwrap();
1196 std::fs::write(
1197 pkg_v12.join("shape.toml"),
1198 "[project]\nname = \"pkg\"\nversion = \"1.2.0\"\n",
1199 )
1200 .unwrap();
1201
1202 std::fs::write(
1203 registry_index.join("pkg.toml"),
1204 r#"
1205package = "pkg"
1206
1207[[versions]]
1208version = "1.0.0"
1209
1210[[versions]]
1211version = "1.2.0"
1212"#,
1213 )
1214 .unwrap();
1215
1216 let resolver =
1217 DependencyResolver::with_paths(project_root, cache_dir, registry_index, registry_src);
1218
1219 let mut deps = HashMap::new();
1220 deps.insert("pkg".to_string(), make_version_dep("^1.0"));
1221 let resolved = resolver
1222 .resolve(&deps)
1223 .expect("registry dep should resolve");
1224 assert_eq!(resolved.len(), 1);
1225 assert_eq!(resolved[0].name, "pkg");
1226 assert_eq!(resolved[0].version, "1.2.0");
1227 assert!(
1228 matches!(
1229 resolved[0].source,
1230 ResolvedDependencySource::Registry { .. }
1231 ),
1232 "expected registry source"
1233 );
1234 assert!(
1235 resolved[0].path.to_string_lossy().contains("pkg-1.2.0"),
1236 "expected highest compatible version path"
1237 );
1238 }
1239
1240 #[test]
1241 fn test_transitive_registry_dep_from_path_package() {
1242 let tmp = tempfile::tempdir().unwrap();
1243 let project_root = tmp.path().join("project");
1244 let cache_dir = tmp.path().join("cache");
1245 let registry_index = tmp.path().join("registry").join("index");
1246 let registry_src = tmp.path().join("registry").join("src");
1247 std::fs::create_dir_all(&project_root).unwrap();
1248 std::fs::create_dir_all(&cache_dir).unwrap();
1249 std::fs::create_dir_all(®istry_index).unwrap();
1250 std::fs::create_dir_all(®istry_src).unwrap();
1251
1252 let dep_a = project_root.join("dep-a");
1253 std::fs::create_dir_all(&dep_a).unwrap();
1254 std::fs::write(
1255 dep_a.join("shape.toml"),
1256 r#"
1257[project]
1258name = "dep-a"
1259version = "0.4.0"
1260
1261[dependencies]
1262pkg = "^1.0"
1263"#,
1264 )
1265 .unwrap();
1266
1267 let pkg_dir = registry_src.join("pkg-1.4.2");
1268 std::fs::create_dir_all(&pkg_dir).unwrap();
1269 std::fs::write(
1270 pkg_dir.join("shape.toml"),
1271 "[project]\nname = \"pkg\"\nversion = \"1.4.2\"\n",
1272 )
1273 .unwrap();
1274 std::fs::write(
1275 registry_index.join("pkg.toml"),
1276 r#"
1277package = "pkg"
1278
1279[[versions]]
1280version = "1.4.2"
1281"#,
1282 )
1283 .unwrap();
1284
1285 let resolver =
1286 DependencyResolver::with_paths(project_root, cache_dir, registry_index, registry_src);
1287 let mut deps = HashMap::new();
1288 deps.insert("dep-a".to_string(), make_path_dep("./dep-a"));
1289
1290 let resolved = resolver
1291 .resolve(&deps)
1292 .expect("path dep should propagate transitive registry constraints");
1293 let by_name: HashMap<_, _> = resolved
1294 .iter()
1295 .map(|dep| (dep.name.clone(), dep.version.clone()))
1296 .collect();
1297 assert_eq!(by_name.get("dep-a"), Some(&"0.4.0".to_string()));
1298 assert_eq!(by_name.get("pkg"), Some(&"1.4.2".to_string()));
1299 }
1300
1301 #[test]
1302 fn test_registry_semver_solver_backtracks_across_transitive_constraints() {
1303 let tmp = tempfile::tempdir().unwrap();
1304 let project_root = tmp.path().join("project");
1305 let cache_dir = tmp.path().join("cache");
1306 let registry_index = tmp.path().join("registry").join("index");
1307 let registry_src = tmp.path().join("registry").join("src");
1308 std::fs::create_dir_all(&project_root).unwrap();
1309 std::fs::create_dir_all(&cache_dir).unwrap();
1310 std::fs::create_dir_all(®istry_index).unwrap();
1311 std::fs::create_dir_all(®istry_src).unwrap();
1312
1313 for (pkg, ver) in [
1314 ("a", "1.0.0"),
1315 ("a", "1.1.0"),
1316 ("b", "1.0.0"),
1317 ("c", "1.5.0"),
1318 ("c", "2.1.0"),
1319 ] {
1320 let dir = registry_src.join(format!("{pkg}-{ver}"));
1321 std::fs::create_dir_all(&dir).unwrap();
1322 std::fs::write(
1323 dir.join("shape.toml"),
1324 format!("[project]\nname = \"{pkg}\"\nversion = \"{ver}\"\n"),
1325 )
1326 .unwrap();
1327 }
1328
1329 std::fs::write(
1330 registry_index.join("a.toml"),
1331 r#"
1332package = "a"
1333
1334[[versions]]
1335version = "1.0.0"
1336[versions.dependencies]
1337c = "^1.0"
1338
1339[[versions]]
1340version = "1.1.0"
1341[versions.dependencies]
1342c = "^2.0"
1343"#,
1344 )
1345 .unwrap();
1346 std::fs::write(
1347 registry_index.join("b.toml"),
1348 r#"
1349package = "b"
1350
1351[[versions]]
1352version = "1.0.0"
1353[versions.dependencies]
1354c = "^2.0"
1355"#,
1356 )
1357 .unwrap();
1358 std::fs::write(
1359 registry_index.join("c.toml"),
1360 r#"
1361package = "c"
1362
1363[[versions]]
1364version = "1.5.0"
1365
1366[[versions]]
1367version = "2.1.0"
1368"#,
1369 )
1370 .unwrap();
1371
1372 let resolver =
1373 DependencyResolver::with_paths(project_root, cache_dir, registry_index, registry_src);
1374
1375 let mut deps = HashMap::new();
1376 deps.insert("a".to_string(), make_version_dep("^1.0"));
1377 deps.insert("b".to_string(), make_version_dep("^1.0"));
1378
1379 let resolved = resolver
1380 .resolve(&deps)
1381 .expect("solver should backtrack and resolve");
1382 let by_name: HashMap<_, _> = resolved
1383 .iter()
1384 .map(|dep| (dep.name.clone(), dep.version.clone()))
1385 .collect();
1386
1387 assert_eq!(by_name.get("a"), Some(&"1.1.0".to_string()));
1388 assert_eq!(by_name.get("b"), Some(&"1.0.0".to_string()));
1389 assert_eq!(by_name.get("c"), Some(&"2.1.0".to_string()));
1390 }
1391
1392 #[test]
1393 fn test_cycle_detection() {
1394 let tmp = tempfile::tempdir().unwrap();
1395
1396 let pkg_a = tmp.path().join("pkg-a");
1398 let pkg_b = tmp.path().join("pkg-b");
1399 std::fs::create_dir_all(&pkg_a).unwrap();
1400 std::fs::create_dir_all(&pkg_b).unwrap();
1401
1402 std::fs::write(
1403 pkg_a.join("shape.toml"),
1404 "[project]\nname = \"pkg-a\"\nversion = \"0.1.0\"\n\n[dependencies]\npkg-b = { path = \"../pkg-b\" }\n",
1405 ).unwrap();
1406
1407 std::fs::write(
1408 pkg_b.join("shape.toml"),
1409 "[project]\nname = \"pkg-b\"\nversion = \"0.1.0\"\n\n[dependencies]\npkg-a = { path = \"../pkg-a\" }\n",
1410 ).unwrap();
1411
1412 let resolver =
1413 DependencyResolver::with_cache_dir(tmp.path().to_path_buf(), tmp.path().join("cache"));
1414
1415 let mut deps = HashMap::new();
1416 deps.insert("pkg-a".to_string(), make_path_dep("./pkg-a"));
1417 deps.insert("pkg-b".to_string(), make_path_dep("./pkg-b"));
1418
1419 let result = resolver.resolve(&deps);
1420 assert!(result.is_err());
1421 assert!(
1422 result.unwrap_err().contains("Circular dependency"),
1423 "Should detect circular dependency"
1424 );
1425 }
1426
1427 #[test]
1428 fn test_git_dep_validation() {
1429 let tmp = tempfile::tempdir().unwrap();
1430 let resolver =
1431 DependencyResolver::with_cache_dir(tmp.path().to_path_buf(), tmp.path().join("cache"));
1432
1433 let mut deps = HashMap::new();
1435 deps.insert(
1436 "bad-git".to_string(),
1437 DependencySpec::Detailed(DetailedDependency {
1438 version: None,
1439 path: None,
1440 git: Some("not-a-valid-url".to_string()),
1441 tag: None,
1442 branch: None,
1443 rev: Some("abc123".to_string()),
1444 permissions: None,
1445 }),
1446 );
1447
1448 let result = resolver.resolve(&deps);
1449 assert!(result.is_err(), "Invalid git URL should fail");
1450 }
1451
1452 #[test]
1453 fn test_resolve_shapec_bundle_explicit_path() {
1454 let tmp = tempfile::tempdir().unwrap();
1455 let project_root = tmp.path().to_path_buf();
1456
1457 let bundle = crate::package_bundle::PackageBundle {
1459 metadata: crate::package_bundle::BundleMetadata {
1460 name: "my-lib".to_string(),
1461 version: "1.0.0".to_string(),
1462 compiler_version: "test".to_string(),
1463 source_hash: "abc123".to_string(),
1464 bundle_kind: "portable-bytecode".to_string(),
1465 build_host: "x86_64-linux".to_string(),
1466 native_portable: true,
1467 entry_module: None,
1468 built_at: 0,
1469 },
1470 modules: vec![],
1471 dependencies: std::collections::HashMap::new(),
1472 blob_store: std::collections::HashMap::new(),
1473 manifests: vec![],
1474 native_dependency_scopes: vec![],
1475 };
1476
1477 let bundle_path = tmp.path().join("my-lib.shapec");
1478 bundle.write_to_file(&bundle_path).unwrap();
1479
1480 let resolver = DependencyResolver::with_cache_dir(project_root, tmp.path().join("cache"));
1481
1482 let mut deps = HashMap::new();
1483 deps.insert("my-lib".to_string(), make_path_dep("./my-lib.shapec"));
1484
1485 let resolved = resolver.resolve(&deps).unwrap();
1486 assert_eq!(resolved.len(), 1);
1487 assert_eq!(resolved[0].name, "my-lib");
1488 assert_eq!(resolved[0].version, "1.0.0");
1489 assert!(resolved[0].path.to_string_lossy().ends_with(".shapec"));
1490 }
1491
1492 #[test]
1493 fn test_resolve_prefers_bundle_over_directory() {
1494 let tmp = tempfile::tempdir().unwrap();
1495 let project_root = tmp.path().to_path_buf();
1496
1497 let dep_dir = tmp.path().join("my-utils");
1499 std::fs::create_dir_all(&dep_dir).unwrap();
1500 std::fs::write(dep_dir.join("index.shape"), "pub fn greet() { \"hello\" }").unwrap();
1501
1502 let bundle = crate::package_bundle::PackageBundle {
1503 metadata: crate::package_bundle::BundleMetadata {
1504 name: "my-utils".to_string(),
1505 version: "1.0.0".to_string(),
1506 compiler_version: "test".to_string(),
1507 source_hash: "abc123".to_string(),
1508 bundle_kind: "portable-bytecode".to_string(),
1509 build_host: "x86_64-linux".to_string(),
1510 native_portable: true,
1511 entry_module: None,
1512 built_at: 0,
1513 },
1514 modules: vec![],
1515 dependencies: std::collections::HashMap::new(),
1516 blob_store: std::collections::HashMap::new(),
1517 manifests: vec![],
1518 native_dependency_scopes: vec![],
1519 };
1520 let bundle_path = tmp.path().join("my-utils.shapec");
1521 bundle.write_to_file(&bundle_path).unwrap();
1522
1523 let resolver = DependencyResolver::with_cache_dir(project_root, tmp.path().join("cache"));
1524
1525 let mut deps = HashMap::new();
1526 deps.insert("my-utils".to_string(), make_path_dep("./my-utils"));
1527
1528 let resolved = resolver.resolve(&deps).unwrap();
1529 assert_eq!(resolved.len(), 1);
1530 assert_eq!(resolved[0].version, "1.0.0");
1531 assert!(resolved[0].path.to_string_lossy().ends_with(".shapec"));
1532 }
1533
1534 #[test]
1535 fn test_dep_without_source() {
1536 let tmp = tempfile::tempdir().unwrap();
1537 let resolver =
1538 DependencyResolver::with_cache_dir(tmp.path().to_path_buf(), tmp.path().join("cache"));
1539
1540 let mut deps = HashMap::new();
1541 deps.insert(
1542 "empty".to_string(),
1543 DependencySpec::Detailed(DetailedDependency {
1544 version: None,
1545 path: None,
1546 git: None,
1547 tag: None,
1548 branch: None,
1549 rev: None,
1550 permissions: None,
1551 }),
1552 );
1553
1554 let result = resolver.resolve(&deps);
1555 assert!(result.is_err());
1556 assert!(result.unwrap_err().contains("must specify"));
1557 }
1558}