Skip to main content

spn_core/
registry.rs

1//! Package registry types for the SuperNovae ecosystem.
2//!
3//! These types are used by spn-client for package resolution.
4
5use std::collections::HashMap;
6
7/// Type of package in the registry.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
9#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
10#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
11pub enum PackageType {
12    /// Workflow package (nika workflows)
13    #[default]
14    Workflow,
15    /// Skill package (Claude Code skills)
16    Skill,
17    /// Agent package
18    Agent,
19    /// Prompt package
20    Prompt,
21    /// Job package
22    Job,
23    /// Schema package
24    Schema,
25    /// MCP server package
26    Mcp,
27    /// Model package (ollama/huggingface)
28    Model,
29    /// Data package
30    Data,
31}
32
33/// Source of a package - where to fetch the actual content.
34///
35/// The registry contains metadata; sources define where to get the content.
36#[derive(Debug, Clone, PartialEq, Eq)]
37#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
38#[cfg_attr(feature = "serde", serde(tag = "type", rename_all = "lowercase"))]
39pub enum Source {
40    /// Tarball from our CDN or GitHub releases
41    Tarball {
42        /// URL to download the tarball
43        url: String,
44        /// SHA256 checksum for verification
45        checksum: String,
46    },
47    /// NPM package (for MCP servers)
48    Npm {
49        /// NPM package name (e.g., "@modelcontextprotocol/server-filesystem")
50        package: String,
51        /// Version constraint (e.g., "^1.0.0")
52        #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
53        version: Option<String>,
54    },
55    /// PyPI package (for Python MCP servers)
56    PyPi {
57        /// PyPI package name
58        package: String,
59        /// Version constraint
60        #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
61        version: Option<String>,
62    },
63    /// Pre-built binary (platform-specific)
64    Binary {
65        /// Platform → URL mapping (e.g., "darwin-arm64" → "https://...")
66        platforms: HashMap<String, String>,
67    },
68    /// Ollama model
69    Ollama {
70        /// Model name (e.g., "deepseek-coder:6.7b")
71        model: String,
72    },
73    /// HuggingFace model
74    HuggingFace {
75        /// Repository ID (e.g., "deepseek-ai/deepseek-coder-6.7b")
76        repo: String,
77        /// Quantization type (e.g., "Q4_K_M")
78        #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
79        quantization: Option<String>,
80    },
81}
82
83/// Reference to a package in the registry.
84///
85/// # Format
86///
87/// - `@scope/name` - Latest version
88/// - `@scope/name@1.2.3` - Specific version
89/// - `@scope/name@^1.0` - Version range
90///
91/// # Example
92///
93/// ```
94/// use spn_core::PackageRef;
95///
96/// let pkg = PackageRef::parse("@workflows/code-review@1.0.0").unwrap();
97/// assert_eq!(pkg.scope, Some("workflows".to_string()));
98/// assert_eq!(pkg.name, "code-review");
99/// assert_eq!(pkg.version, Some("1.0.0".to_string()));
100/// ```
101#[derive(Debug, Clone, PartialEq, Eq)]
102#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
103pub struct PackageRef {
104    /// Package scope (e.g., "workflows" from "@workflows/name")
105    pub scope: Option<String>,
106    /// Package name
107    pub name: String,
108    /// Version constraint
109    pub version: Option<String>,
110}
111
112impl PackageRef {
113    /// Parse a package reference string.
114    ///
115    /// Supports formats:
116    /// - `name`
117    /// - `name@version`
118    /// - `@scope/name`
119    /// - `@scope/name@version`
120    pub fn parse(input: &str) -> Option<Self> {
121        let input = input.trim();
122        if input.is_empty() {
123            return None;
124        }
125
126        // Check if scoped (@scope/name@version)
127        if let Some(without_at) = input.strip_prefix('@') {
128            let (scope, rest) = without_at.split_once('/')?;
129
130            // Check for version
131            if let Some((name, version)) = rest.split_once('@') {
132                Some(PackageRef {
133                    scope: Some(scope.to_string()),
134                    name: name.to_string(),
135                    version: Some(version.to_string()),
136                })
137            } else {
138                Some(PackageRef {
139                    scope: Some(scope.to_string()),
140                    name: rest.to_string(),
141                    version: None,
142                })
143            }
144        } else {
145            // name or name@version
146            if let Some((name, version)) = input.split_once('@') {
147                Some(PackageRef {
148                    scope: None,
149                    name: name.to_string(),
150                    version: Some(version.to_string()),
151                })
152            } else {
153                Some(PackageRef {
154                    scope: None,
155                    name: input.to_string(),
156                    version: None,
157                })
158            }
159        }
160    }
161
162    /// Get the full package name (with scope if present).
163    pub fn full_name(&self) -> String {
164        match &self.scope {
165            Some(scope) => format!("@{}/{}", scope, self.name),
166            None => self.name.clone(),
167        }
168    }
169
170    /// Get the full package reference string.
171    pub fn to_string_with_version(&self) -> String {
172        match &self.version {
173            Some(v) => format!("{}@{}", self.full_name(), v),
174            None => self.full_name(),
175        }
176    }
177}
178
179/// Package manifest (spn.yaml content).
180#[derive(Debug, Clone, Default)]
181#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
182pub struct PackageManifest {
183    /// Package name
184    pub name: String,
185    /// Package version
186    pub version: String,
187    /// Package description
188    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
189    pub description: Option<String>,
190    /// Package type
191    #[cfg_attr(feature = "serde", serde(rename = "type"))]
192    pub package_type: PackageType,
193    /// Source - where to fetch the actual content
194    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
195    pub source: Option<Source>,
196    /// Authors
197    #[cfg_attr(
198        feature = "serde",
199        serde(skip_serializing_if = "Vec::is_empty", default)
200    )]
201    pub authors: Vec<String>,
202    /// License
203    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
204    pub license: Option<String>,
205    /// Repository URL
206    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
207    pub repository: Option<String>,
208    /// Keywords for search
209    #[cfg_attr(
210        feature = "serde",
211        serde(skip_serializing_if = "Vec::is_empty", default)
212    )]
213    pub keywords: Vec<String>,
214    /// Dependencies
215    #[cfg_attr(
216        feature = "serde",
217        serde(skip_serializing_if = "Vec::is_empty", default)
218    )]
219    pub dependencies: Vec<PackageRef>,
220}
221
222impl PackageManifest {
223    /// Create a new package manifest.
224    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
225        Self {
226            name: name.into(),
227            version: version.into(),
228            ..Default::default()
229        }
230    }
231
232    /// Get the package reference for this manifest.
233    pub fn as_ref(&self) -> PackageRef {
234        PackageRef {
235            scope: None, // TODO: Extract from name if scoped
236            name: self.name.clone(),
237            version: Some(self.version.clone()),
238        }
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn test_parse_simple_name() {
248        let pkg = PackageRef::parse("code-review").unwrap();
249        assert_eq!(pkg.scope, None);
250        assert_eq!(pkg.name, "code-review");
251        assert_eq!(pkg.version, None);
252    }
253
254    #[test]
255    fn test_parse_name_with_version() {
256        let pkg = PackageRef::parse("code-review@1.0.0").unwrap();
257        assert_eq!(pkg.scope, None);
258        assert_eq!(pkg.name, "code-review");
259        assert_eq!(pkg.version, Some("1.0.0".to_string()));
260    }
261
262    #[test]
263    fn test_parse_scoped() {
264        let pkg = PackageRef::parse("@workflows/code-review").unwrap();
265        assert_eq!(pkg.scope, Some("workflows".to_string()));
266        assert_eq!(pkg.name, "code-review");
267        assert_eq!(pkg.version, None);
268    }
269
270    #[test]
271    fn test_parse_scoped_with_version() {
272        let pkg = PackageRef::parse("@workflows/code-review@1.2.3").unwrap();
273        assert_eq!(pkg.scope, Some("workflows".to_string()));
274        assert_eq!(pkg.name, "code-review");
275        assert_eq!(pkg.version, Some("1.2.3".to_string()));
276    }
277
278    #[test]
279    fn test_parse_empty() {
280        assert!(PackageRef::parse("").is_none());
281        assert!(PackageRef::parse("  ").is_none());
282    }
283
284    #[test]
285    fn test_full_name() {
286        let scoped = PackageRef::parse("@workflows/code-review").unwrap();
287        assert_eq!(scoped.full_name(), "@workflows/code-review");
288
289        let unscoped = PackageRef::parse("code-review").unwrap();
290        assert_eq!(unscoped.full_name(), "code-review");
291    }
292
293    #[test]
294    fn test_to_string_with_version() {
295        let pkg = PackageRef::parse("@workflows/code-review@1.0.0").unwrap();
296        assert_eq!(pkg.to_string_with_version(), "@workflows/code-review@1.0.0");
297
298        let pkg = PackageRef::parse("@workflows/code-review").unwrap();
299        assert_eq!(pkg.to_string_with_version(), "@workflows/code-review");
300    }
301
302    #[test]
303    fn test_package_manifest() {
304        let manifest = PackageManifest::new("my-workflow", "1.0.0");
305        assert_eq!(manifest.name, "my-workflow");
306        assert_eq!(manifest.version, "1.0.0");
307        assert_eq!(manifest.package_type, PackageType::Workflow);
308        assert!(manifest.source.is_none());
309    }
310
311    #[test]
312    fn test_package_type_variants() {
313        assert_eq!(PackageType::default(), PackageType::Workflow);
314
315        // Ensure all variants exist
316        let types = [
317            PackageType::Workflow,
318            PackageType::Skill,
319            PackageType::Agent,
320            PackageType::Prompt,
321            PackageType::Job,
322            PackageType::Schema,
323            PackageType::Mcp,
324            PackageType::Model,
325            PackageType::Data,
326        ];
327        assert_eq!(types.len(), 9);
328    }
329
330    #[test]
331    fn test_source_npm() {
332        let source = Source::Npm {
333            package: "@modelcontextprotocol/server-filesystem".to_string(),
334            version: Some("^1.0.0".to_string()),
335        };
336
337        if let Source::Npm { package, version } = source {
338            assert_eq!(package, "@modelcontextprotocol/server-filesystem");
339            assert_eq!(version, Some("^1.0.0".to_string()));
340        } else {
341            panic!("Expected Npm source");
342        }
343    }
344
345    #[test]
346    fn test_source_ollama() {
347        let source = Source::Ollama {
348            model: "deepseek-coder:6.7b".to_string(),
349        };
350
351        if let Source::Ollama { model } = source {
352            assert_eq!(model, "deepseek-coder:6.7b");
353        } else {
354            panic!("Expected Ollama source");
355        }
356    }
357
358    #[test]
359    fn test_source_huggingface() {
360        let source = Source::HuggingFace {
361            repo: "deepseek-ai/deepseek-coder-6.7b".to_string(),
362            quantization: Some("Q4_K_M".to_string()),
363        };
364
365        if let Source::HuggingFace { repo, quantization } = source {
366            assert_eq!(repo, "deepseek-ai/deepseek-coder-6.7b");
367            assert_eq!(quantization, Some("Q4_K_M".to_string()));
368        } else {
369            panic!("Expected HuggingFace source");
370        }
371    }
372
373    #[test]
374    fn test_source_tarball() {
375        let source = Source::Tarball {
376            url: "https://cdn.supernovae.studio/packages/workflow-1.0.0.tar.gz".to_string(),
377            checksum: "sha256:abc123".to_string(),
378        };
379
380        if let Source::Tarball { url, checksum } = source {
381            assert!(url.ends_with(".tar.gz"));
382            assert!(checksum.starts_with("sha256:"));
383        } else {
384            panic!("Expected Tarball source");
385        }
386    }
387
388    #[test]
389    fn test_source_binary() {
390        let mut platforms = HashMap::new();
391        platforms.insert(
392            "darwin-arm64".to_string(),
393            "https://github.com/org/repo/releases/download/v1.0.0/bin-darwin-arm64".to_string(),
394        );
395        platforms.insert(
396            "linux-x86_64".to_string(),
397            "https://github.com/org/repo/releases/download/v1.0.0/bin-linux-x86_64".to_string(),
398        );
399
400        let source = Source::Binary { platforms };
401
402        if let Source::Binary { platforms } = source {
403            assert_eq!(platforms.len(), 2);
404            assert!(platforms.contains_key("darwin-arm64"));
405            assert!(platforms.contains_key("linux-x86_64"));
406        } else {
407            panic!("Expected Binary source");
408        }
409    }
410
411    #[test]
412    fn test_manifest_with_source() {
413        let mut manifest = PackageManifest::new("@mcp/neo4j", "1.0.0");
414        manifest.package_type = PackageType::Mcp;
415        manifest.source = Some(Source::Npm {
416            package: "@neo4j/mcp-server-neo4j".to_string(),
417            version: Some("^1.0.0".to_string()),
418        });
419
420        assert_eq!(manifest.package_type, PackageType::Mcp);
421        assert!(manifest.source.is_some());
422
423        if let Some(Source::Npm { package, .. }) = &manifest.source {
424            assert_eq!(package, "@neo4j/mcp-server-neo4j");
425        }
426    }
427}