use std::path::PathBuf;
use std::sync::{Arc, LazyLock};
use dashmap::DashMap;
use semver::Version;
use thiserror::Error;
use super::lockfile::Lockfile;
use super::types::Manifest;
static PACKAGE_CACHE: LazyLock<DashMap<String, Arc<ResolvedPackage>>> = LazyLock::new(DashMap::new);
#[derive(Error, Debug)]
pub enum ResolverError {
#[error("Invalid package reference format: {0}")]
InvalidFormat(String),
#[error("Package not found: {0}")]
PackageNotFound(String),
#[error("No version specified and multiple versions available: {0}")]
AmbiguousVersion(String),
#[error("Manifest parse error: {0}")]
ManifestError(String),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackageRef {
pub scope: String,
pub name: String,
pub version: Option<String>,
}
impl PackageRef {
pub fn full_name(&self) -> String {
format!("{}/{}", self.scope, self.name)
}
pub fn full_ref(&self) -> String {
match &self.version {
Some(v) => format!("{}@{}", self.full_name(), v),
None => self.full_name(),
}
}
}
#[derive(Debug, Clone)]
pub struct ResolvedPackage {
pub path: PathBuf,
pub manifest: Manifest,
pub version: String,
}
pub fn parse_package_ref(input: &str) -> Result<PackageRef, ResolverError> {
if input.is_empty() {
return Err(ResolverError::InvalidFormat(
"Empty package reference".to_string(),
));
}
if !input.starts_with('@') {
return Err(ResolverError::InvalidFormat(format!(
"Package reference must start with @: {}",
input
)));
}
let (scope_and_name, version) = if let Some(pos) = input.rfind('@') {
if pos == 0 {
(input, None)
} else {
let (sn, v) = input.split_at(pos);
(sn, Some(v[1..].to_string())) }
} else {
(input, None)
};
let name_parts: Vec<&str> = scope_and_name.splitn(2, '/').collect();
if name_parts.len() != 2 {
return Err(ResolverError::InvalidFormat(format!(
"Package reference must be in format @scope/name: {}",
input
)));
}
let scope = name_parts[0].to_string();
let name = name_parts[1].to_string();
if name.is_empty() {
return Err(ResolverError::InvalidFormat(format!(
"Package name cannot be empty: {}",
input
)));
}
Ok(PackageRef {
scope,
name,
version,
})
}
pub fn clear_cache() {
PACKAGE_CACHE.clear();
}
pub fn invalidate_package(name: &str) {
PACKAGE_CACHE.retain(|key, _| !key.starts_with(name));
}
pub fn cache_stats() -> (usize, usize) {
let size = PACKAGE_CACHE.len();
let capacity = PACKAGE_CACHE.capacity();
(size, capacity)
}
pub fn resolve_package_path(reference: &str) -> Result<ResolvedPackage, ResolverError> {
if let Some(cached) = PACKAGE_CACHE.get(reference) {
return Ok(Arc::unwrap_or_clone(Arc::clone(cached.value())));
}
let resolved = resolve_package_path_uncached(reference)?;
let arc_resolved = Arc::new(resolved);
PACKAGE_CACHE.insert(reference.to_string(), Arc::clone(&arc_resolved));
Ok(Arc::unwrap_or_clone(arc_resolved))
}
fn resolve_package_path_uncached(reference: &str) -> Result<ResolvedPackage, ResolverError> {
let pkg_ref = parse_package_ref(reference)?;
let packages_dir = get_packages_dir()?;
let package_base = packages_dir
.join(pkg_ref.scope.trim_start_matches('@'))
.join(&pkg_ref.name);
if !package_base.exists() {
return Err(ResolverError::PackageNotFound(pkg_ref.full_name()));
}
let version = match &pkg_ref.version {
Some(v) => {
let version_dir = package_base.join(v);
if !version_dir.exists() {
return Err(ResolverError::PackageNotFound(pkg_ref.full_ref()));
}
v.clone()
}
None => {
if let Ok(lockfile) = Lockfile::load(None) {
if let Some(locked_version) = lockfile.find_version(&pkg_ref.full_name()) {
let version_dir = package_base.join(locked_version);
if version_dir.exists() {
locked_version.to_string()
} else {
find_latest_version(&package_base)?
}
} else {
find_latest_version(&package_base)?
}
} else {
find_latest_version(&package_base)?
}
}
};
let package_path = package_base.join(&version);
let manifest_path = package_path.join("manifest.yaml");
if !manifest_path.exists() {
return Err(ResolverError::ManifestError(format!(
"Manifest not found at: {}",
manifest_path.display()
)));
}
let manifest_content = std::fs::read_to_string(&manifest_path)?;
let manifest: Manifest = crate::serde_yaml::from_str(&manifest_content)
.map_err(|e| ResolverError::ManifestError(e.to_string()))?;
Ok(ResolvedPackage {
path: package_path,
manifest,
version,
})
}
fn get_packages_dir() -> Result<PathBuf, ResolverError> {
let home = dirs::home_dir().ok_or_else(|| {
ResolverError::IoError(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Could not determine home directory",
))
})?;
Ok(home.join(".nika").join("packages"))
}
fn find_latest_version(package_base: &PathBuf) -> Result<String, ResolverError> {
let mut versions: Vec<(Version, String)> = Vec::new();
for entry in std::fs::read_dir(package_base)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
if let Some(version_str) = entry.file_name().to_str() {
if let Ok(version) = Version::parse(version_str) {
versions.push((version, version_str.to_string()));
}
}
}
}
if versions.is_empty() {
return Err(ResolverError::PackageNotFound(format!(
"No valid semantic versions found in {}",
package_base.display()
)));
}
versions.sort_by(|a, b| a.0.cmp(&b.0));
versions.last().map(|(_, s)| s.clone()).ok_or_else(|| {
ResolverError::PackageNotFound(format!(
"No valid semantic versions found in {}",
package_base.display()
))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_package_ref_basic() {
let pkg = parse_package_ref("@workflows/seo-audit").unwrap();
assert_eq!(pkg.scope, "@workflows");
assert_eq!(pkg.name, "seo-audit");
assert_eq!(pkg.version, None);
assert_eq!(pkg.full_name(), "@workflows/seo-audit");
}
#[test]
fn test_parse_package_ref_with_version() {
let pkg = parse_package_ref("@workflows/seo-audit@1.2.0").unwrap();
assert_eq!(pkg.scope, "@workflows");
assert_eq!(pkg.name, "seo-audit");
assert_eq!(pkg.version, Some("1.2.0".to_string()));
assert_eq!(pkg.full_ref(), "@workflows/seo-audit@1.2.0");
}
#[test]
fn test_parse_package_ref_nested_scope() {
let pkg = parse_package_ref("@workflows/data/transformer").unwrap();
assert_eq!(pkg.scope, "@workflows");
assert_eq!(pkg.name, "data/transformer");
}
#[test]
fn test_parse_package_ref_invalid_no_scope() {
let result = parse_package_ref("seo-audit");
assert!(result.is_err());
assert!(matches!(result, Err(ResolverError::InvalidFormat(_))));
}
#[test]
fn test_parse_package_ref_invalid_no_name() {
let result = parse_package_ref("@workflows/");
assert!(result.is_err());
}
#[test]
fn test_parse_package_ref_invalid_empty() {
let result = parse_package_ref("");
assert!(result.is_err());
}
#[test]
fn test_parse_package_ref_invalid_no_slash() {
let result = parse_package_ref("@workflows");
assert!(result.is_err());
}
#[test]
fn test_parse_package_ref_with_prerelease() {
let pkg = parse_package_ref("@workflows/seo-audit@1.2.0-beta.1").unwrap();
assert_eq!(pkg.version, Some("1.2.0-beta.1".to_string()));
}
#[test]
fn test_package_ref_full_name() {
let pkg = PackageRef {
scope: "@workflows".to_string(),
name: "seo-audit".to_string(),
version: None,
};
assert_eq!(pkg.full_name(), "@workflows/seo-audit");
}
#[test]
fn test_package_ref_full_ref_no_version() {
let pkg = PackageRef {
scope: "@workflows".to_string(),
name: "seo-audit".to_string(),
version: None,
};
assert_eq!(pkg.full_ref(), "@workflows/seo-audit");
}
#[test]
fn test_package_ref_full_ref_with_version() {
let pkg = PackageRef {
scope: "@workflows".to_string(),
name: "seo-audit".to_string(),
version: Some("1.2.0".to_string()),
};
assert_eq!(pkg.full_ref(), "@workflows/seo-audit@1.2.0");
}
#[test]
fn test_clear_cache() {
use super::clear_cache;
clear_cache();
let (size, _) = cache_stats();
assert_eq!(size, 0, "Cache should be empty after clear");
}
#[test]
fn test_cache_stats() {
use super::{cache_stats, clear_cache};
clear_cache();
let (size, _capacity) = cache_stats();
assert_eq!(size, 0, "Cache should start empty");
}
}