pub mod loader;
pub mod parser;
use std::path::Path;
pub use loader::Skill;
pub struct SkillRegistry {
skills: Vec<Skill>,
}
impl SkillRegistry {
pub fn new() -> Self {
Self { skills: Vec::new() }
}
pub fn discover(cwd: &Path) -> (Self, Vec<String>) {
let mut dirs = loader::SkillLoader::default_dirs();
dirs.extend(loader::SkillLoader::project_dirs(cwd));
let (skills, warnings) = loader::SkillLoader::discover(&dirs);
(Self { skills }, warnings)
}
#[cfg(test)]
pub fn from_skills_for_test(skills: Vec<Skill>) -> Self {
Self { skills }
}
pub fn all(&self) -> &[Skill] {
&self.skills
}
pub fn get(&self, name: &str) -> Option<&Skill> {
let name_lower = name.to_lowercase();
self.skills
.iter()
.find(|s| s.name.to_lowercase() == name_lower)
}
pub fn search(&self, query: &str) -> Vec<&Skill> {
let query_lower = query.to_lowercase();
let mut results: Vec<_> = self
.skills
.iter()
.filter(|s| {
s.name.to_lowercase().contains(&query_lower)
|| s.description.to_lowercase().contains(&query_lower)
})
.collect();
results.sort_by(|a, b| {
let a_name = a.name.to_lowercase();
let b_name = b.name.to_lowercase();
let a_exact = a_name == query_lower;
let b_exact = b_name == query_lower;
if a_exact && !b_exact {
return std::cmp::Ordering::Less;
}
if !a_exact && b_exact {
return std::cmp::Ordering::Greater;
}
let a_prefix = a_name.starts_with(&query_lower);
let b_prefix = b_name.starts_with(&query_lower);
if a_prefix && !b_prefix {
return std::cmp::Ordering::Less;
}
if !a_prefix && b_prefix {
return std::cmp::Ordering::Greater;
}
std::cmp::Ordering::Equal
});
results
}
}
impl Default for SkillRegistry {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn make_skill(name: &str, desc: &str) -> Skill {
Skill {
name: name.to_string(),
description: desc.to_string(),
path: PathBuf::from("/fake"),
body: format!("# {}\n\nBody content.", name),
has_customize: false,
version: None,
digest: crate::knowledge::digest(name),
}
}
#[test]
fn get_finds_by_name_case_insensitive() {
let mut reg = SkillRegistry::new();
reg.skills.push(make_skill("soroban", "Soroban contracts"));
reg.skills.push(make_skill("dapp", "Stellar dApp dev"));
assert!(reg.get("soroban").is_some());
assert!(reg.get("Soroban").is_some());
assert!(reg.get("Dapp").is_some());
assert!(reg.get("unknown").is_none());
}
#[test]
fn search_matches_name_and_description() {
let mut reg = SkillRegistry::new();
reg.skills
.push(make_skill("soroban", "Soroban smart contracts"));
reg.skills.push(make_skill("dapp", "Stellar dApp frontend"));
let results = reg.search("stellar");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "dapp");
let results = reg.search("soroban");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "soroban");
}
#[test]
fn search_is_case_insensitive() {
let mut reg = SkillRegistry::new();
reg.skills.push(make_skill("soroban", "Smart contracts"));
assert_eq!(reg.search("SOROBAN").len(), 1);
assert_eq!(reg.search("Soroban").len(), 1);
}
#[test]
fn discover_from_empty_dirs() {
let tmp = tempfile::tempdir().unwrap();
let (skill_reg, _) = loader::SkillLoader::discover(&[tmp.path().to_path_buf()]);
assert!(skill_reg.is_empty(), "empty dir should yield no skills");
}
#[test]
fn discover_loads_real_skills() {
let claude_skills = dirs::home_dir()
.map(|h| h.join(".claude").join("skills"))
.filter(|p| p.exists());
if let Some(dir) = claude_skills {
let (reg, _) = SkillRegistry::discover(&dir);
assert!(
!reg.all().is_empty(),
"should find skills in ~/.claude/skills/"
);
}
}
}