1use std::collections::HashMap;
6
7#[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 #[default]
14 Workflow,
15 Skill,
17 Agent,
19 Prompt,
21 Job,
23 Schema,
25 Mcp,
27 Model,
29 Data,
31}
32
33#[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 {
42 url: String,
44 checksum: String,
46 },
47 Npm {
49 package: String,
51 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
53 version: Option<String>,
54 },
55 PyPi {
57 package: String,
59 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
61 version: Option<String>,
62 },
63 Binary {
65 platforms: HashMap<String, String>,
67 },
68 Ollama {
70 model: String,
72 },
73 HuggingFace {
75 repo: String,
77 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
79 quantization: Option<String>,
80 },
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
102#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
103pub struct PackageRef {
104 pub scope: Option<String>,
106 pub name: String,
108 pub version: Option<String>,
110}
111
112impl PackageRef {
113 pub fn parse(input: &str) -> Option<Self> {
121 let input = input.trim();
122 if input.is_empty() {
123 return None;
124 }
125
126 if let Some(without_at) = input.strip_prefix('@') {
128 let (scope, rest) = without_at.split_once('/')?;
129
130 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 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 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 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#[derive(Debug, Clone, Default)]
181#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
182pub struct PackageManifest {
183 pub name: String,
185 pub version: String,
187 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
189 pub description: Option<String>,
190 #[cfg_attr(feature = "serde", serde(rename = "type"))]
192 pub package_type: PackageType,
193 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
195 pub source: Option<Source>,
196 #[cfg_attr(
198 feature = "serde",
199 serde(skip_serializing_if = "Vec::is_empty", default)
200 )]
201 pub authors: Vec<String>,
202 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
204 pub license: Option<String>,
205 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
207 pub repository: Option<String>,
208 #[cfg_attr(
210 feature = "serde",
211 serde(skip_serializing_if = "Vec::is_empty", default)
212 )]
213 pub keywords: Vec<String>,
214 #[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 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 pub fn as_ref(&self) -> PackageRef {
234 PackageRef {
235 scope: None, 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 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}