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
5/// Type of package in the registry.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
7pub enum PackageType {
8    /// Workflow package (nika workflows)
9    #[default]
10    Workflow,
11    /// Skill package (Claude Code skills)
12    Skill,
13    /// Agent package
14    Agent,
15    /// MCP server package
16    Mcp,
17    /// Data package
18    Data,
19}
20
21/// Reference to a package in the registry.
22///
23/// # Format
24///
25/// - `@scope/name` - Latest version
26/// - `@scope/name@1.2.3` - Specific version
27/// - `@scope/name@^1.0` - Version range
28///
29/// # Example
30///
31/// ```
32/// use spn_core::PackageRef;
33///
34/// let pkg = PackageRef::parse("@workflows/code-review@1.0.0").unwrap();
35/// assert_eq!(pkg.scope, Some("workflows".to_string()));
36/// assert_eq!(pkg.name, "code-review");
37/// assert_eq!(pkg.version, Some("1.0.0".to_string()));
38/// ```
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct PackageRef {
41    /// Package scope (e.g., "workflows" from "@workflows/name")
42    pub scope: Option<String>,
43    /// Package name
44    pub name: String,
45    /// Version constraint
46    pub version: Option<String>,
47}
48
49impl PackageRef {
50    /// Parse a package reference string.
51    ///
52    /// Supports formats:
53    /// - `name`
54    /// - `name@version`
55    /// - `@scope/name`
56    /// - `@scope/name@version`
57    pub fn parse(input: &str) -> Option<Self> {
58        let input = input.trim();
59        if input.is_empty() {
60            return None;
61        }
62
63        // Check if scoped (@scope/name@version)
64        if let Some(without_at) = input.strip_prefix('@') {
65            let (scope, rest) = without_at.split_once('/')?;
66
67            // Check for version
68            if let Some((name, version)) = rest.split_once('@') {
69                Some(PackageRef {
70                    scope: Some(scope.to_string()),
71                    name: name.to_string(),
72                    version: Some(version.to_string()),
73                })
74            } else {
75                Some(PackageRef {
76                    scope: Some(scope.to_string()),
77                    name: rest.to_string(),
78                    version: None,
79                })
80            }
81        } else {
82            // name or name@version
83            if let Some((name, version)) = input.split_once('@') {
84                Some(PackageRef {
85                    scope: None,
86                    name: name.to_string(),
87                    version: Some(version.to_string()),
88                })
89            } else {
90                Some(PackageRef {
91                    scope: None,
92                    name: input.to_string(),
93                    version: None,
94                })
95            }
96        }
97    }
98
99    /// Get the full package name (with scope if present).
100    pub fn full_name(&self) -> String {
101        match &self.scope {
102            Some(scope) => format!("@{}/{}", scope, self.name),
103            None => self.name.clone(),
104        }
105    }
106
107    /// Get the full package reference string.
108    pub fn to_string_with_version(&self) -> String {
109        match &self.version {
110            Some(v) => format!("{}@{}", self.full_name(), v),
111            None => self.full_name(),
112        }
113    }
114}
115
116/// Package manifest (spn.yaml content).
117#[derive(Debug, Clone, Default)]
118pub struct PackageManifest {
119    /// Package name
120    pub name: String,
121    /// Package version
122    pub version: String,
123    /// Package description
124    pub description: Option<String>,
125    /// Package type
126    pub package_type: PackageType,
127    /// Authors
128    pub authors: Vec<String>,
129    /// License
130    pub license: Option<String>,
131    /// Repository URL
132    pub repository: Option<String>,
133    /// Keywords for search
134    pub keywords: Vec<String>,
135    /// Dependencies
136    pub dependencies: Vec<PackageRef>,
137}
138
139impl PackageManifest {
140    /// Create a new package manifest.
141    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
142        Self {
143            name: name.into(),
144            version: version.into(),
145            ..Default::default()
146        }
147    }
148
149    /// Get the package reference for this manifest.
150    pub fn as_ref(&self) -> PackageRef {
151        PackageRef {
152            scope: None, // TODO: Extract from name if scoped
153            name: self.name.clone(),
154            version: Some(self.version.clone()),
155        }
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn test_parse_simple_name() {
165        let pkg = PackageRef::parse("code-review").unwrap();
166        assert_eq!(pkg.scope, None);
167        assert_eq!(pkg.name, "code-review");
168        assert_eq!(pkg.version, None);
169    }
170
171    #[test]
172    fn test_parse_name_with_version() {
173        let pkg = PackageRef::parse("code-review@1.0.0").unwrap();
174        assert_eq!(pkg.scope, None);
175        assert_eq!(pkg.name, "code-review");
176        assert_eq!(pkg.version, Some("1.0.0".to_string()));
177    }
178
179    #[test]
180    fn test_parse_scoped() {
181        let pkg = PackageRef::parse("@workflows/code-review").unwrap();
182        assert_eq!(pkg.scope, Some("workflows".to_string()));
183        assert_eq!(pkg.name, "code-review");
184        assert_eq!(pkg.version, None);
185    }
186
187    #[test]
188    fn test_parse_scoped_with_version() {
189        let pkg = PackageRef::parse("@workflows/code-review@1.2.3").unwrap();
190        assert_eq!(pkg.scope, Some("workflows".to_string()));
191        assert_eq!(pkg.name, "code-review");
192        assert_eq!(pkg.version, Some("1.2.3".to_string()));
193    }
194
195    #[test]
196    fn test_parse_empty() {
197        assert!(PackageRef::parse("").is_none());
198        assert!(PackageRef::parse("  ").is_none());
199    }
200
201    #[test]
202    fn test_full_name() {
203        let scoped = PackageRef::parse("@workflows/code-review").unwrap();
204        assert_eq!(scoped.full_name(), "@workflows/code-review");
205
206        let unscoped = PackageRef::parse("code-review").unwrap();
207        assert_eq!(unscoped.full_name(), "code-review");
208    }
209
210    #[test]
211    fn test_to_string_with_version() {
212        let pkg = PackageRef::parse("@workflows/code-review@1.0.0").unwrap();
213        assert_eq!(pkg.to_string_with_version(), "@workflows/code-review@1.0.0");
214
215        let pkg = PackageRef::parse("@workflows/code-review").unwrap();
216        assert_eq!(pkg.to_string_with_version(), "@workflows/code-review");
217    }
218
219    #[test]
220    fn test_package_manifest() {
221        let manifest = PackageManifest::new("my-workflow", "1.0.0");
222        assert_eq!(manifest.name, "my-workflow");
223        assert_eq!(manifest.version, "1.0.0");
224        assert_eq!(manifest.package_type, PackageType::Workflow);
225    }
226}