use std::path::Path;
use serde::Deserialize;
use super::catalog::SkillCatalogEntry;
#[derive(Debug, Deserialize)]
struct SkillFrontmatter {
#[serde(default)]
name: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
required_apis: Vec<String>,
}
pub(crate) async fn scan_skills_dir(dir: &Path) -> Result<Vec<SkillCatalogEntry>, String> {
if !dir.exists() {
tokio::fs::create_dir_all(dir)
.await
.map_err(|e| format!("failed to create skills directory {}: {e}", dir.display()))?;
return Ok(Vec::new());
}
let mut entries = Vec::new();
let mut read_dir = tokio::fs::read_dir(dir)
.await
.map_err(|e| format!("failed to read skills directory {}: {e}", dir.display()))?;
while let Some(dir_entry) = read_dir
.next_entry()
.await
.map_err(|e| format!("failed to read directory entry: {e}"))?
{
let path = dir_entry.path();
if !path.is_dir() {
continue;
}
let skill_md = path.join("SKILL.md");
if !skill_md.exists() {
continue;
}
match parse_skill_md(&skill_md, &path).await {
Ok(entry) => entries.push(entry),
Err(reason) => {
tracing::warn!(
path = %skill_md.display(),
"skipping malformed skill: {reason}"
);
}
}
}
Ok(entries)
}
pub(crate) async fn parse_skill_md(
skill_md: &Path,
skill_dir: &Path,
) -> Result<SkillCatalogEntry, String> {
let content = tokio::fs::read_to_string(skill_md)
.await
.map_err(|e| format!("read error: {e}"))?;
let (frontmatter, _body) = split_frontmatter(&content);
let fm: SkillFrontmatter = match frontmatter {
Some(yaml) => serde_saphyr::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?,
None => {
return Err("missing YAML frontmatter".to_string());
}
};
let name = fm.name.unwrap_or_else(|| {
skill_dir
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
});
let description = fm
.description
.filter(|d| !d.is_empty())
.ok_or_else(|| "missing or empty description".to_string())?;
let metadata = tokio::fs::metadata(skill_md)
.await
.map_err(|e| format!("metadata error: {e}"))?;
let updated_at = metadata
.modified()
.ok()
.map(|t| {
let dt: chrono::DateTime<chrono::Utc> = t.into();
dt.to_rfc3339()
})
.unwrap_or_default();
let created_at = metadata
.created()
.ok()
.map(|t| {
let dt: chrono::DateTime<chrono::Utc> = t.into();
dt.to_rfc3339()
})
.unwrap_or_else(|| updated_at.clone());
let id = uuid::Uuid::new_v4().to_string();
Ok(SkillCatalogEntry {
id,
name,
description,
source: "filesystem".to_string(),
required_apis: fm.required_apis,
path: skill_md.to_path_buf(),
created_at,
updated_at,
bound_tool: None,
})
}
fn split_frontmatter(content: &str) -> (Option<&str>, &str) {
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return (None, content);
}
let after_open = match trimmed[3..].find('\n') {
Some(pos) => &trimmed[3 + pos + 1..],
None => return (None, content),
};
match after_open.find("\n---") {
Some(pos) => {
let yaml = &after_open[..pos];
let body_start = pos + 4; let body = if body_start < after_open.len() {
let rest = &after_open[body_start..];
rest.strip_prefix('\n').unwrap_or(rest)
} else {
""
};
(Some(yaml), body)
}
None => (None, content),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn split_frontmatter_valid() {
let content = "---\nname: test\ndescription: hello\n---\n# Body\nsome content";
let (fm, body) = split_frontmatter(content);
assert_eq!(fm, Some("name: test\ndescription: hello"));
assert_eq!(body, "# Body\nsome content");
}
#[test]
fn split_frontmatter_missing() {
let content = "# No frontmatter\njust body";
let (fm, body) = split_frontmatter(content);
assert!(fm.is_none());
assert_eq!(body, content);
}
#[test]
fn split_frontmatter_unclosed() {
let content = "---\nname: test\nno closing delimiter";
let (fm, _body) = split_frontmatter(content);
assert!(fm.is_none());
}
#[tokio::test]
async fn scan_empty_dir() {
let tmp = TempDir::new().expect("tempdir");
let entries = scan_skills_dir(tmp.path()).await.expect("scan");
assert!(entries.is_empty());
}
#[tokio::test]
async fn scan_creates_missing_dir() {
let tmp = TempDir::new().expect("tempdir");
let skills_dir = tmp.path().join("skills");
let entries = scan_skills_dir(&skills_dir).await.expect("scan");
assert!(entries.is_empty());
assert!(skills_dir.exists());
}
#[tokio::test]
async fn scan_valid_skill() {
let tmp = TempDir::new().expect("tempdir");
let skill_dir = tmp.path().join("code-review");
std::fs::create_dir(&skill_dir).expect("mkdir");
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: code-review\ndescription: Reviews code for quality\n---\n# Instructions\nReview the code.",
)
.expect("write");
let entries = scan_skills_dir(tmp.path()).await.expect("scan");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "code-review");
assert_eq!(entries[0].description, "Reviews code for quality");
assert_eq!(entries[0].source, "filesystem");
}
#[tokio::test]
async fn scan_falls_back_to_dir_name() {
let tmp = TempDir::new().expect("tempdir");
let skill_dir = tmp.path().join("my-skill");
std::fs::create_dir(&skill_dir).expect("mkdir");
std::fs::write(
skill_dir.join("SKILL.md"),
"---\ndescription: A skill without a name field\n---\nBody.",
)
.expect("write");
let entries = scan_skills_dir(tmp.path()).await.expect("scan");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "my-skill");
}
#[tokio::test]
async fn scan_skips_missing_description() {
let tmp = TempDir::new().expect("tempdir");
let skill_dir = tmp.path().join("bad-skill");
std::fs::create_dir(&skill_dir).expect("mkdir");
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: bad-skill\n---\nNo description in frontmatter.",
)
.expect("write");
let entries = scan_skills_dir(tmp.path()).await.expect("scan");
assert!(entries.is_empty());
}
#[tokio::test]
async fn scan_skips_non_skill_dirs() {
let tmp = TempDir::new().expect("tempdir");
std::fs::write(tmp.path().join("README.md"), "not a skill").expect("write");
std::fs::create_dir(tmp.path().join("empty-dir")).expect("mkdir");
let entries = scan_skills_dir(tmp.path()).await.expect("scan");
assert!(entries.is_empty());
}
}