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