use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Manifest {
pub name: String,
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub authors: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub skills: HashMap<String, SkillEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dependencies: Option<HashMap<String, String>>,
}
impl Manifest {
pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
Self {
name: name.into(),
version: version.into(),
description: None,
authors: None,
license: None,
repository: None,
skills: HashMap::new(),
dependencies: None,
}
}
pub fn identifier(&self) -> String {
format!("{}@{}", self.name, self.version)
}
pub fn has_skills(&self) -> bool {
!self.skills.is_empty()
}
pub fn skill_count(&self) -> usize {
self.skills.len()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SkillEntry {
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
impl SkillEntry {
pub fn new(path: impl Into<String>) -> Self {
Self {
path: path.into(),
description: None,
}
}
pub fn with_description(path: impl Into<String>, description: impl Into<String>) -> Self {
Self {
path: path.into(),
description: Some(description.into()),
}
}
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct RegistryIndex {
#[serde(default)]
pub packages: HashMap<String, InstalledPackage>,
}
impl RegistryIndex {
pub fn new() -> Self {
Self::default()
}
pub fn is_installed(&self, name: &str) -> bool {
self.packages.contains_key(name)
}
pub fn get(&self, name: &str) -> Option<&InstalledPackage> {
self.packages.get(name)
}
pub fn insert(&mut self, name: impl Into<String>, package: InstalledPackage) {
self.packages.insert(name.into(), package);
}
pub fn remove(&mut self, name: &str) -> Option<InstalledPackage> {
self.packages.remove(name)
}
pub fn len(&self) -> usize {
self.packages.len()
}
pub fn is_empty(&self) -> bool {
self.packages.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = (&String, &InstalledPackage)> {
self.packages.iter()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InstalledPackage {
pub version: String,
pub installed_at: String,
pub manifest_path: String,
}
impl InstalledPackage {
pub fn new(
version: impl Into<String>,
installed_at: impl Into<String>,
manifest_path: impl Into<String>,
) -> Self {
Self {
version: version.into(),
installed_at: installed_at.into(),
manifest_path: manifest_path.into(),
}
}
pub fn now(version: impl Into<String>, manifest_path: impl Into<String>) -> Self {
Self {
version: version.into(),
installed_at: chrono::Utc::now().to_rfc3339(),
manifest_path: manifest_path.into(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_saphyr;
#[test]
fn test_manifest_new() {
let manifest = Manifest::new("@supernovae/test", "1.0.0");
assert_eq!(manifest.name, "@supernovae/test");
assert_eq!(manifest.version, "1.0.0");
assert!(manifest.description.is_none());
assert!(manifest.skills.is_empty());
}
#[test]
fn test_manifest_identifier() {
let manifest = Manifest::new("@supernovae/workflows", "2.1.0");
assert_eq!(manifest.identifier(), "@supernovae/workflows@2.1.0");
}
#[test]
fn test_manifest_has_skills() {
let mut manifest = Manifest::new("@test/pkg", "1.0.0");
assert!(!manifest.has_skills());
manifest
.skills
.insert("test".to_string(), SkillEntry::new("skills/test.md"));
assert!(manifest.has_skills());
assert_eq!(manifest.skill_count(), 1);
}
#[test]
fn test_manifest_yaml_roundtrip() {
let mut manifest = Manifest::new("@supernovae/workflows", "1.0.0");
manifest.description = Some("Test package".to_string());
manifest.authors = Some(vec!["Author One".to_string()]);
manifest.license = Some("MIT".to_string());
manifest.skills.insert(
"brainstorm".to_string(),
SkillEntry::with_description("skills/brainstorm.md", "Brainstorm skill"),
);
let yaml = serde_saphyr::to_string(&manifest).unwrap();
let parsed: Manifest = serde_saphyr::from_str(&yaml).unwrap();
assert_eq!(manifest, parsed);
}
#[test]
fn test_skill_entry_new() {
let entry = SkillEntry::new("skills/test.skill.md");
assert_eq!(entry.path, "skills/test.skill.md");
assert!(entry.description.is_none());
}
#[test]
fn test_skill_entry_with_description() {
let entry = SkillEntry::with_description("skills/review.md", "Code review skill");
assert_eq!(entry.path, "skills/review.md");
assert_eq!(entry.description.as_deref(), Some("Code review skill"));
}
#[test]
fn test_registry_index_operations() {
let mut index = RegistryIndex::new();
assert!(index.is_empty());
assert!(!index.is_installed("@test/pkg"));
let pkg = InstalledPackage::new(
"1.0.0",
"2026-03-01T10:00:00Z",
"packages/@test/pkg/1.0.0/manifest.yaml",
);
index.insert("@test/pkg", pkg.clone());
assert!(!index.is_empty());
assert_eq!(index.len(), 1);
assert!(index.is_installed("@test/pkg"));
assert_eq!(index.get("@test/pkg"), Some(&pkg));
let removed = index.remove("@test/pkg");
assert_eq!(removed, Some(pkg));
assert!(index.is_empty());
}
#[test]
fn test_registry_index_yaml_roundtrip() {
let mut index = RegistryIndex::new();
index.insert(
"@supernovae/workflows",
InstalledPackage::new(
"1.0.0",
"2026-03-01T10:30:00Z",
"packages/@supernovae/workflows/1.0.0/manifest.yaml",
),
);
index.insert(
"@supernovae/core",
InstalledPackage::new(
"0.8.0",
"2026-02-28T15:45:00Z",
"packages/@supernovae/core/0.8.0/manifest.yaml",
),
);
let yaml = serde_saphyr::to_string(&index).unwrap();
let parsed: RegistryIndex = serde_saphyr::from_str(&yaml).unwrap();
assert_eq!(index.len(), parsed.len());
assert!(parsed.is_installed("@supernovae/workflows"));
assert!(parsed.is_installed("@supernovae/core"));
}
#[test]
fn test_installed_package_new() {
let pkg = InstalledPackage::new(
"2.0.0",
"2026-03-01T12:00:00Z",
"packages/@test/pkg/2.0.0/manifest.yaml",
);
assert_eq!(pkg.version, "2.0.0");
assert_eq!(pkg.installed_at, "2026-03-01T12:00:00Z");
assert_eq!(pkg.manifest_path, "packages/@test/pkg/2.0.0/manifest.yaml");
}
#[test]
fn test_installed_package_now() {
let pkg = InstalledPackage::now("1.0.0", "packages/@test/pkg/1.0.0/manifest.yaml");
assert_eq!(pkg.version, "1.0.0");
assert!(!pkg.installed_at.is_empty());
assert!(pkg.installed_at.contains('T'));
}
#[test]
fn test_registry_index_iter() {
let mut index = RegistryIndex::new();
index.insert(
"@pkg/a",
InstalledPackage::new("1.0.0", "2026-01-01T00:00:00Z", "a/manifest.yaml"),
);
index.insert(
"@pkg/b",
InstalledPackage::new("2.0.0", "2026-01-02T00:00:00Z", "b/manifest.yaml"),
);
let names: Vec<_> = index.iter().map(|(name, _)| name.as_str()).collect();
assert_eq!(names.len(), 2);
assert!(names.contains(&"@pkg/a"));
assert!(names.contains(&"@pkg/b"));
}
}