use std::collections::BTreeMap;
use async_trait::async_trait;
use rust_embed::RustEmbed;
use crate::error::{Result, SkillError};
use crate::skill::{Skill, SkillSummary};
use crate::source::SkillSource;
#[derive(RustEmbed)]
#[folder = "skills/"]
#[include = "*/*/SKILL.md"]
struct BaselineAssets;
#[derive(Default, Clone)]
pub struct EmbeddedSkillSource;
impl EmbeddedSkillSource {
pub fn new() -> Self {
Self
}
fn iter() -> impl Iterator<Item = (String, Vec<u8>)> {
BaselineAssets::iter().filter_map(|path| {
let path_str = path.as_ref().to_string();
let parts: Vec<&str> = path_str.split('/').collect();
if parts.len() != 3 || parts[2] != "SKILL.md" {
return None;
}
let skill_dir = parts[1].to_string();
BaselineAssets::get(&path_str).map(|f| (skill_dir, f.data.into_owned()))
})
}
fn load_skill(name: &str) -> Result<Skill> {
let contents = Self::iter()
.find(|(n, _)| n == name)
.map(|(_, bytes)| bytes)
.ok_or_else(|| SkillError::NotFound {
name: name.to_string(),
source_name: "embedded",
})?;
let text = String::from_utf8(contents).map_err(|e| SkillError::InvalidFieldType {
skill: name.to_string(),
field: "<body>",
reason: format!("SKILL.md is not valid UTF-8: {e}"),
})?;
Skill::parse(name, &text)
}
pub fn all() -> Result<BTreeMap<String, Skill>> {
let mut out = BTreeMap::new();
for (name, bytes) in Self::iter() {
let text = String::from_utf8(bytes).map_err(|e| SkillError::InvalidFieldType {
skill: name.clone(),
field: "<body>",
reason: format!("SKILL.md is not valid UTF-8: {e}"),
})?;
out.insert(name.clone(), Skill::parse(&name, &text)?);
}
Ok(out)
}
}
#[async_trait]
impl SkillSource for EmbeddedSkillSource {
fn name(&self) -> &'static str {
"embedded"
}
async fn list(&self) -> Result<Vec<SkillSummary>> {
let mut summaries = Vec::new();
for (name, bytes) in Self::iter() {
let text = String::from_utf8(bytes).map_err(|e| SkillError::InvalidFieldType {
skill: name.clone(),
field: "<body>",
reason: format!("SKILL.md is not valid UTF-8: {e}"),
})?;
let skill = Skill::parse(&name, &text)?;
summaries.push(SkillSummary::from(&skill));
}
summaries.sort_by(|a, b| (a.category, &a.name).cmp(&(b.category, &b.name)));
Ok(summaries)
}
async fn load(&self, name: &str) -> Result<Skill> {
Self::load_skill(name)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn list_returns_empty_or_valid_before_any_skills_ship() {
let source = EmbeddedSkillSource::new();
let summaries = source.list().await.expect("list should not fail");
for s in &summaries {
assert!(!s.name.is_empty());
assert!(!s.description.is_empty());
}
}
#[tokio::test]
async fn load_missing_skill_returns_not_found() {
let source = EmbeddedSkillSource::new();
let err = source.load("does-not-exist").await.unwrap_err();
assert!(
matches!(
err,
SkillError::NotFound {
source_name: "embedded",
..
}
),
"expected NotFound(embedded), got {err:?}"
);
}
#[test]
fn source_name_is_embedded() {
let source = EmbeddedSkillSource::new();
assert_eq!(source.name(), "embedded");
}
#[test]
fn all_returns_every_embedded_skill() {
let all = EmbeddedSkillSource::all().expect("embedded skills parse");
for (name, skill) in &all {
assert_eq!(name, skill.name(), "map key matches skill name");
assert!(!skill.frontmatter.description.is_empty());
assert!(skill.version() > 0);
}
let summaries = tokio::runtime::Runtime::new()
.unwrap()
.block_on(EmbeddedSkillSource::new().list())
.unwrap();
assert_eq!(summaries.len(), all.len());
}
#[tokio::test]
async fn load_returns_parsed_skill_for_known_name() {
let all = EmbeddedSkillSource::all().unwrap();
let Some((name, _)) = all.iter().next() else {
return;
};
let source = EmbeddedSkillSource::new();
let skill = source.load(name).await.expect("known skill loads");
assert_eq!(skill.name(), name);
}
#[tokio::test]
async fn list_is_sorted_by_category_then_name() {
let source = EmbeddedSkillSource::new();
let summaries = source.list().await.unwrap();
for pair in summaries.windows(2) {
let (a, b) = (&pair[0], &pair[1]);
let ord = (a.category, a.name.as_str()).cmp(&(b.category, b.name.as_str()));
assert!(
ord == std::cmp::Ordering::Less || ord == std::cmp::Ordering::Equal,
"summaries not sorted: {} then {}",
a.name,
b.name
);
}
}
}