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 fn as_str(&self) -> &str {
37 match self {
38 SkillVersion::Latest => "latest",
39 SkillVersion::Specific(v) => v,
40 }
41 }
42
43 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 skill_type: SkillType,
76 skill_id: String,
78 #[serde(default)]
80 version: SkillVersion,
81}
82
83impl SkillSpec {
84 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 fn with_version(mut self, version: SkillVersion) -> Self {
95 self.version = version;
96 self
97 }
98
99 fn anthropic(skill_id: impl Into<String>) -> Self {
101 Self::new(SkillType::Anthropic, skill_id)
102 }
103
104 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 id: Option<String>,
121 skills: Vec<SkillSpec>,
123 #[serde(default, skip_serializing_if = "Vec::is_empty")]
125 inline_bundles: Vec<SkillSource>,
126}
127
128impl SkillContainer {
129 fn new() -> Self {
131 Self {
132 id: None,
133 skills: Vec::with_capacity(8),
134 inline_bundles: Vec::new(),
135 }
136 }
137
138 fn single(spec: SkillSpec) -> Self {
140 Self {
141 id: None,
142 skills: vec![spec],
143 inline_bundles: Vec::new(),
144 }
145 }
146
147 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 fn add_skill(&mut self, spec: SkillSpec) -> anyhow::Result<()> {
161 if self.skills.len() >= 8 {
162 anyhow::bail!("Container already has maximum skills (8), cannot add '{}'", spec.skill_id);
163 }
164 self.skills.push(spec);
165 Ok(())
166 }
167
168 fn add_skills(&mut self, mut specs: Vec<SkillSpec>) -> anyhow::Result<()> {
173 let current_len = self.skills.len();
174 let new_len = current_len + specs.len();
175 if new_len > 8 {
176 anyhow::bail!(
177 "Adding {} skills would exceed maximum (8). Current: {}, requested: {}",
178 specs.len(),
179 current_len,
180 specs.len()
181 );
182 }
183 if new_len > self.skills.capacity() {
185 self.skills.reserve(new_len - current_len);
186 }
187 self.skills.append(&mut specs);
188 Ok(())
189 }
190
191 fn add_anthropic(&mut self, skill_id: impl Into<String>) -> anyhow::Result<()> {
193 self.add_skill(SkillSpec::anthropic(skill_id))
194 }
195
196 fn add_custom(&mut self, skill_id: impl Into<String>) -> anyhow::Result<()> {
198 self.add_skill(SkillSpec::custom(skill_id))
199 }
200
201 fn add_inline(&mut self, bundle_b64: String, sha256: Option<String>) -> anyhow::Result<()> {
208 if self.skills.len() >= 8 {
209 anyhow::bail!("Container already has maximum skills (8)");
210 }
211 let spec = SkillSpec {
212 skill_type: SkillType::Custom,
213 skill_id: sha256.clone().unwrap_or_else(|| format!("inline-{}", self.skills.len())),
214 version: SkillVersion::Latest,
215 };
216 self.skills.push(spec);
217 self.inline_bundles.push(SkillSource::Inline { bundle_b64, sha256 });
218 Ok(())
219 }
220
221 fn len(&self) -> usize {
223 self.skills.len()
224 }
225
226 fn is_empty(&self) -> bool {
228 self.skills.is_empty()
229 }
230
231 fn has_skill(&self, skill_id: &str) -> bool {
233 self.skills.iter().any(|s| s.skill_id == skill_id)
234 }
235
236 fn get_skill(&self, skill_id: &str) -> Option<&SkillSpec> {
238 self.skills.iter().find(|s| s.skill_id == skill_id)
239 }
240
241 fn validate(&self) -> anyhow::Result<()> {
247 if self.skills.len() > 8 {
248 anyhow::bail!("Container has {} skills, maximum is 8", self.skills.len());
249 }
250
251 let mut seen_ids = HashSet::new();
252 for spec in &self.skills {
253 if !seen_ids.insert(&spec.skill_id) {
254 anyhow::bail!("Duplicate skill ID in container: '{}'", spec.skill_id);
255 }
256 }
257
258 Ok(())
259 }
260
261 fn set_id(&mut self, id: impl Into<String>) {
263 self.id = Some(id.into());
264 }
265
266 pub fn clear_id(&mut self) {
268 self.id = None;
269 }
270
271 fn skill_ids(&self) -> Vec<&str> {
273 self.skills.iter().map(|s| s.skill_id.as_str()).collect()
274 }
275
276 fn skills_by_type(&self, skill_type: SkillType) -> Vec<&SkillSpec> {
278 self.skills.iter().filter(|s| s.skill_type == skill_type).collect()
279 }
280
281 fn anthropic_count(&self) -> usize {
283 self.skills_by_type(SkillType::Anthropic).len()
284 }
285
286 fn custom_count(&self) -> usize {
288 self.skills_by_type(SkillType::Custom).len()
289 }
290}
291
292impl Default for SkillContainer {
293 fn default() -> Self {
294 Self::new()
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[test]
303 fn test_skill_spec_new() {
304 let spec = SkillSpec::new(SkillType::Custom, "my-skill");
305 assert_eq!(spec.skill_id, "my-skill");
306 assert_eq!(spec.skill_type, SkillType::Custom);
307 assert!(spec.version.is_latest());
308 }
309
310 #[test]
311 fn test_skill_spec_anthropic() {
312 let spec = SkillSpec::anthropic("xlsx");
313 assert_eq!(spec.skill_id, "xlsx");
314 assert_eq!(spec.skill_type, SkillType::Anthropic);
315 }
316
317 #[test]
318 fn test_skill_spec_with_version() {
319 let spec = SkillSpec::custom("my-skill").with_version(SkillVersion::Specific("1759178010641129".to_string()));
320 assert_eq!(spec.version.as_str(), "1759178010641129");
321 assert!(!spec.version.is_latest());
322 }
323
324 #[test]
325 fn test_container_creation() {
326 let container = SkillContainer::new();
327 assert!(container.is_empty());
328 assert!(container.id.is_none());
329 }
330
331 #[test]
332 fn test_container_single_skill() {
333 let spec = SkillSpec::custom("test-skill");
334 let container = SkillContainer::single(spec.clone());
335 assert_eq!(container.len(), 1);
336 assert!(container.has_skill("test-skill"));
337 assert_eq!(container.get_skill("test-skill"), Some(&spec));
338 }
339
340 #[test]
341 fn test_container_add_skill() {
342 let mut container = SkillContainer::new();
343 let spec = SkillSpec::custom("skill1");
344 container.add_skill(spec).unwrap();
345 assert_eq!(container.len(), 1);
346 }
347
348 #[test]
349 fn test_container_max_skills() {
350 let mut container = SkillContainer::new();
351 for i in 0..8 {
352 let spec = SkillSpec::custom(format!("skill{i}"));
353 container.add_skill(spec).unwrap();
354 }
355 assert_eq!(container.len(), 8);
356
357 let spec = SkillSpec::custom("skill9");
359 assert!(container.add_skill(spec).is_err());
360 }
361
362 #[test]
363 fn test_container_add_skills_batch() {
364 let mut container = SkillContainer::new();
365 let specs = vec![
366 SkillSpec::custom("skill1"),
367 SkillSpec::custom("skill2"),
368 SkillSpec::custom("skill3"),
369 ];
370 container.add_skills(specs).unwrap();
371 assert_eq!(container.len(), 3);
372 }
373
374 #[test]
375 fn test_container_add_skills_batch_overflow() {
376 let mut container = SkillContainer::new();
377 for i in 0..7 {
378 let spec = SkillSpec::custom(format!("skill{i}"));
379 container.add_skill(spec).ok();
380 }
381 assert_eq!(container.len(), 7);
382
383 let specs = vec![SkillSpec::custom("skill7"), SkillSpec::custom("skill8")];
384 assert!(container.add_skills(specs).is_err());
385 }
386
387 #[test]
388 fn test_container_duplicate_skill_ids() {
389 let mut container = SkillContainer::new();
390 container.add_skill(SkillSpec::custom("dup")).unwrap();
391 container.add_skill(SkillSpec::custom("dup")).unwrap();
392 assert!(container.validate().is_err());
393 }
394
395 #[test]
396 fn test_container_with_id() {
397 let container = SkillContainer::with_id("container-123");
398 assert_eq!(container.id, Some("container-123".to_string()));
399 }
400
401 #[test]
402 fn test_container_set_id() {
403 let mut container = SkillContainer::new();
404 container.set_id("new-id");
405 assert_eq!(container.id, Some("new-id".to_string()));
406 }
407
408 #[test]
409 fn test_container_skills_by_type() {
410 let mut container = SkillContainer::new();
411 container.add_anthropic("xlsx").ok();
412 container.add_anthropic("pptx").ok();
413 container.add_custom("my-skill").ok();
414
415 let anthropic = container.skills_by_type(SkillType::Anthropic);
416 assert_eq!(anthropic.len(), 2);
417
418 let custom = container.skills_by_type(SkillType::Custom);
419 assert_eq!(custom.len(), 1);
420
421 assert_eq!(container.anthropic_count(), 2);
422 assert_eq!(container.custom_count(), 1);
423 }
424
425 #[test]
426 fn test_container_skill_ids() {
427 let mut container = SkillContainer::new();
428 container.add_skill(SkillSpec::custom("skill1")).ok();
429 container.add_skill(SkillSpec::custom("skill2")).ok();
430 container.add_skill(SkillSpec::custom("skill3")).ok();
431
432 let ids = container.skill_ids();
433 assert_eq!(ids, vec!["skill1", "skill2", "skill3"]);
434 }
435
436 #[test]
437 fn test_container_validation() {
438 let mut container = SkillContainer::new();
439 for i in 0..8 {
440 container.add_skill(SkillSpec::custom(format!("skill{i}"))).ok();
441 }
442 container.validate().unwrap();
443 }
444
445 #[test]
446 fn test_skill_spec_roundtrip() {
447 let spec = SkillSpec {
449 skill_type: SkillType::Custom,
450 skill_id: "my-skill".to_string(),
451 version: SkillVersion::Specific("1759178010641129".to_string()),
452 };
453
454 let json = serde_json::to_string(&spec).unwrap();
455 let deserialized: SkillSpec = serde_json::from_str(&json).unwrap();
456
457 assert_eq!(deserialized.skill_id, "my-skill");
458 assert_eq!(deserialized.skill_type, SkillType::Custom);
459 assert_eq!(deserialized.version, SkillVersion::Specific("1759178010641129".to_string()));
460 }
461
462 #[test]
463 fn test_container_serialization() {
464 let mut container = SkillContainer::new();
465 container.add_anthropic("xlsx").ok();
466 container.add_custom("my-skill").ok();
467
468 let json = serde_json::to_string(&container).unwrap();
469 let deserialized: SkillContainer = serde_json::from_str(&json).unwrap();
470
471 assert_eq!(deserialized.len(), 2);
472 assert!(deserialized.has_skill("xlsx"));
473 assert!(deserialized.has_skill("my-skill"));
474 }
475
476 #[test]
477 fn test_skill_source_reference_roundtrip() {
478 let source = SkillSource::Reference {
479 skill_id: "my-skill".to_string(),
480 version: SkillVersion::Latest,
481 };
482 let json = serde_json::to_string(&source).unwrap();
483 let deserialized: SkillSource = serde_json::from_str(&json).unwrap();
484 assert_eq!(source, deserialized);
485 }
486
487 #[test]
488 fn test_skill_source_inline_roundtrip() {
489 let source = SkillSource::Inline {
490 bundle_b64: "UEsFBgAAAAAAAA==".to_string(),
491 sha256: Some("abc123".to_string()),
492 };
493 let json = serde_json::to_string(&source).unwrap();
494 assert!(json.contains("\"type\":\"inline\""));
495 let deserialized: SkillSource = serde_json::from_str(&json).unwrap();
496 assert_eq!(source, deserialized);
497 }
498
499 #[test]
500 fn test_skill_source_inline_no_sha() {
501 let source = SkillSource::Inline {
502 bundle_b64: "UEsFBgAAAAAAAA==".to_string(),
503 sha256: None,
504 };
505 let json = serde_json::to_string(&source).unwrap();
506 assert!(!json.contains("sha256"));
507 let deserialized: SkillSource = serde_json::from_str(&json).unwrap();
508 assert_eq!(source, deserialized);
509 }
510
511 #[test]
512 fn test_add_inline_with_sha() {
513 let mut container = SkillContainer::new();
514 container
515 .add_inline("UEsFBgAAAAAAAA==".to_string(), Some("deadbeef".to_string()))
516 .unwrap();
517
518 assert_eq!(container.len(), 1);
519 assert!(container.has_skill("deadbeef"));
520 assert_eq!(container.inline_bundles.len(), 1);
521 assert!(matches!(
522 &container.inline_bundles[0],
523 SkillSource::Inline { sha256: Some(h), .. } if h == "deadbeef"
524 ));
525 }
526
527 #[test]
528 fn test_add_inline_without_sha() {
529 let mut container = SkillContainer::new();
530 container.add_inline("UEsFBgAAAAAAAA==".to_string(), None).unwrap();
531
532 assert_eq!(container.len(), 1);
533 assert!(container.has_skill("inline-0"));
534 assert_eq!(container.inline_bundles.len(), 1);
535 }
536
537 #[test]
538 fn test_add_inline_max_skills() {
539 let mut container = SkillContainer::new();
540 for i in 0..8 {
541 container.add_skill(SkillSpec::custom(format!("skill{i}"))).unwrap();
542 }
543 let result = container.add_inline("data".to_string(), None);
544 assert!(result.is_err());
545 }
546
547 #[test]
548 fn test_container_serialization_with_inline_bundles() {
549 let mut container = SkillContainer::new();
550 container.add_anthropic("xlsx").unwrap();
551 container
552 .add_inline("UEsFBgAAAAAAAA==".to_string(), Some("hash1".to_string()))
553 .unwrap();
554
555 let json = serde_json::to_string(&container).unwrap();
556 assert!(json.contains("inline_bundles"));
557
558 let deserialized: SkillContainer = serde_json::from_str(&json).unwrap();
559 assert_eq!(deserialized.len(), 2);
560 assert_eq!(deserialized.inline_bundles.len(), 1);
561 }
562
563 #[test]
564 fn test_container_serialization_omits_empty_inline_bundles() {
565 let mut container = SkillContainer::new();
566 container.add_anthropic("xlsx").unwrap();
567
568 let json = serde_json::to_string(&container).unwrap();
569 assert!(!json.contains("inline_bundles"));
570 }
571}