procyon 0.1.2

Terminal development harness for Stellar and Soroban smart contracts, driven by a language model
pub mod loader;
pub mod parser;

use std::path::Path;

pub use loader::Skill;

/// Registry of discovered skills, searchable by name or description.
pub struct SkillRegistry {
    skills: Vec<Skill>,
}

impl SkillRegistry {
    /// Creates an empty registry.
    pub fn new() -> Self {
        Self { skills: Vec::new() }
    }

    /// Discovers skills from default + project directories and builds a registry.
    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)
    }

    /// A registry over an exact list of skills, bypassing directory discovery entirely.
    ///
    /// For tests that need to control precisely what is "installed" without picking up whatever
    /// happens to be in the real `~/.claude/skills` on the machine running them.
    #[cfg(test)]
    pub fn from_skills_for_test(skills: Vec<Skill>) -> Self {
        Self { skills }
    }

    /// Returns all loaded skills.
    pub fn all(&self) -> &[Skill] {
        &self.skills
    }

    /// Finds a skill by name (case-insensitive).
    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)
    }

    /// Searches skills by fuzzy matching on name and description.
    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/"
            );
        }
    }
}