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