Skip to main content

nika_engine/registry/
resolver.rs

1//! Package path resolution for local SuperNovae packages.
2//!
3//! Resolves package references like `@workflows/seo-audit` or `@workflows/seo-audit@1.2.0`
4//! to actual filesystem paths in `~/.nika/packages/`.
5//!
6//! # Performance
7//!
8//! Uses DashMap for thread-safe caching of resolved packages.
9
10use std::path::PathBuf;
11use std::sync::{Arc, LazyLock};
12
13use dashmap::DashMap;
14use semver::Version;
15use thiserror::Error;
16
17use super::lockfile::Lockfile;
18use super::types::Manifest;
19
20/// Global package resolution cache
21///
22/// Thread-safe cache using DashMap to avoid repeated filesystem lookups.
23/// Uses Arc<ResolvedPackage> to minimize memory overhead on cache hits.
24///
25/// Key: package reference (e.g., "@workflows/seo-audit@1.2.0")
26/// Value: Arc-wrapped ResolvedPackage (cheap to clone, ~8 bytes per ref)
27static PACKAGE_CACHE: LazyLock<DashMap<String, Arc<ResolvedPackage>>> = LazyLock::new(DashMap::new);
28
29/// Errors that can occur during package resolution.
30#[derive(Error, Debug)]
31pub enum ResolverError {
32    #[error("Invalid package reference format: {0}")]
33    InvalidFormat(String),
34
35    #[error("Package not found: {0}")]
36    PackageNotFound(String),
37
38    #[error("No version specified and multiple versions available: {0}")]
39    AmbiguousVersion(String),
40
41    #[error("Manifest parse error: {0}")]
42    ManifestError(String),
43
44    #[error("IO error: {0}")]
45    IoError(#[from] std::io::Error),
46}
47
48/// A parsed package reference.
49///
50/// Examples:
51/// - `@workflows/seo-audit` → scope="@workflows", name="seo-audit", version=None
52/// - `@workflows/seo-audit@1.2.0` → scope="@workflows", name="seo-audit", version=Some("1.2.0")
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct PackageRef {
55    /// Package scope (e.g., "@workflows")
56    pub scope: String,
57
58    /// Package name (e.g., "seo-audit")
59    pub name: String,
60
61    /// Optional version (e.g., "1.2.0")
62    pub version: Option<String>,
63}
64
65impl PackageRef {
66    /// Get the full package name without version (e.g., "@workflows/seo-audit")
67    pub fn full_name(&self) -> String {
68        format!("{}/{}", self.scope, self.name)
69    }
70
71    /// Get the full package reference with version if present
72    pub fn full_ref(&self) -> String {
73        match &self.version {
74            Some(v) => format!("{}@{}", self.full_name(), v),
75            None => self.full_name(),
76        }
77    }
78}
79
80/// A resolved package with filesystem path and loaded manifest.
81#[derive(Debug, Clone)]
82pub struct ResolvedPackage {
83    /// Full path to package directory
84    pub path: PathBuf,
85
86    /// Loaded package manifest
87    pub manifest: Manifest,
88
89    /// Resolved version
90    pub version: String,
91}
92
93/// Parse a package reference string into a `PackageRef`.
94///
95/// # Formats
96///
97/// - `@workflows/seo-audit` → scope + name only
98/// - `@workflows/seo-audit@1.2.0` → scope + name + version
99///
100/// # Examples
101///
102/// ```
103/// use nika::registry::resolver::parse_package_ref;
104///
105/// let ref1 = parse_package_ref("@workflows/seo-audit").unwrap();
106/// assert_eq!(ref1.scope, "@workflows");
107/// assert_eq!(ref1.name, "seo-audit");
108/// assert_eq!(ref1.version, None);
109///
110/// let ref2 = parse_package_ref("@workflows/seo-audit@1.2.0").unwrap();
111/// assert_eq!(ref2.version, Some("1.2.0".to_string()));
112/// ```
113pub fn parse_package_ref(input: &str) -> Result<PackageRef, ResolverError> {
114    if input.is_empty() {
115        return Err(ResolverError::InvalidFormat(
116            "Empty package reference".to_string(),
117        ));
118    }
119
120    // Validate starts with @
121    if !input.starts_with('@') {
122        return Err(ResolverError::InvalidFormat(format!(
123            "Package reference must start with @: {}",
124            input
125        )));
126    }
127
128    // Find version separator (@) - search for last @ to handle nested paths
129    let (scope_and_name, version) = if let Some(pos) = input.rfind('@') {
130        if pos == 0 {
131            // Only the initial @, no version
132            (input, None)
133        } else {
134            // Split at the last @
135            let (sn, v) = input.split_at(pos);
136            (sn, Some(v[1..].to_string())) // Skip the @ character
137        }
138    } else {
139        (input, None)
140    };
141
142    // Split scope and name at /
143    let name_parts: Vec<&str> = scope_and_name.splitn(2, '/').collect();
144
145    if name_parts.len() != 2 {
146        return Err(ResolverError::InvalidFormat(format!(
147            "Package reference must be in format @scope/name: {}",
148            input
149        )));
150    }
151
152    let scope = name_parts[0].to_string();
153    let name = name_parts[1].to_string();
154
155    // Validate name is not empty
156    if name.is_empty() {
157        return Err(ResolverError::InvalidFormat(format!(
158            "Package name cannot be empty: {}",
159            input
160        )));
161    }
162
163    Ok(PackageRef {
164        scope,
165        name,
166        version,
167    })
168}
169
170/// Clear the package resolution cache
171///
172/// Useful for testing or when packages are installed/updated.
173pub fn clear_cache() {
174    PACKAGE_CACHE.clear();
175}
176
177/// Invalidate a specific package in the cache
178///
179/// Removes all cached entries starting with the given package name.
180/// More efficient than clear_cache() when only one package changed.
181///
182/// # Examples
183///
184/// ```
185/// use nika::registry::resolver;
186///
187/// // After `nika add @workflows/seo-audit`, invalidate just that package
188/// resolver::invalidate_package("@workflows/seo-audit");
189/// ```
190pub fn invalidate_package(name: &str) {
191    PACKAGE_CACHE.retain(|key, _| !key.starts_with(name));
192}
193
194/// Get cache statistics
195pub fn cache_stats() -> (usize, usize) {
196    let size = PACKAGE_CACHE.len();
197    let capacity = PACKAGE_CACHE.capacity();
198    (size, capacity)
199}
200
201/// Resolve a package reference to its filesystem path and manifest.
202///
203/// This function:
204/// 1. Checks the cache first
205/// 2. Parses the package reference
206/// 3. Looks up the package in `~/.nika/packages/`
207/// 4. If no version specified, finds the latest installed version
208/// 5. Loads and validates the package manifest
209/// 6. Caches the result
210///
211/// # Examples
212///
213/// ```no_run
214/// use nika::registry::resolver::resolve_package_path;
215///
216/// let pkg = resolve_package_path("@workflows/seo-audit").unwrap();
217/// println!("Found package at: {}", pkg.path.display());
218/// println!("Version: {}", pkg.version);
219/// ```
220pub fn resolve_package_path(reference: &str) -> Result<ResolvedPackage, ResolverError> {
221    // Check cache first
222    // Arc clone is cheap (~8 bytes + atomic increment)
223    if let Some(cached) = PACKAGE_CACHE.get(reference) {
224        return Ok(Arc::unwrap_or_clone(Arc::clone(cached.value())));
225    }
226
227    // Cache miss - resolve and cache
228    let resolved = resolve_package_path_uncached(reference)?;
229    let arc_resolved = Arc::new(resolved);
230
231    // Cache the Arc-wrapped result
232    PACKAGE_CACHE.insert(reference.to_string(), Arc::clone(&arc_resolved));
233
234    Ok(Arc::unwrap_or_clone(arc_resolved))
235}
236
237/// Internal uncached resolution function
238fn resolve_package_path_uncached(reference: &str) -> Result<ResolvedPackage, ResolverError> {
239    let pkg_ref = parse_package_ref(reference)?;
240
241    // Get base packages directory (~/.nika/packages/)
242    let packages_dir = get_packages_dir()?;
243
244    // Construct package base path (@workflows/seo-audit)
245    let package_base = packages_dir
246        .join(pkg_ref.scope.trim_start_matches('@'))
247        .join(&pkg_ref.name);
248
249    if !package_base.exists() {
250        return Err(ResolverError::PackageNotFound(pkg_ref.full_name()));
251    }
252
253    // Determine version
254    let version = match &pkg_ref.version {
255        Some(v) => {
256            // Explicit version - check it exists
257            let version_dir = package_base.join(v);
258            if !version_dir.exists() {
259                return Err(ResolverError::PackageNotFound(pkg_ref.full_ref()));
260            }
261            v.clone()
262        }
263        None => {
264            // No version - try lockfile first, then find latest
265            if let Ok(lockfile) = Lockfile::load(None) {
266                if let Some(locked_version) = lockfile.find_version(&pkg_ref.full_name()) {
267                    // Verify locked version exists
268                    let version_dir = package_base.join(locked_version);
269                    if version_dir.exists() {
270                        locked_version.to_string()
271                    } else {
272                        // Lockfile version missing, fall back to latest
273                        find_latest_version(&package_base)?
274                    }
275                } else {
276                    // Not in lockfile, find latest
277                    find_latest_version(&package_base)?
278                }
279            } else {
280                // Lockfile load failed, find latest
281                find_latest_version(&package_base)?
282            }
283        }
284    };
285
286    let package_path = package_base.join(&version);
287
288    // Load manifest
289    let manifest_path = package_path.join("manifest.yaml");
290    if !manifest_path.exists() {
291        return Err(ResolverError::ManifestError(format!(
292            "Manifest not found at: {}",
293            manifest_path.display()
294        )));
295    }
296
297    let manifest_content = std::fs::read_to_string(&manifest_path)?;
298    let manifest: Manifest = crate::serde_yaml::from_str(&manifest_content)
299        .map_err(|e| ResolverError::ManifestError(e.to_string()))?;
300
301    Ok(ResolvedPackage {
302        path: package_path,
303        manifest,
304        version,
305    })
306}
307
308/// Get the packages directory (~/.nika/packages/)
309fn get_packages_dir() -> Result<PathBuf, ResolverError> {
310    let home = dirs::home_dir().ok_or_else(|| {
311        ResolverError::IoError(std::io::Error::new(
312            std::io::ErrorKind::NotFound,
313            "Could not determine home directory",
314        ))
315    })?;
316
317    Ok(home.join(".nika").join("packages"))
318}
319
320/// Find the latest installed version in a package directory.
321///
322/// Scans subdirectories and returns the semantically latest version.
323/// Uses semantic versioning (semver) to correctly handle versions like 1.10.0 > 1.9.0.
324fn find_latest_version(package_base: &PathBuf) -> Result<String, ResolverError> {
325    let mut versions: Vec<(Version, String)> = Vec::new();
326
327    for entry in std::fs::read_dir(package_base)? {
328        let entry = entry?;
329        if entry.file_type()?.is_dir() {
330            if let Some(version_str) = entry.file_name().to_str() {
331                // Try to parse as semantic version, skip invalid versions
332                if let Ok(version) = Version::parse(version_str) {
333                    versions.push((version, version_str.to_string()));
334                }
335            }
336        }
337    }
338
339    if versions.is_empty() {
340        return Err(ResolverError::PackageNotFound(format!(
341            "No valid semantic versions found in {}",
342            package_base.display()
343        )));
344    }
345
346    // Sort by semantic version (correctly handles 1.10.0 > 1.9.0)
347    versions.sort_by(|a, b| a.0.cmp(&b.0));
348
349    // Return the latest version string (safe: empty check above guarantees at least one element)
350    versions.last().map(|(_, s)| s.clone()).ok_or_else(|| {
351        ResolverError::PackageNotFound(format!(
352            "No valid semantic versions found in {}",
353            package_base.display()
354        ))
355    })
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    #[test]
363    fn test_parse_package_ref_basic() {
364        let pkg = parse_package_ref("@workflows/seo-audit").unwrap();
365        assert_eq!(pkg.scope, "@workflows");
366        assert_eq!(pkg.name, "seo-audit");
367        assert_eq!(pkg.version, None);
368        assert_eq!(pkg.full_name(), "@workflows/seo-audit");
369    }
370
371    #[test]
372    fn test_parse_package_ref_with_version() {
373        let pkg = parse_package_ref("@workflows/seo-audit@1.2.0").unwrap();
374        assert_eq!(pkg.scope, "@workflows");
375        assert_eq!(pkg.name, "seo-audit");
376        assert_eq!(pkg.version, Some("1.2.0".to_string()));
377        assert_eq!(pkg.full_ref(), "@workflows/seo-audit@1.2.0");
378    }
379
380    #[test]
381    fn test_parse_package_ref_nested_scope() {
382        let pkg = parse_package_ref("@workflows/data/transformer").unwrap();
383        assert_eq!(pkg.scope, "@workflows");
384        assert_eq!(pkg.name, "data/transformer");
385    }
386
387    #[test]
388    fn test_parse_package_ref_invalid_no_scope() {
389        let result = parse_package_ref("seo-audit");
390        assert!(result.is_err());
391        assert!(matches!(result, Err(ResolverError::InvalidFormat(_))));
392    }
393
394    #[test]
395    fn test_parse_package_ref_invalid_no_name() {
396        let result = parse_package_ref("@workflows/");
397        assert!(result.is_err());
398    }
399
400    #[test]
401    fn test_parse_package_ref_invalid_empty() {
402        let result = parse_package_ref("");
403        assert!(result.is_err());
404    }
405
406    #[test]
407    fn test_parse_package_ref_invalid_no_slash() {
408        let result = parse_package_ref("@workflows");
409        assert!(result.is_err());
410    }
411
412    #[test]
413    fn test_parse_package_ref_with_prerelease() {
414        let pkg = parse_package_ref("@workflows/seo-audit@1.2.0-beta.1").unwrap();
415        assert_eq!(pkg.version, Some("1.2.0-beta.1".to_string()));
416    }
417
418    #[test]
419    fn test_package_ref_full_name() {
420        let pkg = PackageRef {
421            scope: "@workflows".to_string(),
422            name: "seo-audit".to_string(),
423            version: None,
424        };
425        assert_eq!(pkg.full_name(), "@workflows/seo-audit");
426    }
427
428    #[test]
429    fn test_package_ref_full_ref_no_version() {
430        let pkg = PackageRef {
431            scope: "@workflows".to_string(),
432            name: "seo-audit".to_string(),
433            version: None,
434        };
435        assert_eq!(pkg.full_ref(), "@workflows/seo-audit");
436    }
437
438    #[test]
439    fn test_package_ref_full_ref_with_version() {
440        let pkg = PackageRef {
441            scope: "@workflows".to_string(),
442            name: "seo-audit".to_string(),
443            version: Some("1.2.0".to_string()),
444        };
445        assert_eq!(pkg.full_ref(), "@workflows/seo-audit@1.2.0");
446    }
447
448    // ============================================================================
449    // CACHE TESTS
450    // ============================================================================
451
452    #[test]
453    fn test_clear_cache() {
454        use super::clear_cache;
455
456        // Clear cache before test
457        clear_cache();
458
459        let (size, _) = cache_stats();
460        assert_eq!(size, 0, "Cache should be empty after clear");
461    }
462
463    #[test]
464    fn test_cache_stats() {
465        use super::{cache_stats, clear_cache};
466
467        clear_cache();
468
469        let (size, _capacity) = cache_stats();
470        assert_eq!(size, 0, "Cache should start empty");
471    }
472}