Skip to main content

dnx_core/
resolver.rs

1use crate::errors::{DnxError, Result};
2use crate::hooks::{HookPackage, Hooks};
3use crate::registry::{PackageMetadata, RegistryClient, VersionMetadata};
4use serde::{Deserialize, Serialize};
5use std::cell::RefCell;
6use std::collections::{HashMap, VecDeque};
7use std::sync::Arc;
8use tracing::{debug, trace, warn};
9
10// Thread-local caches for parsed semver values to avoid re-parsing on every call
11thread_local! {
12    static VERSION_CACHE: RefCell<HashMap<String, Option<node_semver::Version>>> =
13        RefCell::new(HashMap::new());
14    static RANGE_CACHE: RefCell<HashMap<String, Option<node_semver::Range>>> =
15        RefCell::new(HashMap::new());
16}
17
18fn cached_parse_version(s: &str) -> Option<node_semver::Version> {
19    VERSION_CACHE.with(|cache| {
20        let mut cache = cache.borrow_mut();
21        cache
22            .entry(s.to_string())
23            .or_insert_with(|| node_semver::Version::parse(s).ok())
24            .clone()
25    })
26}
27
28fn cached_parse_range(s: &str) -> Option<node_semver::Range> {
29    RANGE_CACHE.with(|cache| {
30        let mut cache = cache.borrow_mut();
31        cache
32            .entry(s.to_string())
33            .or_insert_with(|| node_semver::Range::parse(s).ok())
34            .clone()
35    })
36}
37
38// ---------------------------------------------------------------------------
39// Dependency spec types
40// ---------------------------------------------------------------------------
41
42/// Parsed dependency specifier — determines how to resolve a dependency.
43#[derive(Debug, Clone)]
44pub enum DependencySpec {
45    /// Standard semver range (e.g. "^1.0.0", "~2.3", ">=0.0.0").
46    Semver { range: String },
47    /// npm alias (e.g. "npm:strip-ansi@^6.0.1") — resolves `real_name` at `range`
48    /// but installs under the dependency key's name.
49    Alias { real_name: String, range: String },
50    /// Git URL (e.g. "git+https://github.com/user/repo.git#ref").
51    Git {
52        url: String,
53        reference: Option<String>,
54    },
55    /// GitHub shorthand (e.g. "user/repo", "user/repo#branch").
56    Github {
57        user: String,
58        repo: String,
59        reference: Option<String>,
60    },
61    /// Local file path (e.g. "file:../my-lib").
62    File { path: String },
63    /// Symlink (e.g. "link:../my-lib").
64    Link { path: String },
65    /// Workspace reference (e.g. "workspace:*", "workspace:^", "workspace:~1.0.0").
66    Workspace { range: String },
67    /// Catalog reference (e.g. "catalog:", "catalog:testing").
68    Catalog { catalog_name: Option<String> },
69}
70
71impl DependencySpec {
72    /// Parse a version/range string into a DependencySpec.
73    pub fn parse(spec: &str) -> Self {
74        let spec = spec.trim();
75
76        // workspace: protocol
77        if let Some(suffix) = spec.strip_prefix("workspace:") {
78            return DependencySpec::Workspace {
79                range: suffix.to_string(),
80            };
81        }
82
83        // catalog: protocol
84        if let Some(suffix) = spec.strip_prefix("catalog:") {
85            return DependencySpec::Catalog {
86                catalog_name: if suffix.is_empty() {
87                    None
88                } else {
89                    Some(suffix.to_string())
90                },
91            };
92        }
93
94        // npm: alias protocol — e.g. "npm:strip-ansi@^6.0.1" or "npm:@scope/pkg@^1.0"
95        if let Some(rest) = spec.strip_prefix("npm:") {
96            // after "npm:"
97            // Parse "real-name@range" — handle scoped packages
98            let (real_name, range) = if let Some(stripped) = rest.strip_prefix('@') {
99                // Scoped: "@scope/pkg@^1.0.0"
100                if let Some(idx) = stripped.find('@') {
101                    (rest[..idx + 1].to_string(), stripped[idx + 1..].to_string())
102                } else {
103                    (rest.to_string(), "*".to_string())
104                }
105            } else if let Some(idx) = rest.find('@') {
106                (rest[..idx].to_string(), rest[idx + 1..].to_string())
107            } else {
108                (rest.to_string(), "*".to_string())
109            };
110            return DependencySpec::Alias { real_name, range };
111        }
112
113        // link: protocol — always symlinked, never copied
114        if let Some(suffix) = spec.strip_prefix("link:") {
115            return DependencySpec::Link {
116                path: suffix.to_string(),
117            };
118        }
119
120        // file: protocol — local tarball or directory
121        if let Some(suffix) = spec.strip_prefix("file:") {
122            return DependencySpec::File {
123                path: suffix.to_string(),
124            };
125        }
126
127        // git+ protocols
128        if spec.starts_with("git+") || spec.starts_with("git://") {
129            let url_part = if let Some(stripped) = spec.strip_prefix("git+") {
130                stripped
131            } else {
132                spec
133            };
134            let (url, reference) = if let Some(idx) = url_part.find('#') {
135                (
136                    url_part[..idx].to_string(),
137                    Some(url_part[idx + 1..].to_string()),
138                )
139            } else {
140                (url_part.to_string(), None)
141            };
142            return DependencySpec::Git { url, reference };
143        }
144
145        // https:// git URLs (common for GitHub)
146        if (spec.starts_with("https://") || spec.starts_with("http://")) && spec.contains(".git") {
147            let (url, reference) = if let Some(idx) = spec.find('#') {
148                (spec[..idx].to_string(), Some(spec[idx + 1..].to_string()))
149            } else {
150                (spec.to_string(), None)
151            };
152            return DependencySpec::Git { url, reference };
153        }
154
155        // GitHub shorthand: "user/repo" or "user/repo#ref"
156        // Must contain exactly one slash, no @ at start, no protocol
157        if !spec.starts_with('@') && !spec.contains("://") && spec.contains('/') {
158            let (base, reference) = if let Some(idx) = spec.find('#') {
159                (&spec[..idx], Some(spec[idx + 1..].to_string()))
160            } else {
161                (spec, None)
162            };
163            let parts: Vec<&str> = base.splitn(2, '/').collect();
164            if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() {
165                return DependencySpec::Github {
166                    user: parts[0].to_string(),
167                    repo: parts[1].to_string(),
168                    reference,
169                };
170            }
171        }
172
173        // Default: semver range
174        DependencySpec::Semver {
175            range: spec.to_string(),
176        }
177    }
178
179    /// Returns true if this is a standard semver range.
180    pub fn is_semver(&self) -> bool {
181        matches!(self, DependencySpec::Semver { .. })
182    }
183}
184
185// ---------------------------------------------------------------------------
186// Public types
187// ---------------------------------------------------------------------------
188
189/// A single resolved package with everything needed for fetching and linking.
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct ResolvedPackage {
192    /// Package name (e.g. "react" or "@babel/core").
193    pub name: String,
194    /// Exact resolved version string (e.g. "18.2.0").
195    pub version: String,
196    /// Registry tarball URL.
197    pub tarball_url: String,
198    /// Integrity hash – prefers `integrity` (subresource integrity format)
199    /// and falls back to `shasum`.
200    pub integrity: String,
201    /// Direct dependency references in `"name@version"` format.
202    pub dependencies: Vec<String>,
203    /// Peer dependency references in `"name@version"` format.
204    #[serde(default, skip_serializing_if = "Vec::is_empty")]
205    pub peer_dependencies: Vec<String>,
206    /// Executable binaries exposed by this package.
207    pub bin: HashMap<String, String>,
208    /// Whether this package has install scripts (preinstall, install, postinstall).
209    #[serde(default)]
210    pub has_install_script: bool,
211}
212
213/// The full dependency graph produced by the resolver.
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct DependencyGraph {
216    pub packages: Vec<ResolvedPackage>,
217}
218
219/// Greedy dependency resolver.
220///
221/// The algorithm walks the dependency tree breadth-first, always picking the
222/// highest version that satisfies each requested semver range.  When two
223/// different parts of the tree request the same package with compatible
224/// ranges the already-resolved version is reused; when an incompatible range
225/// is encountered we keep the *first* resolved version (similar to npm
226/// flat-mode) and emit a warning.
227pub struct Resolver {
228    registry: Arc<RegistryClient>,
229    /// Override map: package name → forced version/range.
230    overrides: HashMap<String, String>,
231    /// Workspace packages: name → (version, path). When a dependency resolves
232    /// to a workspace member, it is linked locally instead of fetched.
233    workspace_packages: HashMap<String, (String, std::path::PathBuf)>,
234    /// Whether to automatically install peer dependencies when they are not satisfied.
235    auto_install_peers: bool,
236    /// Hook system for .pnpmfile.cjs — wrapped in RefCell for interior mutability.
237    hooks: RefCell<Hooks>,
238}
239
240// ---------------------------------------------------------------------------
241// Internal helpers
242// ---------------------------------------------------------------------------
243
244/// Work item for the resolution queue.
245#[derive(Debug)]
246struct QueueEntry {
247    name: String,
248    /// The raw version range string from the dependant's `dependencies` map.
249    range: String,
250    /// Whether this dependency is optional (failures are non-fatal).
251    optional: bool,
252}
253
254/// Find the highest version in `metadata` that satisfies `range_str`.
255///
256/// If an override version is provided, it takes precedence over range matching.
257///
258/// Special cases handled:
259/// - `"*"` and `""` are treated as `">=0.0.0"` (any version).
260/// - `"latest"` (or any other dist-tag) is resolved via `dist_tags` first;
261///   if the tag is not present it is tried as a regular semver range.
262/// - Pure URL/tarball/git references are not supported and will error.
263fn find_best_version<'a>(
264    metadata: &'a PackageMetadata,
265    range_str: &str,
266    override_version: Option<&str>,
267) -> Result<(String, &'a VersionMetadata)> {
268    // If an override is specified, use it instead of the range
269    let range_str = override_version.unwrap_or(range_str);
270    let range_str = range_str.trim();
271
272    // ---- Handle dist-tag references (e.g. "latest", "next", "canary") ----
273    if let Some(tag_version) = metadata.dist_tags.get(range_str) {
274        if let Some(vm) = metadata.versions.get(tag_version) {
275            return Ok((tag_version.clone(), vm));
276        }
277        // Tag exists but the pointed-to version is missing – fall through to
278        // range-based resolution.
279    }
280
281    // ---- Normalise wildcard / empty ranges ----
282    let effective_range = match range_str {
283        "" | "*" | "latest" => ">=0.0.0".to_string(),
284        s => s.to_string(),
285    };
286
287    // ---- Parse the semver range ----
288    let range = cached_parse_range(&effective_range).ok_or_else(|| {
289        DnxError::Resolution(format!(
290            "Invalid semver range '{}' for package '{}': parse error",
291            range_str, metadata.name
292        ))
293    })?;
294
295    // ---- Collect & sort candidate versions descending ----
296    let mut candidates: Vec<(node_semver::Version, &str)> = Vec::new();
297
298    for ver_str in metadata.versions.keys() {
299        if let Some(v) = cached_parse_version(ver_str) {
300            // Skip pre-release versions unless the range explicitly mentions one.
301            if !v.pre_release.is_empty() && !range_str.contains('-') {
302                continue;
303            }
304            candidates.push((v, ver_str.as_str()));
305        }
306    }
307
308    // Sort descending so the first match is the highest satisfying version.
309    candidates.sort_by(|a, b| b.0.cmp(&a.0));
310
311    for (ver, ver_str) in &candidates {
312        if range.satisfies(ver) {
313            let vm = metadata.versions.get(*ver_str).unwrap();
314            return Ok((ver_str.to_string(), vm));
315        }
316    }
317
318    Err(DnxError::Resolution(format!(
319        "No version of '{}' satisfies range '{}'",
320        metadata.name, range_str
321    )))
322}
323
324/// Check whether a package version is compatible with the current platform.
325///
326/// Returns `true` if the package can run on this OS + CPU combination.
327/// npm semantics: if the field is absent, any platform is fine.
328/// A leading `!` in a list entry means "exclude this platform".
329fn is_platform_compatible(version_meta: &VersionMetadata) -> bool {
330    if let Some(ref os_list) = version_meta.os {
331        if !os_list.is_empty() && !check_platform_list(os_list, current_os()) {
332            return false;
333        }
334    }
335    if let Some(ref cpu_list) = version_meta.cpu {
336        if !cpu_list.is_empty() && !check_platform_list(cpu_list, current_cpu()) {
337            return false;
338        }
339    }
340    true
341}
342
343/// Check a platform list against a target value.
344/// Lists can be allow-lists (`["linux", "darwin"]`) or deny-lists (`["!win32"]`).
345fn check_platform_list(list: &[String], target: &str) -> bool {
346    let has_negations = list.iter().any(|s| s.starts_with('!'));
347    let has_positives = list.iter().any(|s| !s.starts_with('!'));
348
349    // If there are negation entries, check none of them match
350    if has_negations {
351        for entry in list {
352            if let Some(excluded) = entry.strip_prefix('!') {
353                if excluded == target {
354                    return false;
355                }
356            }
357        }
358    }
359
360    // If there are positive entries, target must be in the list
361    if has_positives {
362        return list.iter().any(|s| !s.starts_with('!') && s == target);
363    }
364
365    // Only negations and none matched — compatible
366    true
367}
368
369/// Get the current OS name in npm conventions.
370fn current_os() -> &'static str {
371    if cfg!(target_os = "windows") {
372        "win32"
373    } else if cfg!(target_os = "macos") {
374        "darwin"
375    } else if cfg!(target_os = "linux") {
376        "linux"
377    } else if cfg!(target_os = "freebsd") {
378        "freebsd"
379    } else if cfg!(target_os = "openbsd") {
380        "openbsd"
381    } else {
382        "unknown"
383    }
384}
385
386/// Get the current CPU architecture in npm conventions.
387fn current_cpu() -> &'static str {
388    if cfg!(target_arch = "x86_64") {
389        "x64"
390    } else if cfg!(target_arch = "aarch64") {
391        "arm64"
392    } else if cfg!(target_arch = "x86") {
393        "ia32"
394    } else if cfg!(target_arch = "arm") {
395        "arm"
396    } else {
397        "unknown"
398    }
399}
400
401/// Extract the `bin` field from a VersionMetadata into a flat map.
402///
403/// npm packages declare `bin` in three ways:
404/// - Not present → empty map.
405/// - A single string → `{ <package_name>: <path> }`.
406/// - An object mapping command names to paths.
407fn extract_bin(bin: &Option<serde_json::Value>, package_name: &str) -> HashMap<String, String> {
408    let mut map = HashMap::new();
409    match bin {
410        None => {}
411        Some(serde_json::Value::String(s)) => {
412            // Use the unscoped portion of the name as the command name.
413            let cmd = if let Some(idx) = package_name.rfind('/') {
414                &package_name[idx + 1..]
415            } else {
416                package_name
417            };
418            map.insert(cmd.to_string(), s.clone());
419        }
420        Some(serde_json::Value::Object(obj)) => {
421            for (key, val) in obj {
422                if let serde_json::Value::String(s) = val {
423                    map.insert(key.clone(), s.clone());
424                }
425            }
426        }
427        _ => {
428            warn!(
429                "Unexpected bin format for package '{}': {:?}",
430                package_name, bin
431            );
432        }
433    }
434    map
435}
436
437/// Build an integrity string preferring the subresource-integrity value.
438/// Returns empty string if no usable integrity is available; the cache layer
439/// will compute SHA-512 from the downloaded tarball in that case.
440fn integrity_string(dist: &crate::registry::DistInfo) -> String {
441    if let Some(ref integrity) = dist.integrity {
442        if !integrity.is_empty() && integrity.starts_with("sha512-") {
443            return integrity.clone();
444        }
445    }
446    // Accept sha384 or other non-sha1 integrity formats
447    if let Some(ref integrity) = dist.integrity {
448        if !integrity.is_empty() && !integrity.starts_with("sha1-") {
449            return integrity.clone();
450        }
451    }
452    // No integrity field available - this will be handled by cache.store()
453    // which computes SHA-512 from the downloaded tarball data
454    String::new()
455}
456
457/// Return type for the readPackage hook: (dependencies, optional_dependencies, peer_dependencies).
458type OptionalDepsTriple = (
459    Option<HashMap<String, String>>,
460    Option<HashMap<String, String>>,
461    Option<HashMap<String, String>>,
462);
463
464// ---------------------------------------------------------------------------
465// Resolver implementation
466// ---------------------------------------------------------------------------
467
468impl Resolver {
469    /// Create a new resolver backed by the given registry client.
470    pub fn new(registry: Arc<RegistryClient>) -> Self {
471        Self {
472            registry,
473            overrides: HashMap::new(),
474            workspace_packages: HashMap::new(),
475            auto_install_peers: true,
476            hooks: RefCell::new(Hooks::Noop),
477        }
478    }
479
480    /// Create a new resolver with overrides for forced version resolution.
481    pub fn with_overrides(
482        registry: Arc<RegistryClient>,
483        overrides: HashMap<String, String>,
484    ) -> Self {
485        Self {
486            registry,
487            overrides,
488            workspace_packages: HashMap::new(),
489            auto_install_peers: true,
490            hooks: RefCell::new(Hooks::Noop),
491        }
492    }
493
494    /// Create a new resolver with workspace package information.
495    pub fn with_workspace(
496        registry: Arc<RegistryClient>,
497        overrides: HashMap<String, String>,
498        workspace_packages: HashMap<String, (String, std::path::PathBuf)>,
499    ) -> Self {
500        Self {
501            registry,
502            overrides,
503            workspace_packages,
504            auto_install_peers: true,
505            hooks: RefCell::new(Hooks::Noop),
506        }
507    }
508
509    /// Set the hook system for this resolver.
510    pub fn set_hooks(&self, hooks: Hooks) {
511        *self.hooks.borrow_mut() = hooks;
512    }
513
514    /// Enable or disable automatic peer dependency installation.
515    pub fn set_auto_install_peers(&mut self, enabled: bool) {
516        self.auto_install_peers = enabled;
517    }
518
519    /// Apply readPackage hook to a version's dependency maps.
520    /// Returns potentially modified (dependencies, optional_dependencies, peer_dependencies).
521    fn apply_read_package_hook(
522        &self,
523        name: &str,
524        version: &str,
525        deps: &Option<HashMap<String, String>>,
526        opt_deps: &Option<HashMap<String, String>>,
527        peer_deps: &Option<HashMap<String, String>>,
528    ) -> OptionalDepsTriple {
529        let mut hooks = self.hooks.borrow_mut();
530        if !hooks.is_active() {
531            return (deps.clone(), opt_deps.clone(), peer_deps.clone());
532        }
533
534        let hook_pkg = HookPackage {
535            name: name.to_string(),
536            version: version.to_string(),
537            dependencies: deps.clone().unwrap_or_default(),
538            dev_dependencies: HashMap::new(),
539            peer_dependencies: peer_deps.clone().unwrap_or_default(),
540            optional_dependencies: opt_deps.clone().unwrap_or_default(),
541        };
542
543        match hooks.read_package(hook_pkg) {
544            Ok(modified) => {
545                let new_deps = if modified.dependencies.is_empty() {
546                    None
547                } else {
548                    Some(modified.dependencies)
549                };
550                let new_opt = if modified.optional_dependencies.is_empty() {
551                    None
552                } else {
553                    Some(modified.optional_dependencies)
554                };
555                let new_peers = if modified.peer_dependencies.is_empty() {
556                    None
557                } else {
558                    Some(modified.peer_dependencies)
559                };
560                (new_deps, new_opt, new_peers)
561            }
562            Err(e) => {
563                warn!("readPackage hook failed for {}@{}: {}", name, version, e);
564                (deps.clone(), opt_deps.clone(), peer_deps.clone())
565            }
566        }
567    }
568
569    /// Resolve a set of root dependencies into a flat dependency graph.
570    ///
571    /// `root_deps` maps package names to semver range strings, exactly as
572    /// they appear in `package.json` under `dependencies`.
573    ///
574    /// Uses a batch-drain BFS: drains up to 32 entries from the queue,
575    /// fetches all missing metadata concurrently, then processes the batch.
576    pub async fn resolve(&self, root_deps: &HashMap<String, String>) -> Result<DependencyGraph> {
577        if root_deps.is_empty() {
578            return Ok(DependencyGraph {
579                packages: Vec::new(),
580            });
581        }
582
583        // Metadata cache: name -> Arc<PackageMetadata>
584        let mut metadata_cache: HashMap<String, Arc<PackageMetadata>> = HashMap::new();
585
586        // ----- Phase 1: pre-fetch root package metadata in parallel ---------
587        // Collect package names to fetch, resolving npm: aliases to real names
588        let mut root_names: Vec<String> = Vec::new();
589        for (name, range) in root_deps {
590            let spec = DependencySpec::parse(range);
591            if let DependencySpec::Alias { ref real_name, .. } = spec {
592                root_names.push(real_name.clone());
593            } else {
594                root_names.push(name.clone());
595            }
596        }
597        let batch_results = self.registry.fetch_metadata_batch(root_names).await;
598        for (name, result) in batch_results {
599            match result {
600                Ok(meta) => {
601                    metadata_cache.insert(name, Arc::new(meta));
602                }
603                Err(e) => {
604                    debug!("Failed to prefetch metadata for '{}': {}", name, e);
605                }
606            }
607        }
608
609        // ----- Phase 2: greedy BFS resolution (batch-drain) -----------------
610        // resolved: name -> list of (version_string, ResolvedPackage)
611        // Supports multiple versions of the same package when ranges conflict.
612        let mut resolved: HashMap<String, Vec<(String, ResolvedPackage)>> = HashMap::new();
613
614        // BFS queue
615        let mut queue: VecDeque<QueueEntry> = VecDeque::new();
616
617        // Track which peer dependencies have been auto-installed to avoid re-adding them
618        let mut auto_installed_peers: std::collections::HashSet<String> =
619            std::collections::HashSet::new();
620
621        // Seed the queue with root dependencies.
622        for (name, range) in root_deps {
623            queue.push_back(QueueEntry {
624                name: name.clone(),
625                range: range.clone(),
626                optional: false,
627            });
628        }
629
630        const BATCH_SIZE: usize = 32;
631
632        while !queue.is_empty() {
633            // Drain up to BATCH_SIZE entries from the queue
634            let batch: Vec<QueueEntry> = queue.drain(..queue.len().min(BATCH_SIZE)).collect();
635
636            // Collect names that need metadata fetching.
637            // For npm: alias deps, fetch the *real* package name.
638            let missing_names: Vec<String> = batch
639                .iter()
640                .filter(|entry| !has_satisfying_version(&resolved, &entry.name, &entry.range))
641                .map(|entry| {
642                    let spec = DependencySpec::parse(&entry.range);
643                    if let DependencySpec::Alias { ref real_name, .. } = spec {
644                        real_name.clone()
645                    } else {
646                        entry.name.clone()
647                    }
648                })
649                .filter(|name| !metadata_cache.contains_key(name))
650                .collect::<std::collections::HashSet<_>>()
651                .into_iter()
652                .collect();
653
654            // Fetch all missing metadata concurrently
655            if !missing_names.is_empty() {
656                let batch_results = self.registry.fetch_metadata_batch(missing_names).await;
657                for (name, result) in batch_results {
658                    match result {
659                        Ok(meta) => {
660                            metadata_cache.insert(name, Arc::new(meta));
661                        }
662                        Err(e) => {
663                            debug!("Failed to fetch metadata for '{}': {}", name, e);
664                            // Will be handled per-entry below
665                        }
666                    }
667                }
668            }
669
670            // Process each entry in the batch
671            for entry in batch {
672                // -- Check if already resolved and compatible -----------------
673                if let Some(versions) = resolved.get(&entry.name) {
674                    // Check if any existing version satisfies the requested range
675                    let any_satisfies = versions
676                        .iter()
677                        .any(|(ver, _)| version_satisfies(ver, &entry.range));
678                    if any_satisfies {
679                        trace!(
680                            "{} already has a version satisfying '{}'",
681                            entry.name,
682                            entry.range
683                        );
684                        continue;
685                    }
686                    // No existing version satisfies — fall through to resolve a new one
687                    debug!(
688                        "Resolving additional version of '{}' for range '{}' (existing: {})",
689                        entry.name,
690                        entry.range,
691                        versions
692                            .iter()
693                            .map(|(v, _)| v.as_str())
694                            .collect::<Vec<_>>()
695                            .join(", ")
696                    );
697                }
698
699                // -- Check workspace packages -----------------------------------
700                if let Some((ws_version, ws_path)) = self.workspace_packages.get(&entry.name) {
701                    let spec = DependencySpec::parse(&entry.range);
702                    let is_ws_dep = matches!(spec, DependencySpec::Workspace { .. })
703                        || self.workspace_packages.contains_key(&entry.name);
704
705                    if is_ws_dep {
706                        // Validate version satisfies range if specified
707                        if let DependencySpec::Workspace { ref range } = spec {
708                            if range != "*" && !range.is_empty() {
709                                // For ^ or ~ prefixes, build a semver range from ws version
710                                let effective_range = if range == "^" {
711                                    format!("^{}", ws_version)
712                                } else if range == "~" {
713                                    format!("~{}", ws_version)
714                                } else {
715                                    range.clone()
716                                };
717                                if !version_satisfies(ws_version, &effective_range) {
718                                    warn!(
719                                        "Workspace package '{}@{}' does not satisfy range '{}'",
720                                        entry.name, ws_version, effective_range
721                                    );
722                                }
723                            }
724                        }
725
726                        let relative_path = ws_path.to_string_lossy().replace('\\', "/");
727                        let pkg = ResolvedPackage {
728                            name: entry.name.clone(),
729                            version: format!("workspace:{}", relative_path),
730                            tarball_url: String::new(),
731                            integrity: String::new(),
732                            dependencies: Vec::new(),
733                            peer_dependencies: Vec::new(),
734                            bin: HashMap::new(),
735                            has_install_script: false,
736                        };
737                        resolved
738                            .entry(entry.name.clone())
739                            .or_default()
740                            .push((pkg.version.clone(), pkg));
741                        continue;
742                    }
743                }
744
745                // -- Check if this is a non-semver spec ----------------------
746                let spec = DependencySpec::parse(&entry.range);
747                if !spec.is_semver() {
748                    match &spec {
749                        DependencySpec::Alias { real_name, range } => {
750                            // npm: alias — fetch metadata for the real package,
751                            // resolve version, but store under the alias name.
752                            let alias_meta = match metadata_cache.get(real_name) {
753                                Some(m) => Arc::clone(m),
754                                None => {
755                                    match self.registry.fetch_package_metadata(real_name).await {
756                                        Ok(m) => {
757                                            let arc = Arc::new(m);
758                                            metadata_cache
759                                                .insert(real_name.clone(), Arc::clone(&arc));
760                                            arc
761                                        }
762                                        Err(e) => {
763                                            if entry.optional {
764                                                debug!(
765                                                    "Skipping optional alias dep '{}': {}",
766                                                    entry.name, e
767                                                );
768                                                continue;
769                                            }
770                                            return Err(e);
771                                        }
772                                    }
773                                }
774                            };
775
776                            let override_ver = self.overrides.get(&entry.name).map(|s| s.as_str());
777                            let (version, version_meta) =
778                                match find_best_version(&alias_meta, range, override_ver) {
779                                    Ok(v) => v,
780                                    Err(e) => {
781                                        if entry.optional {
782                                            debug!(
783                                                "Skipping optional alias dep '{}': {}",
784                                                entry.name, e
785                                            );
786                                            continue;
787                                        }
788                                        return Err(e);
789                                    }
790                                };
791
792                            if !is_platform_compatible(version_meta) {
793                                debug!(
794                                    "Skipping {}@{}: incompatible platform",
795                                    entry.name, version
796                                );
797                                continue;
798                            }
799
800                            debug!("Resolved alias {} → {}@{}", entry.name, real_name, version);
801
802                            // Apply readPackage hook
803                            let (hooked_deps, hooked_opt_deps, hooked_peers) = self
804                                .apply_read_package_hook(
805                                    real_name,
806                                    &version,
807                                    &version_meta.dependencies,
808                                    &version_meta.optional_dependencies,
809                                    &version_meta.peer_dependencies,
810                                );
811
812                            let mut dep_refs: Vec<String> = Vec::new();
813                            let mut peer_dep_refs: Vec<String> = Vec::new();
814
815                            if let Some(ref deps) = hooked_deps {
816                                for (dep_name, dep_range) in deps {
817                                    dep_refs.push(format!("{}@{}", dep_name, dep_range));
818                                    if !has_satisfying_version(&resolved, dep_name, dep_range) {
819                                        queue.push_back(QueueEntry {
820                                            name: dep_name.clone(),
821                                            range: dep_range.clone(),
822                                            optional: false,
823                                        });
824                                    }
825                                }
826                            }
827
828                            if let Some(ref opt_deps) = hooked_opt_deps {
829                                for (dep_name, dep_range) in opt_deps {
830                                    dep_refs.push(format!("{}@{}", dep_name, dep_range));
831                                    if !has_satisfying_version(&resolved, dep_name, dep_range) {
832                                        queue.push_back(QueueEntry {
833                                            name: dep_name.clone(),
834                                            range: dep_range.clone(),
835                                            optional: true,
836                                        });
837                                    }
838                                }
839                            }
840
841                            if let Some(ref peers) = hooked_peers {
842                                for (dep_name, dep_range) in peers {
843                                    peer_dep_refs.push(format!("{}@{}", dep_name, dep_range));
844
845                                    if self.auto_install_peers {
846                                        let peer_key = format!("{}@{}", dep_name, dep_range);
847
848                                        if !has_satisfying_version(&resolved, dep_name, dep_range)
849                                            && !auto_installed_peers.contains(&peer_key)
850                                        {
851                                            debug!(
852                                                "Auto-installing peer dependency: {} requires {}",
853                                                entry.name, peer_key
854                                            );
855                                            queue.push_back(QueueEntry {
856                                                name: dep_name.clone(),
857                                                range: dep_range.clone(),
858                                                optional: false,
859                                            });
860                                            auto_installed_peers.insert(peer_key);
861                                        }
862                                    }
863                                }
864                            }
865
866                            let pkg = ResolvedPackage {
867                                name: entry.name.clone(),
868                                version: version.clone(),
869                                tarball_url: version_meta.dist.tarball.clone(),
870                                integrity: integrity_string(&version_meta.dist),
871                                dependencies: dep_refs,
872                                peer_dependencies: peer_dep_refs,
873                                bin: extract_bin(&version_meta.bin, &entry.name),
874                                has_install_script: version_meta
875                                    .has_install_script
876                                    .unwrap_or(false),
877                            };
878
879                            resolved
880                                .entry(entry.name.clone())
881                                .or_default()
882                                .push((version, pkg));
883                            continue;
884                        }
885                        DependencySpec::Workspace { .. } => {
886                            // Workspace dep but the target is not in workspace_packages.
887                            // This can happen if the workspace member isn't found.
888                            warn!(
889                                "Workspace dependency '{}' not found in workspace members",
890                                entry.name
891                            );
892                            continue;
893                        }
894                        DependencySpec::Catalog { .. } => {
895                            // Catalog deps should be pre-resolved before calling resolve().
896                            warn!(
897                                "Unresolved catalog dependency '{}': catalog refs must be resolved before resolution",
898                                entry.name
899                            );
900                            continue;
901                        }
902                        DependencySpec::Link { path } => {
903                            debug!(
904                                "Link dependency '{}' -> {} (will be symlinked by linker)",
905                                entry.name, path
906                            );
907                            let pkg = ResolvedPackage {
908                                name: entry.name.clone(),
909                                version: format!("link:{}", path),
910                                tarball_url: String::new(),
911                                integrity: String::new(),
912                                dependencies: Vec::new(),
913                                peer_dependencies: Vec::new(),
914                                bin: HashMap::new(),
915                                has_install_script: false,
916                            };
917                            resolved
918                                .entry(entry.name.clone())
919                                .or_default()
920                                .push((pkg.version.clone(), pkg));
921                            continue;
922                        }
923                        DependencySpec::File { path } => {
924                            debug!("File dependency '{}' -> {}", entry.name, path);
925                            // Read package.json from the file path to extract transitive deps
926                            let mut dep_refs: Vec<String> = Vec::new();
927                            let file_path = std::path::Path::new(&path);
928                            let pkg_json_path = file_path.join("package.json");
929                            let mut has_scripts = false;
930                            let mut file_bin = HashMap::new();
931                            if let Ok(content) = std::fs::read_to_string(&pkg_json_path) {
932                                if let Ok(json) =
933                                    serde_json::from_str::<serde_json::Value>(&content)
934                                {
935                                    // Extract dependencies and enqueue them
936                                    if let Some(deps) =
937                                        json.get("dependencies").and_then(|d| d.as_object())
938                                    {
939                                        for (dep_name, dep_range) in deps {
940                                            if let Some(range) = dep_range.as_str() {
941                                                dep_refs.push(format!("{}@{}", dep_name, range));
942                                                if !has_satisfying_version(
943                                                    &resolved, dep_name, range,
944                                                ) {
945                                                    queue.push_back(QueueEntry {
946                                                        name: dep_name.clone(),
947                                                        range: range.to_string(),
948                                                        optional: false,
949                                                    });
950                                                }
951                                            }
952                                        }
953                                    }
954                                    // Check for install scripts
955                                    if let Some(scripts) =
956                                        json.get("scripts").and_then(|s| s.as_object())
957                                    {
958                                        has_scripts = scripts.contains_key("preinstall")
959                                            || scripts.contains_key("install")
960                                            || scripts.contains_key("postinstall");
961                                    }
962                                    // Extract bin
963                                    file_bin = extract_bin(&json.get("bin").cloned(), &entry.name);
964                                }
965                            }
966                            let pkg = ResolvedPackage {
967                                name: entry.name.clone(),
968                                version: format!("file:{}", path),
969                                tarball_url: String::new(),
970                                integrity: String::new(),
971                                dependencies: dep_refs,
972                                peer_dependencies: Vec::new(),
973                                bin: file_bin,
974                                has_install_script: has_scripts,
975                            };
976                            resolved
977                                .entry(entry.name.clone())
978                                .or_default()
979                                .push((pkg.version.clone(), pkg));
980                            continue;
981                        }
982                        DependencySpec::Git { url, reference } => {
983                            debug!(
984                                "Git dependency '{}' -> {}#{}",
985                                entry.name,
986                                url,
987                                reference.as_deref().unwrap_or("HEAD")
988                            );
989                            let version =
990                                format!("git+{}#{}", url, reference.as_deref().unwrap_or("HEAD"));
991                            let pkg = ResolvedPackage {
992                                name: entry.name.clone(),
993                                version: version.clone(),
994                                tarball_url: String::new(),
995                                integrity: String::new(),
996                                dependencies: Vec::new(),
997                                peer_dependencies: Vec::new(),
998                                bin: HashMap::new(),
999                                has_install_script: false,
1000                            };
1001                            resolved
1002                                .entry(entry.name.clone())
1003                                .or_default()
1004                                .push((version, pkg));
1005                            continue;
1006                        }
1007                        DependencySpec::Github {
1008                            user,
1009                            repo,
1010                            reference,
1011                        } => {
1012                            debug!(
1013                                "GitHub dependency '{}' -> {}/{}#{}",
1014                                entry.name,
1015                                user,
1016                                repo,
1017                                reference.as_deref().unwrap_or("HEAD")
1018                            );
1019                            let version = format!(
1020                                "github:{}/{}#{}",
1021                                user,
1022                                repo,
1023                                reference.as_deref().unwrap_or("HEAD")
1024                            );
1025                            let pkg = ResolvedPackage {
1026                                name: entry.name.clone(),
1027                                version: version.clone(),
1028                                tarball_url: format!(
1029                                    "https://codeload.github.com/{}/{}/tar.gz/{}",
1030                                    user,
1031                                    repo,
1032                                    reference.as_deref().unwrap_or("HEAD")
1033                                ),
1034                                integrity: String::new(),
1035                                dependencies: Vec::new(),
1036                                peer_dependencies: Vec::new(),
1037                                bin: HashMap::new(),
1038                                has_install_script: false,
1039                            };
1040                            resolved
1041                                .entry(entry.name.clone())
1042                                .or_default()
1043                                .push((version, pkg));
1044                            continue;
1045                        }
1046                        _ => {} // Semver handled below
1047                    }
1048                }
1049
1050                // -- Get metadata (from cache or single fetch) ----------------
1051                let metadata = match metadata_cache.get(&entry.name) {
1052                    Some(m) => Arc::clone(m),
1053                    None => {
1054                        // Batch fetch missed this one — try individual fetch
1055                        match self.registry.fetch_package_metadata(&entry.name).await {
1056                            Ok(m) => {
1057                                let arc = Arc::new(m);
1058                                metadata_cache.insert(entry.name.clone(), Arc::clone(&arc));
1059                                arc
1060                            }
1061                            Err(e) => {
1062                                if entry.optional {
1063                                    debug!("Skipping optional dependency '{}': {}", entry.name, e);
1064                                    continue;
1065                                }
1066                                return Err(e);
1067                            }
1068                        }
1069                    }
1070                };
1071
1072                // -- Pick the best version ------------------------------------
1073                let override_ver = self.overrides.get(&entry.name).map(|s| s.as_str());
1074                let (version, version_meta) =
1075                    match find_best_version(&metadata, &entry.range, override_ver) {
1076                        Ok(v) => v,
1077                        Err(e) => {
1078                            if entry.optional {
1079                                debug!("Skipping optional dependency '{}': {}", entry.name, e);
1080                                continue;
1081                            }
1082                            return Err(e);
1083                        }
1084                    };
1085
1086                // -- Platform check -----------------------------------------------
1087                if !is_platform_compatible(version_meta) {
1088                    debug!(
1089                        "Skipping {}@{}: incompatible with current platform (os={}, cpu={})",
1090                        entry.name,
1091                        version,
1092                        current_os(),
1093                        current_cpu()
1094                    );
1095                    continue;
1096                }
1097
1098                debug!("Resolved {}@{}", entry.name, version);
1099
1100                // -- Apply readPackage hook ------------------------------------
1101                let (hooked_deps, hooked_opt_deps, hooked_peers) = self.apply_read_package_hook(
1102                    &entry.name,
1103                    &version,
1104                    &version_meta.dependencies,
1105                    &version_meta.optional_dependencies,
1106                    &version_meta.peer_dependencies,
1107                );
1108
1109                // -- Collect dependency refs & enqueue transitive deps ---------
1110                let mut dep_refs: Vec<String> = Vec::new();
1111                let mut peer_dep_refs: Vec<String> = Vec::new();
1112
1113                if let Some(ref deps) = hooked_deps {
1114                    for (dep_name, dep_range) in deps {
1115                        dep_refs.push(format!("{}@{}", dep_name, dep_range));
1116                        if !has_satisfying_version(&resolved, dep_name, dep_range) {
1117                            queue.push_back(QueueEntry {
1118                                name: dep_name.clone(),
1119                                range: dep_range.clone(),
1120                                optional: false,
1121                            });
1122                        }
1123                    }
1124                }
1125
1126                // Optional dependencies
1127                if let Some(ref opt_deps) = hooked_opt_deps {
1128                    for (dep_name, dep_range) in opt_deps {
1129                        dep_refs.push(format!("{}@{}", dep_name, dep_range));
1130                        if !has_satisfying_version(&resolved, dep_name, dep_range) {
1131                            queue.push_back(QueueEntry {
1132                                name: dep_name.clone(),
1133                                range: dep_range.clone(),
1134                                optional: true,
1135                            });
1136                        }
1137                    }
1138                }
1139
1140                // Peer dependencies — collect and optionally auto-install
1141                if let Some(ref peers) = hooked_peers {
1142                    for (dep_name, dep_range) in peers {
1143                        peer_dep_refs.push(format!("{}@{}", dep_name, dep_range));
1144
1145                        if self.auto_install_peers {
1146                            let peer_key = format!("{}@{}", dep_name, dep_range);
1147
1148                            if !has_satisfying_version(&resolved, dep_name, dep_range)
1149                                && !auto_installed_peers.contains(&peer_key)
1150                            {
1151                                debug!(
1152                                    "Auto-installing peer dependency: {} requires {}",
1153                                    entry.name, peer_key
1154                                );
1155                                queue.push_back(QueueEntry {
1156                                    name: dep_name.clone(),
1157                                    range: dep_range.clone(),
1158                                    optional: false,
1159                                });
1160                                auto_installed_peers.insert(peer_key);
1161                            }
1162                        }
1163                    }
1164                }
1165
1166                // Build the resolved package record.
1167                let pkg = ResolvedPackage {
1168                    name: entry.name.clone(),
1169                    version: version.clone(),
1170                    tarball_url: version_meta.dist.tarball.clone(),
1171                    integrity: integrity_string(&version_meta.dist),
1172                    dependencies: dep_refs,
1173                    peer_dependencies: peer_dep_refs,
1174                    bin: extract_bin(&version_meta.bin, &entry.name),
1175                    has_install_script: version_meta.has_install_script.unwrap_or(false),
1176                };
1177
1178                resolved
1179                    .entry(entry.name.clone())
1180                    .or_default()
1181                    .push((version, pkg));
1182            }
1183        }
1184
1185        // ----- Phase 3: fixup dependency refs with resolved versions ---------
1186        // Build a lookup: name -> list of resolved versions
1187        let resolved_versions: HashMap<String, Vec<String>> = resolved
1188            .iter()
1189            .map(|(name, versions)| {
1190                (
1191                    name.clone(),
1192                    versions.iter().map(|(ver, _)| ver.clone()).collect(),
1193                )
1194            })
1195            .collect();
1196
1197        // Flatten all versions into a single list of packages
1198        let packages: Vec<ResolvedPackage> = resolved
1199            .into_values()
1200            .flat_map(|versions| versions.into_iter().map(|(_, pkg)| pkg))
1201            .map(|mut pkg| {
1202                pkg.dependencies = pkg
1203                    .dependencies
1204                    .iter()
1205                    .filter_map(|dep_ref| {
1206                        let (dep_name, dep_range) = split_dep_ref(dep_ref);
1207                        find_best_resolved_version(&resolved_versions, dep_name, dep_range)
1208                            .map(|ver| format!("{}@{}", dep_name, ver))
1209                    })
1210                    .collect();
1211                // Fixup peer dep refs too
1212                pkg.peer_dependencies = pkg
1213                    .peer_dependencies
1214                    .iter()
1215                    .filter_map(|dep_ref| {
1216                        let (dep_name, dep_range) = split_dep_ref(dep_ref);
1217                        find_best_resolved_version(&resolved_versions, dep_name, dep_range)
1218                            .map(|ver| format!("{}@{}", dep_name, ver))
1219                    })
1220                    .collect();
1221                pkg
1222            })
1223            .collect();
1224
1225        // ----- Phase 4: validate peer dependencies ---------------------------
1226        // Only warn about unmet peers if auto_install_peers is disabled.
1227        // When auto_install_peers is enabled, all non-optional peers should have been queued.
1228        if !self.auto_install_peers {
1229            let pkg_map: HashMap<&str, &ResolvedPackage> =
1230                packages.iter().map(|p| (p.name.as_str(), p)).collect();
1231            for pkg in &packages {
1232                if pkg.peer_dependencies.is_empty() {
1233                    continue;
1234                }
1235                for peer_ref in &pkg.peer_dependencies {
1236                    let (peer_name, _) = split_dep_ref(peer_ref);
1237                    if !pkg_map.contains_key(peer_name) {
1238                        warn!(
1239                            "Unmet peer dependency: '{}' requires peer '{}' but it is not installed.",
1240                            pkg.name, peer_ref
1241                        );
1242                    }
1243                }
1244            }
1245        }
1246
1247        Ok(DependencyGraph { packages })
1248    }
1249}
1250
1251// ---------------------------------------------------------------------------
1252// Small utility functions
1253// ---------------------------------------------------------------------------
1254
1255/// Split a `"name@range"` reference into `(name, range)`.
1256///
1257/// Handles scoped packages correctly: `"@scope/pkg@^1.0"` → `("@scope/pkg", "^1.0")`.
1258fn split_dep_ref(dep_ref: &str) -> (&str, &str) {
1259    // If it starts with '@' the first '@' is part of the scope – find the
1260    // second '@'.
1261    if let Some(stripped) = dep_ref.strip_prefix('@') {
1262        if let Some(idx) = stripped.find('@') {
1263            return (&dep_ref[..idx + 1], &stripped[idx + 1..]);
1264        }
1265        // No second '@' – the whole string is the name, range is empty.
1266        return (dep_ref, "");
1267    }
1268    // Non-scoped: first '@' separates name from range.
1269    if let Some(idx) = dep_ref.find('@') {
1270        (&dep_ref[..idx], &dep_ref[idx + 1..])
1271    } else {
1272        (dep_ref, "")
1273    }
1274}
1275
1276/// Check if the resolved map has any version of `name` that satisfies `range`.
1277fn has_satisfying_version(
1278    resolved: &HashMap<String, Vec<(String, ResolvedPackage)>>,
1279    name: &str,
1280    range: &str,
1281) -> bool {
1282    match resolved.get(name) {
1283        None => false,
1284        Some(versions) => versions
1285            .iter()
1286            .any(|(ver, _)| version_satisfies(ver, range)),
1287    }
1288}
1289
1290/// Find the best resolved version for a dependency reference.
1291/// Prefers a version that satisfies the range; falls back to the first version.
1292fn find_best_resolved_version(
1293    resolved_versions: &HashMap<String, Vec<String>>,
1294    name: &str,
1295    range: &str,
1296) -> Option<String> {
1297    resolved_versions.get(name).and_then(|versions| {
1298        // Try to find a version that satisfies the range
1299        versions
1300            .iter()
1301            .find(|ver| version_satisfies(ver, range))
1302            .or_else(|| versions.first())
1303            .cloned()
1304    })
1305}
1306
1307/// Check whether `version_str` satisfies the given semver `range_str`.
1308fn version_satisfies(version_str: &str, range_str: &str) -> bool {
1309    let range_str = range_str.trim();
1310
1311    // Normalise wildcards.
1312    let effective = match range_str {
1313        "" | "*" | "latest" => ">=0.0.0",
1314        s => s,
1315    };
1316
1317    let version = match cached_parse_version(version_str) {
1318        Some(v) => v,
1319        None => return false,
1320    };
1321
1322    let range = match cached_parse_range(effective) {
1323        Some(r) => r,
1324        None => return false,
1325    };
1326
1327    range.satisfies(&version)
1328}
1329
1330// ---------------------------------------------------------------------------
1331// Tests
1332// ---------------------------------------------------------------------------
1333
1334#[cfg(test)]
1335mod tests {
1336    use super::*;
1337    use crate::registry::DistInfo;
1338
1339    /// Build a minimal `PackageMetadata` with the given versions.
1340    fn make_metadata(name: &str, versions: &[&str]) -> PackageMetadata {
1341        let mut ver_map = HashMap::new();
1342        for v in versions {
1343            ver_map.insert(
1344                v.to_string(),
1345                VersionMetadata {
1346                    name: name.to_string(),
1347                    version: v.to_string(),
1348                    dependencies: None,
1349                    dev_dependencies: None,
1350                    peer_dependencies: None,
1351                    optional_dependencies: None,
1352                    dist: DistInfo {
1353                        tarball: format!(
1354                            "https://registry.npmjs.org/{}/-/{}-{}.tgz",
1355                            name, name, v
1356                        ),
1357                        shasum: "abc123".to_string(),
1358                        integrity: Some("sha512-test".to_string()),
1359                    },
1360                    bin: None,
1361                    os: None,
1362                    cpu: None,
1363                    has_install_script: None,
1364                },
1365            );
1366        }
1367        let latest = versions.last().unwrap_or(&"0.0.0").to_string();
1368        let mut dist_tags = HashMap::new();
1369        dist_tags.insert("latest".to_string(), latest);
1370        PackageMetadata {
1371            name: name.to_string(),
1372            versions: ver_map,
1373            dist_tags,
1374        }
1375    }
1376
1377    #[test]
1378    fn test_find_best_version_caret() {
1379        let meta = make_metadata("foo", &["1.0.0", "1.2.3", "1.5.0", "2.0.0"]);
1380        let (ver, _) = find_best_version(&meta, "^1.2.0", None).unwrap();
1381        assert_eq!(ver, "1.5.0");
1382    }
1383
1384    #[test]
1385    fn test_find_best_version_tilde() {
1386        let meta = make_metadata("foo", &["1.0.0", "1.2.3", "1.2.9", "1.3.0"]);
1387        let (ver, _) = find_best_version(&meta, "~1.2.0", None).unwrap();
1388        assert_eq!(ver, "1.2.9");
1389    }
1390
1391    #[test]
1392    fn test_find_best_version_wildcard() {
1393        let meta = make_metadata("foo", &["1.0.0", "2.0.0", "3.0.0"]);
1394        let (ver, _) = find_best_version(&meta, "*", None).unwrap();
1395        assert_eq!(ver, "3.0.0");
1396    }
1397
1398    #[test]
1399    fn test_find_best_version_exact() {
1400        let meta = make_metadata("foo", &["1.0.0", "1.2.3", "2.0.0"]);
1401        let (ver, _) = find_best_version(&meta, "1.2.3", None).unwrap();
1402        assert_eq!(ver, "1.2.3");
1403    }
1404
1405    #[test]
1406    fn test_find_best_version_latest_tag() {
1407        let meta = make_metadata("foo", &["1.0.0", "2.0.0"]);
1408        let (ver, _) = find_best_version(&meta, "latest", None).unwrap();
1409        assert_eq!(ver, "2.0.0");
1410    }
1411
1412    #[test]
1413    fn test_find_best_version_no_match() {
1414        let meta = make_metadata("foo", &["1.0.0", "1.1.0"]);
1415        let result = find_best_version(&meta, "^2.0.0", None);
1416        assert!(result.is_err());
1417    }
1418
1419    #[test]
1420    fn test_extract_bin_none() {
1421        let result = extract_bin(&None, "my-pkg");
1422        assert!(result.is_empty());
1423    }
1424
1425    #[test]
1426    fn test_extract_bin_string() {
1427        let val = serde_json::Value::String("./bin/cli.js".to_string());
1428        let result = extract_bin(&Some(val), "my-pkg");
1429        assert_eq!(result.get("my-pkg").unwrap(), "./bin/cli.js");
1430    }
1431
1432    #[test]
1433    fn test_extract_bin_string_scoped() {
1434        let val = serde_json::Value::String("./bin/cli.js".to_string());
1435        let result = extract_bin(&Some(val), "@scope/my-pkg");
1436        assert_eq!(result.get("my-pkg").unwrap(), "./bin/cli.js");
1437    }
1438
1439    #[test]
1440    fn test_extract_bin_object() {
1441        let obj = serde_json::json!({
1442            "cmd1": "./bin/cmd1.js",
1443            "cmd2": "./bin/cmd2.js"
1444        });
1445        let result = extract_bin(&Some(obj), "my-pkg");
1446        assert_eq!(result.len(), 2);
1447        assert_eq!(result.get("cmd1").unwrap(), "./bin/cmd1.js");
1448        assert_eq!(result.get("cmd2").unwrap(), "./bin/cmd2.js");
1449    }
1450
1451    #[test]
1452    fn test_split_dep_ref_simple() {
1453        let (name, range) = split_dep_ref("react@^18.0.0");
1454        assert_eq!(name, "react");
1455        assert_eq!(range, "^18.0.0");
1456    }
1457
1458    #[test]
1459    fn test_split_dep_ref_scoped() {
1460        let (name, range) = split_dep_ref("@babel/core@^7.0.0");
1461        assert_eq!(name, "@babel/core");
1462        assert_eq!(range, "^7.0.0");
1463    }
1464
1465    #[test]
1466    fn test_version_satisfies_basic() {
1467        assert!(version_satisfies("1.5.0", "^1.2.0"));
1468        assert!(!version_satisfies("2.0.0", "^1.2.0"));
1469        assert!(version_satisfies("3.0.0", "*"));
1470        assert!(version_satisfies("1.0.0", "latest"));
1471    }
1472}