use std::borrow::Cow;
use std::collections::BTreeMap;
use async_trait::async_trait;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SkillId(pub String);
impl SkillId {
pub fn collection(&self) -> Option<&str> {
self.0.rfind('/').map(|pos| &self.0[..pos])
}
pub fn skill_name(&self) -> &str {
match self.0.rfind('/') {
Some(pos) => &self.0[pos + 1..],
None => &self.0,
}
}
}
impl std::fmt::Display for SkillId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl From<String> for SkillId {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for SkillId {
fn from(s: &str) -> Self {
Self(s.to_string())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(strum::EnumString, strum::Display)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum SkillScope {
#[default]
Builtin,
Project,
User,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SkillDescriptor {
pub id: SkillId,
pub name: String,
pub description: String,
pub scope: SkillScope,
pub requires_capabilities: Vec<String>,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub metadata: IndexMap<String, String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub source_name: String,
}
#[derive(Debug, Clone)]
pub struct SkillDocument {
pub descriptor: SkillDescriptor,
pub body: String,
pub extensions: IndexMap<String, String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SkillFilter {
#[serde(skip_serializing_if = "Option::is_none")]
pub collection: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub query: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillCollection {
pub path: String,
pub description: String,
pub count: usize,
}
#[derive(Debug, Clone)]
pub struct ResolvedSkill {
pub id: SkillId,
pub name: String,
pub rendered_body: String,
pub byte_size: usize,
}
pub fn collection_matches_prefix(skill_collection: Option<&str>, prefix: &str) -> bool {
match skill_collection {
None => false,
Some(coll) => {
coll == prefix
|| (coll.starts_with(prefix) && coll.as_bytes().get(prefix.len()) == Some(&b'/'))
}
}
}
pub fn derive_collections(skills: &[SkillDescriptor]) -> Vec<SkillCollection> {
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for skill in skills {
if let Some(coll) = skill.id.collection() {
let top = match coll.find('/') {
Some(pos) => &coll[..pos],
None => coll,
};
*counts.entry(top.to_string()).or_default() += 1;
}
}
counts
.into_iter()
.map(|(path, count)| SkillCollection {
description: if count == 1 {
"1 skill".to_string()
} else {
format!("{count} skills")
},
path,
count,
})
.collect()
}
pub fn apply_filter(skills: &[SkillDescriptor], filter: &SkillFilter) -> Vec<SkillDescriptor> {
let query_lower = filter.query.as_ref().map(|q| q.to_lowercase());
skills
.iter()
.filter(|s| {
if let Some(ref prefix) = filter.collection {
if !collection_matches_prefix(s.id.collection(), prefix) {
return false;
}
}
if let Some(ref q) = query_lower {
if !s.name.to_lowercase().contains(q) && !s.description.to_lowercase().contains(q) {
return false;
}
}
true
})
.cloned()
.collect()
}
#[derive(Debug, thiserror::Error)]
pub enum SkillError {
#[error("skill not found: {id}")]
NotFound { id: SkillId },
#[error("skill requires unavailable capability: {capability}")]
CapabilityUnavailable { id: SkillId, capability: String },
#[error("ambiguous skill reference '{reference}' matches: {matches:?}")]
Ambiguous {
reference: String,
matches: Vec<SkillId>,
},
#[error("skill loading failed: {0}")]
Load(Cow<'static, str>),
#[error("skill parse failed: {0}")]
Parse(Cow<'static, str>),
}
#[async_trait]
pub trait SkillSource: Send + Sync {
async fn list(&self, filter: &SkillFilter) -> Result<Vec<SkillDescriptor>, SkillError>;
async fn load(&self, id: &SkillId) -> Result<SkillDocument, SkillError>;
async fn collections(&self) -> Result<Vec<SkillCollection>, SkillError> {
let all = self.list(&SkillFilter::default()).await?;
Ok(derive_collections(&all))
}
}
#[async_trait]
pub trait SkillEngine: Send + Sync {
async fn inventory_section(&self) -> Result<String, SkillError>;
async fn resolve_and_render(&self, ids: &[SkillId]) -> Result<Vec<ResolvedSkill>, SkillError>;
async fn collections(&self) -> Result<Vec<SkillCollection>, SkillError>;
async fn list_skills(&self, filter: &SkillFilter) -> Result<Vec<SkillDescriptor>, SkillError>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_skill_id_collection_extraction() {
let id = SkillId("extraction/email".into());
assert_eq!(id.collection(), Some("extraction"));
}
#[test]
fn test_skill_id_nested_collection() {
let id = SkillId("a/b/c".into());
assert_eq!(id.collection(), Some("a/b"));
}
#[test]
fn test_skill_id_root_level() {
let id = SkillId("pdf".into());
assert_eq!(id.collection(), None);
}
#[test]
fn test_skill_id_name_extraction() {
let id = SkillId("extraction/email".into());
assert_eq!(id.skill_name(), "email");
let root = SkillId("pdf-processing".into());
assert_eq!(root.skill_name(), "pdf-processing");
let nested = SkillId("a/b/c".into());
assert_eq!(nested.skill_name(), "c");
}
#[test]
fn test_skill_filter_default_is_empty() {
let filter = SkillFilter::default();
assert!(filter.collection.is_none());
assert!(filter.query.is_none());
}
#[test]
fn test_derive_collections_basic() {
let skills = vec![
SkillDescriptor {
id: SkillId("extraction/email".into()),
..Default::default()
},
SkillDescriptor {
id: SkillId("extraction/fiction".into()),
..Default::default()
},
SkillDescriptor {
id: SkillId("formatting/markdown".into()),
..Default::default()
},
];
let collections = derive_collections(&skills);
assert_eq!(collections.len(), 2);
let extraction = collections.iter().find(|c| c.path == "extraction");
assert!(extraction.is_some());
assert_eq!(extraction.map(|c| c.count), Some(2));
let formatting = collections.iter().find(|c| c.path == "formatting");
assert!(formatting.is_some());
assert_eq!(formatting.map(|c| c.count), Some(1));
}
#[test]
fn test_derive_collections_nested() {
let skills = vec![
SkillDescriptor {
id: SkillId("extraction/email".into()),
..Default::default()
},
SkillDescriptor {
id: SkillId("extraction/medical/diagnosis".into()),
..Default::default()
},
SkillDescriptor {
id: SkillId("extraction/medical/imaging/ct".into()),
..Default::default()
},
];
let collections = derive_collections(&skills);
assert_eq!(collections.len(), 1);
assert_eq!(collections[0].path, "extraction");
assert_eq!(collections[0].count, 3);
}
#[test]
fn test_derive_collections_empty() {
let collections = derive_collections(&[]);
assert!(collections.is_empty());
let skills = vec![SkillDescriptor {
id: SkillId("pdf-processing".into()),
..Default::default()
}];
let collections = derive_collections(&skills);
assert!(collections.is_empty());
}
#[test]
fn test_collection_prefix_match_segment() {
assert!(collection_matches_prefix(Some("extraction"), "extraction"));
assert!(collection_matches_prefix(
Some("extraction/medical"),
"extraction"
));
assert!(!collection_matches_prefix(Some("extract"), "extraction"));
assert!(!collection_matches_prefix(
Some("extractions"),
"extraction"
));
assert!(!collection_matches_prefix(None, "extraction"));
}
#[test]
fn test_apply_filter_collection() {
let skills = vec![
SkillDescriptor {
id: SkillId("extraction/email".into()),
name: "email".into(),
..Default::default()
},
SkillDescriptor {
id: SkillId("formatting/md".into()),
name: "md".into(),
..Default::default()
},
];
let filtered = apply_filter(
&skills,
&SkillFilter {
collection: Some("extraction".into()),
..Default::default()
},
);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].id.0, "extraction/email");
}
#[test]
fn test_apply_filter_query() {
let skills = vec![
SkillDescriptor {
id: SkillId("a/email".into()),
name: "email".into(),
description: "Extract from emails".into(),
..Default::default()
},
SkillDescriptor {
id: SkillId("b/fiction".into()),
name: "fiction".into(),
description: "Extract from fiction".into(),
..Default::default()
},
];
let filtered = apply_filter(
&skills,
&SkillFilter {
query: Some("email".into()),
..Default::default()
},
);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].name, "email");
}
}