Skip to main content

nika_engine/registry/
types.rs

1//! Registry type definitions for the SuperNovae package system.
2//!
3//! This module defines the core types for the `~/.nika/packages/` registry:
4//! - `Manifest` - Package metadata from manifest.yaml
5//! - `SkillEntry` - Individual skill definition within a package
6//! - `RegistryIndex` - Installed packages index (registry.yaml)
7//! - `InstalledPackage` - Metadata for an installed package
8
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12/// Package manifest loaded from `manifest.yaml`.
13///
14/// Located at: `~/.nika/packages/@scope/name/version/manifest.yaml`
15///
16/// # Example
17///
18/// ```yaml
19/// name: "@supernovae/workflows"
20/// version: "1.0.0"
21/// description: "Production workflow templates"
22/// authors:
23///   - "SuperNovae Team"
24/// license: "MIT"
25/// repository: "https://github.com/supernovae/workflows"
26/// skills:
27///   brainstorm:
28///     path: "skills/brainstorm.skill.md"
29///     description: "Collaborative ideation skill"
30///   review:
31///     path: "skills/review.skill.md"
32///     description: "Code review skill"
33/// dependencies:
34///   "@supernovae/core": "^0.8.0"
35/// ```
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37pub struct Manifest {
38    /// Package name (e.g., "@supernovae/workflows")
39    pub name: String,
40
41    /// Semantic version (e.g., "1.0.0")
42    pub version: String,
43
44    /// Human-readable description
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub description: Option<String>,
47
48    /// Package authors
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub authors: Option<Vec<String>>,
51
52    /// SPDX license identifier (e.g., "MIT", "Apache-2.0")
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub license: Option<String>,
55
56    /// Repository URL
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub repository: Option<String>,
59
60    /// Skills provided by this package
61    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
62    pub skills: HashMap<String, SkillEntry>,
63
64    /// Package dependencies (name -> version constraint)
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub dependencies: Option<HashMap<String, String>>,
67}
68
69impl Manifest {
70    /// Create a new manifest with required fields.
71    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
72        Self {
73            name: name.into(),
74            version: version.into(),
75            description: None,
76            authors: None,
77            license: None,
78            repository: None,
79            skills: HashMap::new(),
80            dependencies: None,
81        }
82    }
83
84    /// Get the scoped package identifier (e.g., "@scope/name@1.0.0").
85    pub fn identifier(&self) -> String {
86        format!("{}@{}", self.name, self.version)
87    }
88
89    /// Check if this package has any skills.
90    pub fn has_skills(&self) -> bool {
91        !self.skills.is_empty()
92    }
93
94    /// Get skill count.
95    pub fn skill_count(&self) -> usize {
96        self.skills.len()
97    }
98}
99
100/// Individual skill entry within a package manifest.
101#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102pub struct SkillEntry {
103    /// Relative path to skill file from package root
104    pub path: String,
105
106    /// Human-readable description
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub description: Option<String>,
109}
110
111impl SkillEntry {
112    /// Create a new skill entry with required path.
113    pub fn new(path: impl Into<String>) -> Self {
114        Self {
115            path: path.into(),
116            description: None,
117        }
118    }
119
120    /// Create a skill entry with path and description.
121    pub fn with_description(path: impl Into<String>, description: impl Into<String>) -> Self {
122        Self {
123            path: path.into(),
124            description: Some(description.into()),
125        }
126    }
127}
128
129/// Registry index tracking all installed packages.
130///
131/// Located at: `~/.nika/registry.yaml`
132///
133/// # Example
134///
135/// ```yaml
136/// packages:
137///   "@supernovae/workflows":
138///     version: "1.0.0"
139///     installed_at: "2026-03-01T10:30:00Z"
140///     manifest_path: "packages/@supernovae/workflows/1.0.0/manifest.yaml"
141///   "@supernovae/core":
142///     version: "0.8.0"
143///     installed_at: "2026-02-28T15:45:00Z"
144///     manifest_path: "packages/@supernovae/core/0.8.0/manifest.yaml"
145/// ```
146#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
147pub struct RegistryIndex {
148    /// Map of package name -> installed package info
149    #[serde(default)]
150    pub packages: HashMap<String, InstalledPackage>,
151}
152
153impl RegistryIndex {
154    /// Create an empty registry index.
155    pub fn new() -> Self {
156        Self::default()
157    }
158
159    /// Check if a package is installed.
160    pub fn is_installed(&self, name: &str) -> bool {
161        self.packages.contains_key(name)
162    }
163
164    /// Get installed package info.
165    pub fn get(&self, name: &str) -> Option<&InstalledPackage> {
166        self.packages.get(name)
167    }
168
169    /// Add or update an installed package.
170    pub fn insert(&mut self, name: impl Into<String>, package: InstalledPackage) {
171        self.packages.insert(name.into(), package);
172    }
173
174    /// Remove a package from the index.
175    pub fn remove(&mut self, name: &str) -> Option<InstalledPackage> {
176        self.packages.remove(name)
177    }
178
179    /// Get the number of installed packages.
180    pub fn len(&self) -> usize {
181        self.packages.len()
182    }
183
184    /// Check if the registry is empty.
185    pub fn is_empty(&self) -> bool {
186        self.packages.is_empty()
187    }
188
189    /// Iterate over installed packages.
190    pub fn iter(&self) -> impl Iterator<Item = (&String, &InstalledPackage)> {
191        self.packages.iter()
192    }
193}
194
195/// Metadata for an installed package in the registry.
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
197pub struct InstalledPackage {
198    /// Installed version
199    pub version: String,
200
201    /// ISO 8601 timestamp of installation
202    pub installed_at: String,
203
204    /// Relative path to manifest.yaml from ~/.nika/
205    pub manifest_path: String,
206}
207
208impl InstalledPackage {
209    /// Create new installed package metadata.
210    pub fn new(
211        version: impl Into<String>,
212        installed_at: impl Into<String>,
213        manifest_path: impl Into<String>,
214    ) -> Self {
215        Self {
216            version: version.into(),
217            installed_at: installed_at.into(),
218            manifest_path: manifest_path.into(),
219        }
220    }
221
222    /// Create installed package with current timestamp.
223    pub fn now(version: impl Into<String>, manifest_path: impl Into<String>) -> Self {
224        Self {
225            version: version.into(),
226            installed_at: chrono::Utc::now().to_rfc3339(),
227            manifest_path: manifest_path.into(),
228        }
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use serde_saphyr;
236
237    #[test]
238    fn test_manifest_new() {
239        let manifest = Manifest::new("@supernovae/test", "1.0.0");
240        assert_eq!(manifest.name, "@supernovae/test");
241        assert_eq!(manifest.version, "1.0.0");
242        assert!(manifest.description.is_none());
243        assert!(manifest.skills.is_empty());
244    }
245
246    #[test]
247    fn test_manifest_identifier() {
248        let manifest = Manifest::new("@supernovae/workflows", "2.1.0");
249        assert_eq!(manifest.identifier(), "@supernovae/workflows@2.1.0");
250    }
251
252    #[test]
253    fn test_manifest_has_skills() {
254        let mut manifest = Manifest::new("@test/pkg", "1.0.0");
255        assert!(!manifest.has_skills());
256
257        manifest
258            .skills
259            .insert("test".to_string(), SkillEntry::new("skills/test.md"));
260        assert!(manifest.has_skills());
261        assert_eq!(manifest.skill_count(), 1);
262    }
263
264    #[test]
265    fn test_manifest_yaml_roundtrip() {
266        let mut manifest = Manifest::new("@supernovae/workflows", "1.0.0");
267        manifest.description = Some("Test package".to_string());
268        manifest.authors = Some(vec!["Author One".to_string()]);
269        manifest.license = Some("MIT".to_string());
270        manifest.skills.insert(
271            "brainstorm".to_string(),
272            SkillEntry::with_description("skills/brainstorm.md", "Brainstorm skill"),
273        );
274
275        let yaml = serde_saphyr::to_string(&manifest).unwrap();
276        let parsed: Manifest = serde_saphyr::from_str(&yaml).unwrap();
277
278        assert_eq!(manifest, parsed);
279    }
280
281    #[test]
282    fn test_skill_entry_new() {
283        let entry = SkillEntry::new("skills/test.skill.md");
284        assert_eq!(entry.path, "skills/test.skill.md");
285        assert!(entry.description.is_none());
286    }
287
288    #[test]
289    fn test_skill_entry_with_description() {
290        let entry = SkillEntry::with_description("skills/review.md", "Code review skill");
291        assert_eq!(entry.path, "skills/review.md");
292        assert_eq!(entry.description.as_deref(), Some("Code review skill"));
293    }
294
295    #[test]
296    fn test_registry_index_operations() {
297        let mut index = RegistryIndex::new();
298        assert!(index.is_empty());
299        assert!(!index.is_installed("@test/pkg"));
300
301        let pkg = InstalledPackage::new(
302            "1.0.0",
303            "2026-03-01T10:00:00Z",
304            "packages/@test/pkg/1.0.0/manifest.yaml",
305        );
306        index.insert("@test/pkg", pkg.clone());
307
308        assert!(!index.is_empty());
309        assert_eq!(index.len(), 1);
310        assert!(index.is_installed("@test/pkg"));
311        assert_eq!(index.get("@test/pkg"), Some(&pkg));
312
313        let removed = index.remove("@test/pkg");
314        assert_eq!(removed, Some(pkg));
315        assert!(index.is_empty());
316    }
317
318    #[test]
319    fn test_registry_index_yaml_roundtrip() {
320        let mut index = RegistryIndex::new();
321        index.insert(
322            "@supernovae/workflows",
323            InstalledPackage::new(
324                "1.0.0",
325                "2026-03-01T10:30:00Z",
326                "packages/@supernovae/workflows/1.0.0/manifest.yaml",
327            ),
328        );
329        index.insert(
330            "@supernovae/core",
331            InstalledPackage::new(
332                "0.8.0",
333                "2026-02-28T15:45:00Z",
334                "packages/@supernovae/core/0.8.0/manifest.yaml",
335            ),
336        );
337
338        let yaml = serde_saphyr::to_string(&index).unwrap();
339        let parsed: RegistryIndex = serde_saphyr::from_str(&yaml).unwrap();
340
341        assert_eq!(index.len(), parsed.len());
342        assert!(parsed.is_installed("@supernovae/workflows"));
343        assert!(parsed.is_installed("@supernovae/core"));
344    }
345
346    #[test]
347    fn test_installed_package_new() {
348        let pkg = InstalledPackage::new(
349            "2.0.0",
350            "2026-03-01T12:00:00Z",
351            "packages/@test/pkg/2.0.0/manifest.yaml",
352        );
353        assert_eq!(pkg.version, "2.0.0");
354        assert_eq!(pkg.installed_at, "2026-03-01T12:00:00Z");
355        assert_eq!(pkg.manifest_path, "packages/@test/pkg/2.0.0/manifest.yaml");
356    }
357
358    #[test]
359    fn test_installed_package_now() {
360        let pkg = InstalledPackage::now("1.0.0", "packages/@test/pkg/1.0.0/manifest.yaml");
361        assert_eq!(pkg.version, "1.0.0");
362        assert!(!pkg.installed_at.is_empty());
363        // Check ISO 8601 format
364        assert!(pkg.installed_at.contains('T'));
365    }
366
367    #[test]
368    fn test_registry_index_iter() {
369        let mut index = RegistryIndex::new();
370        index.insert(
371            "@pkg/a",
372            InstalledPackage::new("1.0.0", "2026-01-01T00:00:00Z", "a/manifest.yaml"),
373        );
374        index.insert(
375            "@pkg/b",
376            InstalledPackage::new("2.0.0", "2026-01-02T00:00:00Z", "b/manifest.yaml"),
377        );
378
379        let names: Vec<_> = index.iter().map(|(name, _)| name.as_str()).collect();
380        assert_eq!(names.len(), 2);
381        assert!(names.contains(&"@pkg/a"));
382        assert!(names.contains(&"@pkg/b"));
383    }
384}