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