1use 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
20static PACKAGE_CACHE: LazyLock<DashMap<String, Arc<ResolvedPackage>>> = LazyLock::new(DashMap::new);
28
29#[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#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct PackageRef {
55 pub scope: String,
57
58 pub name: String,
60
61 pub version: Option<String>,
63}
64
65impl PackageRef {
66 pub fn full_name(&self) -> String {
68 format!("{}/{}", self.scope, self.name)
69 }
70
71 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#[derive(Debug, Clone)]
82pub struct ResolvedPackage {
83 pub path: PathBuf,
85
86 pub manifest: Manifest,
88
89 pub version: String,
91}
92
93pub 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 if !input.starts_with('@') {
122 return Err(ResolverError::InvalidFormat(format!(
123 "Package reference must start with @: {}",
124 input
125 )));
126 }
127
128 let (scope_and_name, version) = if let Some(pos) = input.rfind('@') {
130 if pos == 0 {
131 (input, None)
133 } else {
134 let (sn, v) = input.split_at(pos);
136 (sn, Some(v[1..].to_string())) }
138 } else {
139 (input, None)
140 };
141
142 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 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
170pub fn clear_cache() {
174 PACKAGE_CACHE.clear();
175}
176
177pub fn invalidate_package(name: &str) {
191 PACKAGE_CACHE.retain(|key, _| !key.starts_with(name));
192}
193
194pub fn cache_stats() -> (usize, usize) {
196 let size = PACKAGE_CACHE.len();
197 let capacity = PACKAGE_CACHE.capacity();
198 (size, capacity)
199}
200
201pub fn resolve_package_path(reference: &str) -> Result<ResolvedPackage, ResolverError> {
221 if let Some(cached) = PACKAGE_CACHE.get(reference) {
224 return Ok(Arc::unwrap_or_clone(Arc::clone(cached.value())));
225 }
226
227 let resolved = resolve_package_path_uncached(reference)?;
229 let arc_resolved = Arc::new(resolved);
230
231 PACKAGE_CACHE.insert(reference.to_string(), Arc::clone(&arc_resolved));
233
234 Ok(Arc::unwrap_or_clone(arc_resolved))
235}
236
237fn resolve_package_path_uncached(reference: &str) -> Result<ResolvedPackage, ResolverError> {
239 let pkg_ref = parse_package_ref(reference)?;
240
241 let packages_dir = get_packages_dir()?;
243
244 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 let version = match &pkg_ref.version {
255 Some(v) => {
256 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 if let Ok(lockfile) = Lockfile::load(None) {
266 if let Some(locked_version) = lockfile.find_version(&pkg_ref.full_name()) {
267 let version_dir = package_base.join(locked_version);
269 if version_dir.exists() {
270 locked_version.to_string()
271 } else {
272 find_latest_version(&package_base)?
274 }
275 } else {
276 find_latest_version(&package_base)?
278 }
279 } else {
280 find_latest_version(&package_base)?
282 }
283 }
284 };
285
286 let package_path = package_base.join(&version);
287
288 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
308fn 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
320fn 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 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 versions.sort_by(|a, b| a.0.cmp(&b.0));
348
349 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 #[test]
453 fn test_clear_cache() {
454 use super::clear_cache;
455
456 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}