use std::path::{Path, PathBuf};
use tokio::fs;
use tracing::debug;
use crate::{
error::SkillError,
manifest::{SkillManifest, parse_skill_md},
};
#[derive(Debug, Clone)]
pub struct SkillEntry {
pub manifest: SkillManifest,
pub body: String,
pub skill_dir: PathBuf,
}
#[derive(Debug, Default)]
pub struct SkillLoader {}
impl SkillLoader {
pub const fn new() -> Self {
Self {}
}
pub async fn scan(&self, dir: &Path) -> Result<Vec<SkillEntry>, SkillError> {
let mut entries: Vec<SkillEntry> = Vec::new();
let mut read_dir = fs::read_dir(dir).await?;
while let Some(child) = read_dir.next_entry().await? {
let child_path = child.path();
if !child_path.is_dir() {
continue;
}
let skill_file = child_path.join("SKILL.md");
if !skill_file.exists() {
continue;
}
debug!(path = %skill_file.display(), "loading skill");
let content = fs::read_to_string(&skill_file).await?;
let manifest = parse_skill_md(&content)?;
let body = extract_body(&content);
let entry = SkillEntry {
manifest,
body: body.to_owned(),
skill_dir: child_path,
};
self.validate(&entry)?;
entries.push(entry);
}
Ok(entries)
}
pub fn validate(&self, entry: &SkillEntry) -> Result<(), SkillError> {
let dir_name = entry
.skill_dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
if dir_name != entry.manifest.name {
return Err(SkillError::InvalidManifest(format!(
"directory name '{}' does not match skill name '{}'",
dir_name, entry.manifest.name
)));
}
Ok(())
}
}
fn extract_body(content: &str) -> &str {
let Some(after_open) = content
.strip_prefix("---")
.and_then(|s| s.strip_prefix('\n').or_else(|| s.strip_prefix("\r\n")))
else {
return content;
};
after_open.find("\n---").map_or("", |pos| {
let remainder = &after_open[pos + 4..]; remainder
.strip_prefix('\n')
.or_else(|| remainder.strip_prefix("\r\n"))
.unwrap_or(remainder)
})
}