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