use hashbrown::HashSet;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SkillType {
#[serde(rename = "anthropic")]
Anthropic,
#[serde(rename = "custom")]
Custom,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(untagged)]
pub enum SkillVersion {
#[serde(rename = "latest")]
#[default]
Latest,
Specific(String),
}
impl SkillVersion {
pub fn as_str(&self) -> &str {
match self {
SkillVersion::Latest => "latest",
SkillVersion::Specific(v) => v,
}
}
pub fn is_latest(&self) -> bool {
matches!(self, SkillVersion::Latest)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SkillSource {
#[serde(rename = "skill_reference")]
Reference {
skill_id: String,
#[serde(default)]
version: SkillVersion,
},
#[serde(rename = "inline")]
Inline {
bundle_b64: String,
#[serde(skip_serializing_if = "Option::is_none")]
sha256: Option<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SkillSpec {
#[serde(rename = "type")]
pub skill_type: SkillType,
pub skill_id: String,
#[serde(default)]
pub version: SkillVersion,
}
impl SkillSpec {
pub fn new(skill_type: SkillType, skill_id: impl Into<String>) -> Self {
Self {
skill_type,
skill_id: skill_id.into(),
version: SkillVersion::Latest,
}
}
pub fn with_version(mut self, version: SkillVersion) -> Self {
self.version = version;
self
}
pub fn anthropic(skill_id: impl Into<String>) -> Self {
Self::new(SkillType::Anthropic, skill_id)
}
pub fn custom(skill_id: impl Into<String>) -> Self {
Self::new(SkillType::Custom, skill_id)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillContainer {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub skills: Vec<SkillSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub inline_bundles: Vec<SkillSource>,
}
impl SkillContainer {
pub fn new() -> Self {
Self {
id: None,
skills: Vec::with_capacity(8),
inline_bundles: Vec::new(),
}
}
pub fn single(spec: SkillSpec) -> Self {
Self {
id: None,
skills: vec![spec],
inline_bundles: Vec::new(),
}
}
pub fn with_id(id: impl Into<String>) -> Self {
Self {
id: Some(id.into()),
skills: Vec::with_capacity(8),
inline_bundles: Vec::new(),
}
}
pub fn add_skill(&mut self, spec: SkillSpec) -> anyhow::Result<()> {
if self.skills.len() >= 8 {
anyhow::bail!(
"Container already has maximum skills (8), cannot add '{}'",
spec.skill_id
);
}
self.skills.push(spec);
Ok(())
}
pub fn add_skills(&mut self, mut specs: Vec<SkillSpec>) -> anyhow::Result<()> {
let current_len = self.skills.len();
let new_len = current_len + specs.len();
if new_len > 8 {
anyhow::bail!(
"Adding {} skills would exceed maximum (8). Current: {}, requested: {}",
specs.len(),
current_len,
specs.len()
);
}
if new_len > self.skills.capacity() {
self.skills.reserve(new_len - current_len);
}
self.skills.append(&mut specs);
Ok(())
}
pub fn add_anthropic(&mut self, skill_id: impl Into<String>) -> anyhow::Result<()> {
self.add_skill(SkillSpec::anthropic(skill_id))
}
pub fn add_custom(&mut self, skill_id: impl Into<String>) -> anyhow::Result<()> {
self.add_skill(SkillSpec::custom(skill_id))
}
pub fn add_inline(&mut self, bundle_b64: String, sha256: Option<String>) -> anyhow::Result<()> {
if self.skills.len() >= 8 {
anyhow::bail!("Container already has maximum skills (8)");
}
let spec = SkillSpec {
skill_type: SkillType::Custom,
skill_id: sha256
.clone()
.unwrap_or_else(|| format!("inline-{}", self.skills.len())),
version: SkillVersion::Latest,
};
self.skills.push(spec);
self.inline_bundles
.push(SkillSource::Inline { bundle_b64, sha256 });
Ok(())
}
pub fn len(&self) -> usize {
self.skills.len()
}
pub fn is_empty(&self) -> bool {
self.skills.is_empty()
}
pub fn has_skill(&self, skill_id: &str) -> bool {
self.skills.iter().any(|s| s.skill_id == skill_id)
}
pub fn get_skill(&self, skill_id: &str) -> Option<&SkillSpec> {
self.skills.iter().find(|s| s.skill_id == skill_id)
}
pub fn validate(&self) -> anyhow::Result<()> {
if self.skills.len() > 8 {
anyhow::bail!("Container has {} skills, maximum is 8", self.skills.len());
}
let mut seen_ids = HashSet::new();
for spec in &self.skills {
if !seen_ids.insert(&spec.skill_id) {
anyhow::bail!("Duplicate skill ID in container: '{}'", spec.skill_id);
}
}
Ok(())
}
pub fn set_id(&mut self, id: impl Into<String>) {
self.id = Some(id.into());
}
pub fn clear_id(&mut self) {
self.id = None;
}
pub fn skill_ids(&self) -> Vec<&str> {
self.skills.iter().map(|s| s.skill_id.as_str()).collect()
}
pub fn skills_by_type(&self, skill_type: SkillType) -> Vec<&SkillSpec> {
self.skills
.iter()
.filter(|s| s.skill_type == skill_type)
.collect()
}
pub fn anthropic_count(&self) -> usize {
self.skills_by_type(SkillType::Anthropic).len()
}
pub fn custom_count(&self) -> usize {
self.skills_by_type(SkillType::Custom).len()
}
}
impl Default for SkillContainer {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_skill_spec_new() {
let spec = SkillSpec::new(SkillType::Custom, "my-skill");
assert_eq!(spec.skill_id, "my-skill");
assert_eq!(spec.skill_type, SkillType::Custom);
assert!(spec.version.is_latest());
}
#[test]
fn test_skill_spec_anthropic() {
let spec = SkillSpec::anthropic("xlsx");
assert_eq!(spec.skill_id, "xlsx");
assert_eq!(spec.skill_type, SkillType::Anthropic);
}
#[test]
fn test_skill_spec_with_version() {
let spec = SkillSpec::custom("my-skill")
.with_version(SkillVersion::Specific("1759178010641129".to_string()));
assert_eq!(spec.version.as_str(), "1759178010641129");
assert!(!spec.version.is_latest());
}
#[test]
fn test_container_creation() {
let container = SkillContainer::new();
assert!(container.is_empty());
assert!(container.id.is_none());
}
#[test]
fn test_container_single_skill() {
let spec = SkillSpec::custom("test-skill");
let container = SkillContainer::single(spec.clone());
assert_eq!(container.len(), 1);
assert!(container.has_skill("test-skill"));
assert_eq!(container.get_skill("test-skill"), Some(&spec));
}
#[test]
fn test_container_add_skill() {
let mut container = SkillContainer::new();
let spec = SkillSpec::custom("skill1");
assert!(container.add_skill(spec).is_ok());
assert_eq!(container.len(), 1);
}
#[test]
fn test_container_max_skills() {
let mut container = SkillContainer::new();
for i in 0..8 {
let spec = SkillSpec::custom(format!("skill{}", i));
assert!(container.add_skill(spec).is_ok());
}
assert_eq!(container.len(), 8);
let spec = SkillSpec::custom("skill9");
assert!(container.add_skill(spec).is_err());
}
#[test]
fn test_container_add_skills_batch() {
let mut container = SkillContainer::new();
let specs = vec![
SkillSpec::custom("skill1"),
SkillSpec::custom("skill2"),
SkillSpec::custom("skill3"),
];
assert!(container.add_skills(specs).is_ok());
assert_eq!(container.len(), 3);
}
#[test]
fn test_container_add_skills_batch_overflow() {
let mut container = SkillContainer::new();
for i in 0..7 {
let spec = SkillSpec::custom(format!("skill{}", i));
container.add_skill(spec).ok();
}
assert_eq!(container.len(), 7);
let specs = vec![SkillSpec::custom("skill7"), SkillSpec::custom("skill8")];
assert!(container.add_skills(specs).is_err());
}
#[test]
fn test_container_duplicate_skill_ids() {
let mut container = SkillContainer::new();
assert!(container.add_skill(SkillSpec::custom("dup")).is_ok());
assert!(container.add_skill(SkillSpec::custom("dup")).is_ok());
assert!(container.validate().is_err());
}
#[test]
fn test_container_with_id() {
let container = SkillContainer::with_id("container-123");
assert_eq!(container.id, Some("container-123".to_string()));
}
#[test]
fn test_container_set_id() {
let mut container = SkillContainer::new();
container.set_id("new-id");
assert_eq!(container.id, Some("new-id".to_string()));
}
#[test]
fn test_container_skills_by_type() {
let mut container = SkillContainer::new();
container.add_anthropic("xlsx").ok();
container.add_anthropic("pptx").ok();
container.add_custom("my-skill").ok();
let anthropic = container.skills_by_type(SkillType::Anthropic);
assert_eq!(anthropic.len(), 2);
let custom = container.skills_by_type(SkillType::Custom);
assert_eq!(custom.len(), 1);
assert_eq!(container.anthropic_count(), 2);
assert_eq!(container.custom_count(), 1);
}
#[test]
fn test_container_skill_ids() {
let mut container = SkillContainer::new();
container.add_skill(SkillSpec::custom("skill1")).ok();
container.add_skill(SkillSpec::custom("skill2")).ok();
container.add_skill(SkillSpec::custom("skill3")).ok();
let ids = container.skill_ids();
assert_eq!(ids, vec!["skill1", "skill2", "skill3"]);
}
#[test]
fn test_container_validation() {
let mut container = SkillContainer::new();
for i in 0..8 {
container
.add_skill(SkillSpec::custom(format!("skill{}", i)))
.ok();
}
assert!(container.validate().is_ok());
}
#[test]
fn test_skill_spec_roundtrip() {
let spec = SkillSpec {
skill_type: SkillType::Custom,
skill_id: "my-skill".to_string(),
version: SkillVersion::Specific("1759178010641129".to_string()),
};
let json = serde_json::to_string(&spec).unwrap();
let deserialized: SkillSpec = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.skill_id, "my-skill");
assert_eq!(deserialized.skill_type, SkillType::Custom);
assert_eq!(
deserialized.version,
SkillVersion::Specific("1759178010641129".to_string())
);
}
#[test]
fn test_container_serialization() {
let mut container = SkillContainer::new();
container.add_anthropic("xlsx").ok();
container.add_custom("my-skill").ok();
let json = serde_json::to_string(&container).unwrap();
let deserialized: SkillContainer = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.len(), 2);
assert!(deserialized.has_skill("xlsx"));
assert!(deserialized.has_skill("my-skill"));
}
#[test]
fn test_skill_source_reference_roundtrip() {
let source = SkillSource::Reference {
skill_id: "my-skill".to_string(),
version: SkillVersion::Latest,
};
let json = serde_json::to_string(&source).unwrap();
let deserialized: SkillSource = serde_json::from_str(&json).unwrap();
assert_eq!(source, deserialized);
}
#[test]
fn test_skill_source_inline_roundtrip() {
let source = SkillSource::Inline {
bundle_b64: "UEsFBgAAAAAAAA==".to_string(),
sha256: Some("abc123".to_string()),
};
let json = serde_json::to_string(&source).unwrap();
assert!(json.contains("\"type\":\"inline\""));
let deserialized: SkillSource = serde_json::from_str(&json).unwrap();
assert_eq!(source, deserialized);
}
#[test]
fn test_skill_source_inline_no_sha() {
let source = SkillSource::Inline {
bundle_b64: "UEsFBgAAAAAAAA==".to_string(),
sha256: None,
};
let json = serde_json::to_string(&source).unwrap();
assert!(!json.contains("sha256"));
let deserialized: SkillSource = serde_json::from_str(&json).unwrap();
assert_eq!(source, deserialized);
}
#[test]
fn test_add_inline_with_sha() {
let mut container = SkillContainer::new();
container
.add_inline("UEsFBgAAAAAAAA==".to_string(), Some("deadbeef".to_string()))
.unwrap();
assert_eq!(container.len(), 1);
assert!(container.has_skill("deadbeef"));
assert_eq!(container.inline_bundles.len(), 1);
assert!(matches!(
&container.inline_bundles[0],
SkillSource::Inline { sha256: Some(h), .. } if h == "deadbeef"
));
}
#[test]
fn test_add_inline_without_sha() {
let mut container = SkillContainer::new();
container
.add_inline("UEsFBgAAAAAAAA==".to_string(), None)
.unwrap();
assert_eq!(container.len(), 1);
assert!(container.has_skill("inline-0"));
assert_eq!(container.inline_bundles.len(), 1);
}
#[test]
fn test_add_inline_max_skills() {
let mut container = SkillContainer::new();
for i in 0..8 {
container
.add_skill(SkillSpec::custom(format!("skill{i}")))
.unwrap();
}
let result = container.add_inline("data".to_string(), None);
assert!(result.is_err());
}
#[test]
fn test_container_serialization_with_inline_bundles() {
let mut container = SkillContainer::new();
container.add_anthropic("xlsx").unwrap();
container
.add_inline("UEsFBgAAAAAAAA==".to_string(), Some("hash1".to_string()))
.unwrap();
let json = serde_json::to_string(&container).unwrap();
assert!(json.contains("inline_bundles"));
let deserialized: SkillContainer = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.len(), 2);
assert_eq!(deserialized.inline_bundles.len(), 1);
}
#[test]
fn test_container_serialization_omits_empty_inline_bundles() {
let mut container = SkillContainer::new();
container.add_anthropic("xlsx").unwrap();
let json = serde_json::to_string(&container).unwrap();
assert!(!json.contains("inline_bundles"));
}
}