1use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37pub struct Manifest {
38 pub name: String,
40
41 pub version: String,
43
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub description: Option<String>,
47
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub authors: Option<Vec<String>>,
51
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub license: Option<String>,
55
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub repository: Option<String>,
59
60 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
62 pub skills: HashMap<String, SkillEntry>,
63
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub dependencies: Option<HashMap<String, String>>,
67}
68
69impl Manifest {
70 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 pub fn identifier(&self) -> String {
86 format!("{}@{}", self.name, self.version)
87 }
88
89 pub fn has_skills(&self) -> bool {
91 !self.skills.is_empty()
92 }
93
94 pub fn skill_count(&self) -> usize {
96 self.skills.len()
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102pub struct SkillEntry {
103 pub path: String,
105
106 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub description: Option<String>,
109}
110
111impl SkillEntry {
112 pub fn new(path: impl Into<String>) -> Self {
114 Self {
115 path: path.into(),
116 description: None,
117 }
118 }
119
120 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#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
147pub struct RegistryIndex {
148 #[serde(default)]
150 pub packages: HashMap<String, InstalledPackage>,
151}
152
153impl RegistryIndex {
154 pub fn new() -> Self {
156 Self::default()
157 }
158
159 pub fn is_installed(&self, name: &str) -> bool {
161 self.packages.contains_key(name)
162 }
163
164 pub fn get(&self, name: &str) -> Option<&InstalledPackage> {
166 self.packages.get(name)
167 }
168
169 pub fn insert(&mut self, name: impl Into<String>, package: InstalledPackage) {
171 self.packages.insert(name.into(), package);
172 }
173
174 pub fn remove(&mut self, name: &str) -> Option<InstalledPackage> {
176 self.packages.remove(name)
177 }
178
179 pub fn len(&self) -> usize {
181 self.packages.len()
182 }
183
184 pub fn is_empty(&self) -> bool {
186 self.packages.is_empty()
187 }
188
189 pub fn iter(&self) -> impl Iterator<Item = (&String, &InstalledPackage)> {
191 self.packages.iter()
192 }
193}
194
195#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
197pub struct InstalledPackage {
198 pub version: String,
200
201 pub installed_at: String,
203
204 pub manifest_path: String,
206}
207
208impl InstalledPackage {
209 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 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 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}