1use hashbrown::HashSet;
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14pub enum SkillType {
15 #[serde(rename = "anthropic")]
17 Anthropic,
18 #[serde(rename = "custom")]
20 Custom,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
25#[serde(untagged)]
26pub enum SkillVersion {
27 #[serde(rename = "latest")]
29 #[default]
30 Latest,
31 Specific(String),
33}
34
35impl SkillVersion {
36 pub fn as_str(&self) -> &str {
37 match self {
38 SkillVersion::Latest => "latest",
39 SkillVersion::Specific(v) => v,
40 }
41 }
42
43 pub fn is_latest(&self) -> bool {
44 matches!(self, SkillVersion::Latest)
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(tag = "type", rename_all = "snake_case")]
51pub enum SkillSource {
52 #[serde(rename = "skill_reference")]
54 Reference {
55 skill_id: String,
56 #[serde(default)]
57 version: SkillVersion,
58 },
59 #[serde(rename = "inline")]
61 Inline {
62 bundle_b64: String,
64 #[serde(skip_serializing_if = "Option::is_none")]
66 sha256: Option<String>,
67 },
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct SkillSpec {
73 #[serde(rename = "type")]
75 pub skill_type: SkillType,
76 pub skill_id: String,
78 #[serde(default)]
80 pub version: SkillVersion,
81}
82
83impl SkillSpec {
84 pub fn new(skill_type: SkillType, skill_id: impl Into<String>) -> Self {
86 Self {
87 skill_type,
88 skill_id: skill_id.into(),
89 version: SkillVersion::Latest,
90 }
91 }
92
93 pub fn with_version(mut self, version: SkillVersion) -> Self {
95 self.version = version;
96 self
97 }
98
99 pub fn anthropic(skill_id: impl Into<String>) -> Self {
101 Self::new(SkillType::Anthropic, skill_id)
102 }
103
104 pub fn custom(skill_id: impl Into<String>) -> Self {
106 Self::new(SkillType::Custom, skill_id)
107 }
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct SkillContainer {
118 #[serde(skip_serializing_if = "Option::is_none")]
120 pub id: Option<String>,
121 pub skills: Vec<SkillSpec>,
123 #[serde(default, skip_serializing_if = "Vec::is_empty")]
125 pub inline_bundles: Vec<SkillSource>,
126}
127
128impl SkillContainer {
129 pub fn new() -> Self {
131 Self {
132 id: None,
133 skills: Vec::with_capacity(8),
134 inline_bundles: Vec::new(),
135 }
136 }
137
138 pub fn single(spec: SkillSpec) -> Self {
140 Self {
141 id: None,
142 skills: vec![spec],
143 inline_bundles: Vec::new(),
144 }
145 }
146
147 pub fn with_id(id: impl Into<String>) -> Self {
149 Self {
150 id: Some(id.into()),
151 skills: Vec::with_capacity(8),
152 inline_bundles: Vec::new(),
153 }
154 }
155
156 pub fn add_skill(&mut self, spec: SkillSpec) -> anyhow::Result<()> {
161 if self.skills.len() >= 8 {
162 anyhow::bail!(
163 "Container already has maximum skills (8), cannot add '{}'",
164 spec.skill_id
165 );
166 }
167 self.skills.push(spec);
168 Ok(())
169 }
170
171 pub fn add_skills(&mut self, mut specs: Vec<SkillSpec>) -> anyhow::Result<()> {
176 let current_len = self.skills.len();
177 let new_len = current_len + specs.len();
178 if new_len > 8 {
179 anyhow::bail!(
180 "Adding {} skills would exceed maximum (8). Current: {}, requested: {}",
181 specs.len(),
182 current_len,
183 specs.len()
184 );
185 }
186 if new_len > self.skills.capacity() {
188 self.skills.reserve(new_len - current_len);
189 }
190 self.skills.append(&mut specs);
191 Ok(())
192 }
193
194 pub fn add_anthropic(&mut self, skill_id: impl Into<String>) -> anyhow::Result<()> {
196 self.add_skill(SkillSpec::anthropic(skill_id))
197 }
198
199 pub fn add_custom(&mut self, skill_id: impl Into<String>) -> anyhow::Result<()> {
201 self.add_skill(SkillSpec::custom(skill_id))
202 }
203
204 pub fn add_inline(&mut self, bundle_b64: String, sha256: Option<String>) -> anyhow::Result<()> {
211 if self.skills.len() >= 8 {
212 anyhow::bail!("Container already has maximum skills (8)");
213 }
214 let spec = SkillSpec {
215 skill_type: SkillType::Custom,
216 skill_id: sha256
217 .clone()
218 .unwrap_or_else(|| format!("inline-{}", self.skills.len())),
219 version: SkillVersion::Latest,
220 };
221 self.skills.push(spec);
222 self.inline_bundles
223 .push(SkillSource::Inline { bundle_b64, sha256 });
224 Ok(())
225 }
226
227 pub fn len(&self) -> usize {
229 self.skills.len()
230 }
231
232 pub fn is_empty(&self) -> bool {
234 self.skills.is_empty()
235 }
236
237 pub fn has_skill(&self, skill_id: &str) -> bool {
239 self.skills.iter().any(|s| s.skill_id == skill_id)
240 }
241
242 pub fn get_skill(&self, skill_id: &str) -> Option<&SkillSpec> {
244 self.skills.iter().find(|s| s.skill_id == skill_id)
245 }
246
247 pub fn validate(&self) -> anyhow::Result<()> {
253 if self.skills.len() > 8 {
254 anyhow::bail!("Container has {} skills, maximum is 8", self.skills.len());
255 }
256
257 let mut seen_ids = HashSet::new();
258 for spec in &self.skills {
259 if !seen_ids.insert(&spec.skill_id) {
260 anyhow::bail!("Duplicate skill ID in container: '{}'", spec.skill_id);
261 }
262 }
263
264 Ok(())
265 }
266
267 pub fn set_id(&mut self, id: impl Into<String>) {
269 self.id = Some(id.into());
270 }
271
272 pub fn clear_id(&mut self) {
274 self.id = None;
275 }
276
277 pub fn skill_ids(&self) -> Vec<&str> {
279 self.skills.iter().map(|s| s.skill_id.as_str()).collect()
280 }
281
282 pub fn skills_by_type(&self, skill_type: SkillType) -> Vec<&SkillSpec> {
284 self.skills
285 .iter()
286 .filter(|s| s.skill_type == skill_type)
287 .collect()
288 }
289
290 pub fn anthropic_count(&self) -> usize {
292 self.skills_by_type(SkillType::Anthropic).len()
293 }
294
295 pub fn custom_count(&self) -> usize {
297 self.skills_by_type(SkillType::Custom).len()
298 }
299}
300
301impl Default for SkillContainer {
302 fn default() -> Self {
303 Self::new()
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 #[test]
312 fn test_skill_spec_new() {
313 let spec = SkillSpec::new(SkillType::Custom, "my-skill");
314 assert_eq!(spec.skill_id, "my-skill");
315 assert_eq!(spec.skill_type, SkillType::Custom);
316 assert!(spec.version.is_latest());
317 }
318
319 #[test]
320 fn test_skill_spec_anthropic() {
321 let spec = SkillSpec::anthropic("xlsx");
322 assert_eq!(spec.skill_id, "xlsx");
323 assert_eq!(spec.skill_type, SkillType::Anthropic);
324 }
325
326 #[test]
327 fn test_skill_spec_with_version() {
328 let spec = SkillSpec::custom("my-skill")
329 .with_version(SkillVersion::Specific("1759178010641129".to_string()));
330 assert_eq!(spec.version.as_str(), "1759178010641129");
331 assert!(!spec.version.is_latest());
332 }
333
334 #[test]
335 fn test_container_creation() {
336 let container = SkillContainer::new();
337 assert!(container.is_empty());
338 assert!(container.id.is_none());
339 }
340
341 #[test]
342 fn test_container_single_skill() {
343 let spec = SkillSpec::custom("test-skill");
344 let container = SkillContainer::single(spec.clone());
345 assert_eq!(container.len(), 1);
346 assert!(container.has_skill("test-skill"));
347 assert_eq!(container.get_skill("test-skill"), Some(&spec));
348 }
349
350 #[test]
351 fn test_container_add_skill() {
352 let mut container = SkillContainer::new();
353 let spec = SkillSpec::custom("skill1");
354 container.add_skill(spec).unwrap();
355 assert_eq!(container.len(), 1);
356 }
357
358 #[test]
359 fn test_container_max_skills() {
360 let mut container = SkillContainer::new();
361 for i in 0..8 {
362 let spec = SkillSpec::custom(format!("skill{}", i));
363 container.add_skill(spec).unwrap();
364 }
365 assert_eq!(container.len(), 8);
366
367 let spec = SkillSpec::custom("skill9");
369 assert!(container.add_skill(spec).is_err());
370 }
371
372 #[test]
373 fn test_container_add_skills_batch() {
374 let mut container = SkillContainer::new();
375 let specs = vec![
376 SkillSpec::custom("skill1"),
377 SkillSpec::custom("skill2"),
378 SkillSpec::custom("skill3"),
379 ];
380 container.add_skills(specs).unwrap();
381 assert_eq!(container.len(), 3);
382 }
383
384 #[test]
385 fn test_container_add_skills_batch_overflow() {
386 let mut container = SkillContainer::new();
387 for i in 0..7 {
388 let spec = SkillSpec::custom(format!("skill{}", i));
389 container.add_skill(spec).ok();
390 }
391 assert_eq!(container.len(), 7);
392
393 let specs = vec![SkillSpec::custom("skill7"), SkillSpec::custom("skill8")];
394 assert!(container.add_skills(specs).is_err());
395 }
396
397 #[test]
398 fn test_container_duplicate_skill_ids() {
399 let mut container = SkillContainer::new();
400 container.add_skill(SkillSpec::custom("dup")).unwrap();
401 container.add_skill(SkillSpec::custom("dup")).unwrap();
402 assert!(container.validate().is_err());
403 }
404
405 #[test]
406 fn test_container_with_id() {
407 let container = SkillContainer::with_id("container-123");
408 assert_eq!(container.id, Some("container-123".to_string()));
409 }
410
411 #[test]
412 fn test_container_set_id() {
413 let mut container = SkillContainer::new();
414 container.set_id("new-id");
415 assert_eq!(container.id, Some("new-id".to_string()));
416 }
417
418 #[test]
419 fn test_container_skills_by_type() {
420 let mut container = SkillContainer::new();
421 container.add_anthropic("xlsx").ok();
422 container.add_anthropic("pptx").ok();
423 container.add_custom("my-skill").ok();
424
425 let anthropic = container.skills_by_type(SkillType::Anthropic);
426 assert_eq!(anthropic.len(), 2);
427
428 let custom = container.skills_by_type(SkillType::Custom);
429 assert_eq!(custom.len(), 1);
430
431 assert_eq!(container.anthropic_count(), 2);
432 assert_eq!(container.custom_count(), 1);
433 }
434
435 #[test]
436 fn test_container_skill_ids() {
437 let mut container = SkillContainer::new();
438 container.add_skill(SkillSpec::custom("skill1")).ok();
439 container.add_skill(SkillSpec::custom("skill2")).ok();
440 container.add_skill(SkillSpec::custom("skill3")).ok();
441
442 let ids = container.skill_ids();
443 assert_eq!(ids, vec!["skill1", "skill2", "skill3"]);
444 }
445
446 #[test]
447 fn test_container_validation() {
448 let mut container = SkillContainer::new();
449 for i in 0..8 {
450 container
451 .add_skill(SkillSpec::custom(format!("skill{}", i)))
452 .ok();
453 }
454 container.validate().unwrap();
455 }
456
457 #[test]
458 fn test_skill_spec_roundtrip() {
459 let spec = SkillSpec {
461 skill_type: SkillType::Custom,
462 skill_id: "my-skill".to_string(),
463 version: SkillVersion::Specific("1759178010641129".to_string()),
464 };
465
466 let json = serde_json::to_string(&spec).unwrap();
467 let deserialized: SkillSpec = serde_json::from_str(&json).unwrap();
468
469 assert_eq!(deserialized.skill_id, "my-skill");
470 assert_eq!(deserialized.skill_type, SkillType::Custom);
471 assert_eq!(
472 deserialized.version,
473 SkillVersion::Specific("1759178010641129".to_string())
474 );
475 }
476
477 #[test]
478 fn test_container_serialization() {
479 let mut container = SkillContainer::new();
480 container.add_anthropic("xlsx").ok();
481 container.add_custom("my-skill").ok();
482
483 let json = serde_json::to_string(&container).unwrap();
484 let deserialized: SkillContainer = serde_json::from_str(&json).unwrap();
485
486 assert_eq!(deserialized.len(), 2);
487 assert!(deserialized.has_skill("xlsx"));
488 assert!(deserialized.has_skill("my-skill"));
489 }
490
491 #[test]
492 fn test_skill_source_reference_roundtrip() {
493 let source = SkillSource::Reference {
494 skill_id: "my-skill".to_string(),
495 version: SkillVersion::Latest,
496 };
497 let json = serde_json::to_string(&source).unwrap();
498 let deserialized: SkillSource = serde_json::from_str(&json).unwrap();
499 assert_eq!(source, deserialized);
500 }
501
502 #[test]
503 fn test_skill_source_inline_roundtrip() {
504 let source = SkillSource::Inline {
505 bundle_b64: "UEsFBgAAAAAAAA==".to_string(),
506 sha256: Some("abc123".to_string()),
507 };
508 let json = serde_json::to_string(&source).unwrap();
509 assert!(json.contains("\"type\":\"inline\""));
510 let deserialized: SkillSource = serde_json::from_str(&json).unwrap();
511 assert_eq!(source, deserialized);
512 }
513
514 #[test]
515 fn test_skill_source_inline_no_sha() {
516 let source = SkillSource::Inline {
517 bundle_b64: "UEsFBgAAAAAAAA==".to_string(),
518 sha256: None,
519 };
520 let json = serde_json::to_string(&source).unwrap();
521 assert!(!json.contains("sha256"));
522 let deserialized: SkillSource = serde_json::from_str(&json).unwrap();
523 assert_eq!(source, deserialized);
524 }
525
526 #[test]
527 fn test_add_inline_with_sha() {
528 let mut container = SkillContainer::new();
529 container
530 .add_inline("UEsFBgAAAAAAAA==".to_string(), Some("deadbeef".to_string()))
531 .unwrap();
532
533 assert_eq!(container.len(), 1);
534 assert!(container.has_skill("deadbeef"));
535 assert_eq!(container.inline_bundles.len(), 1);
536 assert!(matches!(
537 &container.inline_bundles[0],
538 SkillSource::Inline { sha256: Some(h), .. } if h == "deadbeef"
539 ));
540 }
541
542 #[test]
543 fn test_add_inline_without_sha() {
544 let mut container = SkillContainer::new();
545 container
546 .add_inline("UEsFBgAAAAAAAA==".to_string(), None)
547 .unwrap();
548
549 assert_eq!(container.len(), 1);
550 assert!(container.has_skill("inline-0"));
551 assert_eq!(container.inline_bundles.len(), 1);
552 }
553
554 #[test]
555 fn test_add_inline_max_skills() {
556 let mut container = SkillContainer::new();
557 for i in 0..8 {
558 container
559 .add_skill(SkillSpec::custom(format!("skill{i}")))
560 .unwrap();
561 }
562 let result = container.add_inline("data".to_string(), None);
563 assert!(result.is_err());
564 }
565
566 #[test]
567 fn test_container_serialization_with_inline_bundles() {
568 let mut container = SkillContainer::new();
569 container.add_anthropic("xlsx").unwrap();
570 container
571 .add_inline("UEsFBgAAAAAAAA==".to_string(), Some("hash1".to_string()))
572 .unwrap();
573
574 let json = serde_json::to_string(&container).unwrap();
575 assert!(json.contains("inline_bundles"));
576
577 let deserialized: SkillContainer = serde_json::from_str(&json).unwrap();
578 assert_eq!(deserialized.len(), 2);
579 assert_eq!(deserialized.inline_bundles.len(), 1);
580 }
581
582 #[test]
583 fn test_container_serialization_omits_empty_inline_bundles() {
584 let mut container = SkillContainer::new();
585 container.add_anthropic("xlsx").unwrap();
586
587 let json = serde_json::to_string(&container).unwrap();
588 assert!(!json.contains("inline_bundles"));
589 }
590}