use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct SkillMeta {
pub name: String,
pub description: String,
pub tags: Vec<String>,
pub source: SkillSource,
}
#[derive(Debug, Clone)]
pub enum SkillSource {
BuiltIn,
User,
Project,
}
#[derive(Debug, Clone)]
pub struct Skill {
pub meta: SkillMeta,
pub content: String,
}
#[derive(Debug, Default)]
pub struct SkillRegistry {
skills: HashMap<String, Skill>,
}
impl SkillRegistry {
pub fn discover(project_root: &Path) -> Self {
let mut registry = Self::default();
registry.load_builtin();
if let Ok(config_dir) = crate::db::config_dir() {
let user_dir = config_dir.join("skills");
registry.load_directory(&user_dir, SkillSource::User);
}
let project_dir = project_root.join(".koda").join("skills");
registry.load_directory(&project_dir, SkillSource::Project);
registry
}
fn load_builtin(&mut self) {
let builtins: &[(&str, &str)] = &[
(
"code-review",
include_str!("../skills/code-review/SKILL.md"),
),
(
"security-audit",
include_str!("../skills/security-audit/SKILL.md"),
),
];
for (name, content) in builtins {
if let Some(skill) = parse_skill_md(content, SkillSource::BuiltIn) {
self.skills.insert(name.to_string(), skill);
}
}
}
fn load_directory(&mut self, dir: &Path, source: SkillSource) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
continue;
}
let skill_file = entry.path().join("SKILL.md");
if let Some(skill) = std::fs::read_to_string(&skill_file)
.ok()
.and_then(|content| parse_skill_md(&content, source.clone()))
{
self.skills.insert(skill.meta.name.clone(), skill);
}
}
}
pub fn list(&self) -> Vec<&SkillMeta> {
let mut metas: Vec<&SkillMeta> = self.skills.values().map(|s| &s.meta).collect();
metas.sort_by_key(|m| &m.name);
metas
}
pub fn search(&self, query: &str) -> Vec<&SkillMeta> {
let q = query.to_lowercase();
let mut results: Vec<&SkillMeta> = self
.skills
.values()
.filter(|s| {
s.meta.name.to_lowercase().contains(&q)
|| s.meta.description.to_lowercase().contains(&q)
|| s.meta.tags.iter().any(|t| t.to_lowercase().contains(&q))
})
.map(|s| &s.meta)
.collect();
results.sort_by_key(|m| &m.name);
results
}
pub fn activate(&self, name: &str) -> Option<&str> {
self.skills.get(name).map(|s| s.content.as_str())
}
pub fn len(&self) -> usize {
self.skills.len()
}
pub fn is_empty(&self) -> bool {
self.skills.is_empty()
}
}
fn parse_skill_md(raw: &str, source: SkillSource) -> Option<Skill> {
let trimmed = raw.trim_start();
if !trimmed.starts_with("---") {
return None;
}
let after_open = &trimmed[3..];
let close_pos = after_open.find("\n---")?;
let frontmatter = &after_open[..close_pos].trim();
let content = after_open[close_pos + 4..].trim_start().to_string();
let mut name = String::new();
let mut description = String::new();
let mut tags = Vec::new();
for line in frontmatter.lines() {
let line = line.trim();
if let Some(val) = line.strip_prefix("name:") {
name = val.trim().to_string();
} else if let Some(val) = line.strip_prefix("description:") {
description = val.trim().to_string();
} else if let Some(val) = line.strip_prefix("tags:") {
let val = val.trim();
if let Some(inner) = val.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
tags = inner.split(',').map(|t| t.trim().to_string()).collect();
}
}
}
if name.is_empty() {
return None;
}
Some(Skill {
meta: SkillMeta {
name,
description,
tags,
source,
},
content,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_skill_md() {
let raw = r#"---
name: code-review
description: Senior code review
tags: [review, quality]
---
# Code Review
Do the review.
"#;
let skill = parse_skill_md(raw, SkillSource::BuiltIn).unwrap();
assert_eq!(skill.meta.name, "code-review");
assert_eq!(skill.meta.description, "Senior code review");
assert_eq!(skill.meta.tags, vec!["review", "quality"]);
assert!(skill.content.contains("# Code Review"));
assert!(skill.content.contains("Do the review."));
}
#[test]
fn test_parse_no_frontmatter() {
assert!(parse_skill_md("# Just markdown", SkillSource::BuiltIn).is_none());
}
#[test]
fn test_parse_no_name() {
let raw = "---\ndescription: no name\n---\ncontent";
assert!(parse_skill_md(raw, SkillSource::BuiltIn).is_none());
}
#[test]
fn test_builtin_skills_load() {
let mut registry = SkillRegistry::default();
registry.load_builtin();
assert!(registry.len() >= 2);
assert!(registry.activate("code-review").is_some());
assert!(registry.activate("security-audit").is_some());
}
#[test]
fn test_search() {
let mut registry = SkillRegistry::default();
registry.load_builtin();
let results = registry.search("review");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "code-review");
let results = registry.search("security");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "security-audit");
}
#[test]
fn test_search_by_tag() {
let mut registry = SkillRegistry::default();
registry.load_builtin();
let results = registry.search("owasp");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "security-audit");
}
#[test]
fn test_list_sorted() {
let mut registry = SkillRegistry::default();
registry.load_builtin();
let list = registry.list();
assert!(list.len() >= 2);
assert_eq!(list[0].name, "code-review");
assert_eq!(list[1].name, "security-audit");
}
#[test]
fn test_directory_discovery() {
let tmp = tempfile::TempDir::new().unwrap();
let skill_dir = tmp.path().join("my-skill");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: my-skill\ndescription: test\ntags: []\n---\n# Test",
)
.unwrap();
let mut registry = SkillRegistry::default();
registry.load_directory(tmp.path(), SkillSource::Project);
assert_eq!(registry.len(), 1);
assert!(registry.activate("my-skill").is_some());
}
}