Skip to main content

shape_runtime/
dependency_resolver.rs

1//! Dependency resolution from shape.toml
2//!
3//! Resolves `[dependencies]` entries to concrete local paths:
4//! - **Path deps**: resolved relative to the project root.
5//! - **Git deps**: cloned/fetched into `~/.shape/cache/git/` and checked out.
6//! - **Version deps**: resolved from a local registry index with semver solving.
7
8use semver::{Version, VersionReq};
9use serde::Deserialize;
10use std::collections::{HashMap, HashSet, VecDeque};
11use std::path::{Path, PathBuf};
12
13use crate::project::DependencySpec;
14
15/// Source classification for a resolved dependency.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum ResolvedDependencySource {
18    /// Local source directory.
19    Path,
20    /// Git checkout cached under `~/.shape/cache/git`.
21    Git { url: String, rev: String },
22    /// Precompiled `.shapec` bundle path.
23    Bundle,
24    /// Version-selected registry package.
25    Registry { registry: String },
26}
27
28/// A fully resolved dependency ready for the module loader.
29#[derive(Debug, Clone)]
30pub struct ResolvedDependency {
31    /// Package name (matches the key in `[dependencies]`).
32    pub name: String,
33    /// Absolute local path to the dependency source directory.
34    pub path: PathBuf,
35    /// Resolved version string (or git rev, or "local").
36    pub version: String,
37    /// Resolved source kind.
38    pub source: ResolvedDependencySource,
39    /// Direct dependency names declared by this package.
40    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
97/// Resolves dependency specs to local filesystem paths.
98pub struct DependencyResolver {
99    /// Root directory of the current project (contains shape.toml).
100    project_root: PathBuf,
101    /// Global cache directory (`~/.shape/cache/`).
102    cache_dir: PathBuf,
103    /// Registry index directory (`~/.shape/registry/index` by default).
104    registry_index_dir: PathBuf,
105    /// Registry source cache directory (`~/.shape/registry/src` by default).
106    registry_src_dir: PathBuf,
107}
108
109impl DependencyResolver {
110    /// Create a resolver for the given project root.
111    ///
112    /// Uses `~/.shape/cache/` as the shared cache root. Returns `None` if the home
113    /// directory cannot be determined.
114    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    /// Create a resolver with an explicit cache directory (for testing).
134    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    /// Create a resolver with explicit cache + registry paths (for tests/tooling).
149    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    /// Resolve all dependencies, returning them in topological order.
164    ///
165    /// Checks for circular dependencies among path deps, then performs a
166    /// topological sort so that dependencies appear before their dependents.
167    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        // Check for circular dependencies among the resolved set.
192        self.check_cycles(&resolved_vec)?;
193
194        // Build adjacency graph for topological sort.
195        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        // DFS post-order topological sort
206        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        // Build the result in topological order (dependencies first)
215        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        // Backwards-compatible fallback for older lock/source formats.
301        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    /// DFS post-order traversal for topological sort.
311    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    /// Resolve a single dependency spec to a local path.
329    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    /// Resolve a path dependency relative to the owning package root.
367    ///
368    /// If the path ends in `.shapec`, treats it as a pre-compiled bundle file.
369    /// If a `.shapec` file exists alongside a resolved directory (e.g.,
370    /// `./utils.shapec` next to `./utils/`), the bundle is preferred.
371    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 the path explicitly points to a .shapec bundle, use it directly
380        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        // Check if a .shapec bundle exists alongside the directory
429        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        // Look for a shape.toml in the dependency to extract its version
486        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    /// Resolve a git dependency by cloning/fetching into the cache.
503    fn resolve_git_dep(
504        &self,
505        name: &str,
506        url: &str,
507        git_ref: &str,
508    ) -> Result<ResolvedDependency, String> {
509        // Hash the URL to create a stable cache directory name
510        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        // Clone or fetch
522        if git_cache.join(".git").exists() {
523            // Already cloned -- fetch latest
524            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            // Fresh clone
535            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        // Checkout the requested ref
549        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        // Get the resolved rev
563        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    /// Try to read the version from a dependency's shape.toml.
589    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    /// Try to read direct dependency specs from a dependency's shape.toml.
601    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    /// Try to read direct dependency names from a dependency's shape.toml.
612    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                    // Explicit source dependency; treat as non-registry.
623                    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    /// Check for circular dependencies among path deps.
976    fn check_cycles(&self, resolved: &[ResolvedDependency]) -> Result<(), String> {
977        // Build adjacency from resolved metadata, falling back to manifest reads when needed.
978        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        // DFS cycle detection
988        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        // Create a dependency directory
1059        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        // Create dep with shape.toml
1081        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(&registry_index).unwrap();
1191        std::fs::create_dir_all(&registry_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(&registry_index).unwrap();
1256        std::fs::create_dir_all(&registry_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(&registry_index).unwrap();
1317        std::fs::create_dir_all(&registry_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        // Create two packages that depend on each other
1403        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        // Git dep with invalid URL should fail
1440        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        // Create a .shapec bundle file
1464        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        // Create both a directory and a .shapec bundle
1506        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}