use std::fs;
use std::path::PathBuf;
#[cfg(test)]
use crate::registry::types::InstalledPackage;
use crate::registry::types::{Manifest, RegistryIndex};
use crate::serde_yaml;
use crate::NikaError;
pub use crate::core::paths::{NIKA_DIR_NAME, NIKA_HOME_ENV};
pub const REGISTRY_INDEX_FILE: &str = "registry.yaml";
pub const PACKAGES_DIR_NAME: &str = "packages";
pub const MANIFEST_FILE: &str = "manifest.yaml";
pub fn packages_dir() -> Result<PathBuf, NikaError> {
Ok(crate::core::paths::nika_home().join(PACKAGES_DIR_NAME))
}
pub fn registry_index_path() -> Result<PathBuf, NikaError> {
Ok(crate::core::paths::nika_home().join(REGISTRY_INDEX_FILE))
}
pub fn package_dir(name: &str, version: &str) -> Result<PathBuf, NikaError> {
let packages = packages_dir()?;
let package_path = if name.starts_with('@') {
packages.join(name).join(version)
} else {
packages.join(name).join(version)
};
Ok(package_path)
}
pub fn manifest_path(name: &str, version: &str) -> Result<PathBuf, NikaError> {
Ok(package_dir(name, version)?.join(MANIFEST_FILE))
}
pub fn ensure_nika_home() -> Result<PathBuf, NikaError> {
let home = crate::core::paths::nika_home();
if !home.exists() {
fs::create_dir_all(&home).map_err(|e| NikaError::ValidationError {
reason: format!("Failed to create directory '{}': {}", home.display(), e),
})?;
}
let packages = home.join(PACKAGES_DIR_NAME);
if !packages.exists() {
fs::create_dir_all(&packages).map_err(|e| NikaError::ValidationError {
reason: format!("Failed to create directory '{}': {}", packages.display(), e),
})?;
}
Ok(home)
}
pub fn load_registry() -> Result<RegistryIndex, NikaError> {
let path = registry_index_path()?;
if !path.exists() {
return Ok(RegistryIndex::new());
}
let content = fs::read_to_string(&path).map_err(|e| NikaError::ValidationError {
reason: format!("Failed to read registry file '{}': {}", path.display(), e),
})?;
serde_yaml::from_str(&content).map_err(|e| NikaError::ParseError {
details: format!("Failed to parse registry YAML: {}", e),
})
}
pub fn save_registry(index: &RegistryIndex) -> Result<(), NikaError> {
let path = registry_index_path()?;
if let Some(parent) = path.parent() {
if !parent.exists() {
fs::create_dir_all(parent).map_err(|e| NikaError::ValidationError {
reason: format!("Failed to create directory '{}': {}", parent.display(), e),
})?;
}
}
let content = serde_yaml::to_string(index).map_err(|e| NikaError::ParseError {
details: format!("Failed to serialize registry: {}", e),
})?;
fs::write(&path, content).map_err(|e| NikaError::ValidationError {
reason: format!("Failed to write registry file '{}': {}", path.display(), e),
})?;
Ok(())
}
pub fn load_manifest(name: &str, version: &str) -> Result<Manifest, NikaError> {
let path = manifest_path(name, version)?;
if !path.exists() {
return Err(NikaError::PackageNotFound {
name: name.to_string(),
version: version.to_string(),
});
}
let content = fs::read_to_string(&path).map_err(|e| NikaError::ValidationError {
reason: format!("Failed to read manifest file '{}': {}", path.display(), e),
})?;
serde_yaml::from_str(&content).map_err(|e| NikaError::ParseError {
details: format!("Failed to parse manifest YAML: {}", e),
})
}
pub fn is_installed(name: &str) -> Result<bool, NikaError> {
let index = load_registry()?;
Ok(index.is_installed(name))
}
pub fn is_version_installed(name: &str, version: &str) -> Result<bool, NikaError> {
let index = load_registry()?;
match index.get(name) {
Some(pkg) => Ok(pkg.version == version),
None => Ok(false),
}
}
pub fn installed_version(name: &str) -> Result<Option<String>, NikaError> {
let index = load_registry()?;
Ok(index.get(name).map(|pkg| pkg.version.clone()))
}
pub fn list_installed() -> Result<Vec<(String, String)>, NikaError> {
let index = load_registry()?;
Ok(index
.iter()
.map(|(name, pkg)| (name.clone(), pkg.version.clone()))
.collect())
}
pub fn resolve_skill_path(
name: &str,
version: &str,
skill_path: &str,
) -> Result<PathBuf, NikaError> {
if skill_path.contains("..") {
return Err(NikaError::SkillLoadError {
skill: format!("{}:{}", name, skill_path),
reason: "Skill path contains path traversal sequence (..)".into(),
});
}
let pkg_dir = package_dir(name, version)?;
let full_path = pkg_dir.join(skill_path);
if !full_path.exists() {
return Err(NikaError::SkillLoadError {
skill: format!("{}:{}", name, skill_path),
reason: format!("Skill file not found at '{}'", full_path.display()),
});
}
let canonical_pkg = pkg_dir
.canonicalize()
.map_err(|e| NikaError::SkillLoadError {
skill: format!("{}:{}", name, skill_path),
reason: format!("Failed to canonicalize package directory: {}", e),
})?;
let canonical_skill = full_path
.canonicalize()
.map_err(|e| NikaError::SkillLoadError {
skill: format!("{}:{}", name, skill_path),
reason: format!("Failed to canonicalize skill path: {}", e),
})?;
if !canonical_skill.starts_with(&canonical_pkg) {
return Err(NikaError::SkillLoadError {
skill: format!("{}:{}", name, skill_path),
reason: format!(
"Path traversal detected: skill file '{}' is outside package directory '{}'",
canonical_skill.display(),
canonical_pkg.display()
),
});
}
Ok(canonical_skill)
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::env;
use std::path::Path;
use tempfile::TempDir;
fn with_temp_nika_home<F, T>(f: F) -> T
where
F: FnOnce(&Path) -> T,
{
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path().to_path_buf();
env::set_var(NIKA_HOME_ENV, &temp_path);
let result = f(&temp_path);
env::remove_var(NIKA_HOME_ENV);
result
}
#[test]
#[serial]
fn test_nika_home_uses_env_var() {
with_temp_nika_home(|temp_path| {
let home = crate::core::paths::nika_home();
assert_eq!(home, temp_path);
});
}
#[test]
#[serial]
fn test_packages_dir() {
with_temp_nika_home(|temp_path| {
let dir = packages_dir().unwrap();
assert_eq!(dir, temp_path.join("packages"));
});
}
#[test]
#[serial]
fn test_registry_index_path() {
with_temp_nika_home(|temp_path| {
let path = registry_index_path().unwrap();
assert_eq!(path, temp_path.join("registry.yaml"));
});
}
#[test]
#[serial]
fn test_package_dir_scoped() {
with_temp_nika_home(|temp_path| {
let dir = package_dir("@supernovae/workflows", "1.0.0").unwrap();
assert_eq!(
dir,
temp_path
.join("packages")
.join("@supernovae/workflows")
.join("1.0.0")
);
});
}
#[test]
#[serial]
fn test_package_dir_unscoped() {
with_temp_nika_home(|temp_path| {
let dir = package_dir("my-package", "2.0.0").unwrap();
assert_eq!(
dir,
temp_path.join("packages").join("my-package").join("2.0.0")
);
});
}
#[test]
#[serial]
fn test_manifest_path() {
with_temp_nika_home(|temp_path| {
let path = manifest_path("@test/pkg", "1.0.0").unwrap();
assert_eq!(
path,
temp_path
.join("packages")
.join("@test/pkg")
.join("1.0.0")
.join("manifest.yaml")
);
});
}
#[test]
#[serial]
fn test_ensure_nika_home() {
with_temp_nika_home(|temp_path| {
let _ = fs::remove_dir_all(temp_path);
let home = ensure_nika_home().unwrap();
assert!(home.exists());
assert!(home.join("packages").exists());
});
}
#[test]
#[serial]
fn test_load_registry_empty() {
with_temp_nika_home(|_| {
let index = load_registry().unwrap();
assert!(index.is_empty());
});
}
#[test]
#[serial]
fn test_save_and_load_registry() {
with_temp_nika_home(|_| {
ensure_nika_home().unwrap();
let mut index = RegistryIndex::new();
index.insert(
"@test/pkg",
InstalledPackage::new(
"1.0.0",
"2026-03-01T10:00:00Z",
"packages/@test/pkg/1.0.0/manifest.yaml",
),
);
save_registry(&index).unwrap();
let loaded = load_registry().unwrap();
assert_eq!(loaded.len(), 1);
assert!(loaded.is_installed("@test/pkg"));
assert_eq!(loaded.get("@test/pkg").unwrap().version, "1.0.0");
});
}
#[test]
#[serial]
fn test_is_installed() {
with_temp_nika_home(|_| {
ensure_nika_home().unwrap();
assert!(!is_installed("@test/pkg").unwrap());
let mut index = RegistryIndex::new();
index.insert(
"@test/pkg",
InstalledPackage::new("1.0.0", "2026-03-01T10:00:00Z", "path/to/manifest.yaml"),
);
save_registry(&index).unwrap();
assert!(is_installed("@test/pkg").unwrap());
});
}
#[test]
#[serial]
fn test_is_version_installed() {
with_temp_nika_home(|_| {
ensure_nika_home().unwrap();
let mut index = RegistryIndex::new();
index.insert(
"@test/pkg",
InstalledPackage::new("1.0.0", "2026-03-01T10:00:00Z", "path"),
);
save_registry(&index).unwrap();
assert!(is_version_installed("@test/pkg", "1.0.0").unwrap());
assert!(!is_version_installed("@test/pkg", "2.0.0").unwrap());
assert!(!is_version_installed("@other/pkg", "1.0.0").unwrap());
});
}
#[test]
#[serial]
fn test_installed_version() {
with_temp_nika_home(|_| {
ensure_nika_home().unwrap();
assert_eq!(installed_version("@test/pkg").unwrap(), None);
let mut index = RegistryIndex::new();
index.insert(
"@test/pkg",
InstalledPackage::new("2.1.0", "2026-03-01T10:00:00Z", "path"),
);
save_registry(&index).unwrap();
assert_eq!(
installed_version("@test/pkg").unwrap(),
Some("2.1.0".to_string())
);
});
}
#[test]
#[serial]
fn test_list_installed() {
with_temp_nika_home(|_| {
ensure_nika_home().unwrap();
let mut index = RegistryIndex::new();
index.insert(
"@pkg/a",
InstalledPackage::new("1.0.0", "2026-03-01T10:00:00Z", "a"),
);
index.insert(
"@pkg/b",
InstalledPackage::new("2.0.0", "2026-03-01T10:00:00Z", "b"),
);
save_registry(&index).unwrap();
let list = list_installed().unwrap();
assert_eq!(list.len(), 2);
let names: Vec<_> = list.iter().map(|(n, _)| n.as_str()).collect();
assert!(names.contains(&"@pkg/a"));
assert!(names.contains(&"@pkg/b"));
});
}
#[test]
#[serial]
fn test_load_manifest_not_found() {
with_temp_nika_home(|_| {
ensure_nika_home().unwrap();
let result = load_manifest("@nonexistent/pkg", "1.0.0");
assert!(result.is_err());
});
}
#[test]
#[serial]
fn test_load_manifest_success() {
with_temp_nika_home(|_| {
ensure_nika_home().unwrap();
let pkg_dir = package_dir("@test/pkg", "1.0.0").unwrap();
fs::create_dir_all(&pkg_dir).unwrap();
let manifest = Manifest::new("@test/pkg", "1.0.0");
let manifest_content = serde_yaml::to_string(&manifest).unwrap();
fs::write(pkg_dir.join("manifest.yaml"), manifest_content).unwrap();
let loaded = load_manifest("@test/pkg", "1.0.0").unwrap();
assert_eq!(loaded.name, "@test/pkg");
assert_eq!(loaded.version, "1.0.0");
});
}
#[test]
#[serial]
fn test_resolve_skill_path() {
with_temp_nika_home(|_| {
ensure_nika_home().unwrap();
let pkg_dir = package_dir("@test/pkg", "1.0.0").unwrap();
let skills_dir = pkg_dir.join("skills");
fs::create_dir_all(&skills_dir).unwrap();
fs::write(skills_dir.join("test.skill.md"), "# Test Skill").unwrap();
let path = resolve_skill_path("@test/pkg", "1.0.0", "skills/test.skill.md").unwrap();
assert!(path.exists());
assert!(path.ends_with("skills/test.skill.md"));
});
}
#[test]
#[serial]
fn test_resolve_skill_path_not_found() {
with_temp_nika_home(|_| {
ensure_nika_home().unwrap();
let pkg_dir = package_dir("@test/pkg", "1.0.0").unwrap();
fs::create_dir_all(&pkg_dir).unwrap();
let result = resolve_skill_path("@test/pkg", "1.0.0", "skills/nonexistent.md");
assert!(result.is_err());
});
}
}