collet 0.1.0

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
//! Skill registry — holds discovered skills and provides invocation.

use std::path::Path;

use super::discovery::{self, SkillMeta};

/// Compute the Levenshtein edit distance between two strings.
fn levenshtein(a: &str, b: &str) -> usize {
    let a: Vec<char> = a.chars().collect();
    let b: Vec<char> = b.chars().collect();
    let (m, n) = (a.len(), b.len());
    let mut dp = vec![vec![0usize; n + 1]; m + 1];
    for (i, row) in dp.iter_mut().enumerate() {
        row[0] = i;
    }
    for (j, val) in dp[0].iter_mut().enumerate() {
        *val = j;
    }
    for i in 1..=m {
        for j in 1..=n {
            dp[i][j] = if a[i - 1] == b[j - 1] {
                dp[i - 1][j - 1]
            } else {
                1 + dp[i - 1][j].min(dp[i][j - 1]).min(dp[i - 1][j - 1])
            };
        }
    }
    dp[m][n]
}

/// Central registry of all discovered skills.
#[derive(Debug, Clone)]
pub struct SkillRegistry {
    skills: Vec<SkillMeta>,
}

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

    /// Discover and load all skills from project + user directories.
    pub fn discover(working_dir: &Path) -> Self {
        let skills = discovery::discover_all(working_dir);
        Self { skills }
    }

    /// Number of registered skills.
    pub fn count(&self) -> usize {
        self.skills.len()
    }

    /// All registered skill metadata.
    pub fn all(&self) -> &[SkillMeta] {
        &self.skills
    }

    /// Find a skill by exact name.
    pub fn find(&self, name: &str) -> Option<&SkillMeta> {
        self.skills.iter().find(|s| s.name == name)
    }

    /// Find a skill by name using fuzzy matching:
    /// 1. Exact match
    /// 2. Substring match (unique only)
    /// 3. Levenshtein closest match (distance ≤ 3, unambiguous)
    pub fn find_fuzzy(&self, query: &str) -> Option<&SkillMeta> {
        let q = query.to_lowercase();

        // 1. Exact match
        if let Some(skill) = self.skills.iter().find(|s| s.name == q) {
            return Some(skill);
        }

        // 2. Substring match (unique only)
        let substr_matches: Vec<_> = self
            .skills
            .iter()
            .filter(|s| s.name.contains(&q) || s.description.to_lowercase().contains(&q))
            .collect();
        if substr_matches.len() == 1 {
            return Some(substr_matches[0]);
        }

        // 3. Levenshtein: closest skill name within edit distance 3, unambiguous
        const MAX_EDIT_DIST: usize = 3;
        let mut best_dist = MAX_EDIT_DIST + 1;
        let mut best: Option<&SkillMeta> = None;
        let mut ambiguous = false;
        for skill in &self.skills {
            let dist = levenshtein(&q, &skill.name.to_lowercase());
            if dist < best_dist {
                best_dist = dist;
                best = Some(skill);
                ambiguous = false;
            } else if dist == best_dist {
                ambiguous = true;
            }
        }
        if !ambiguous && best_dist <= MAX_EDIT_DIST {
            return best;
        }

        None
    }

    /// Generate the Level-1 metadata string for system prompt injection.
    ///
    /// Format: concise list of skill names and descriptions (~100 tokens each).
    pub fn system_prompt_metadata(&self) -> Option<String> {
        if self.skills.is_empty() {
            return None;
        }

        let mut lines = vec!["## Available Skills\n".to_string()];
        lines.push("The following skills are available. Use the `skill` tool to invoke them when relevant.\n".to_string());

        for skill in &self.skills {
            lines.push(format!(
                "- **{}** ({}): {}",
                skill.name, skill.source, skill.description,
            ));
        }

        Some(lines.join("\n"))
    }

    /// Invoke a skill: load Level-2 instructions (SKILL.md body).
    /// Falls back to fuzzy (Levenshtein) matching when exact name fails.
    pub fn invoke(&self, name: &str) -> anyhow::Result<SkillInvocation> {
        let skill = self
            .find(name)
            .or_else(|| self.find_fuzzy(name))
            .ok_or_else(|| {
                anyhow::anyhow!(
                    "Skill '{name}' not found. Available: {}",
                    self.skills
                        .iter()
                        .map(|s| s.name.as_str())
                        .collect::<Vec<_>>()
                        .join(", ")
                )
            })?;
        self.invoke_skill(skill)
    }

    /// Invoke with BM25 semantic fallback via ToolIndex.
    /// Chain: exact → fuzzy (Levenshtein) → BM25 top-1 match.
    pub fn invoke_with_index(
        &self,
        name: &str,
        tool_index: &crate::tools::tool_index::ToolIndex,
    ) -> anyhow::Result<SkillInvocation> {
        // Try exact + Levenshtein first.
        if let Some(skill) = self.find(name).or_else(|| self.find_fuzzy(name)) {
            return self.invoke_skill(skill);
        }

        // BM25 semantic fallback: find the top-ranked skill by query similarity.
        let results =
            tool_index.search_by_source(name, crate::tools::tool_index::ToolSource::Skill, 1);
        if let Some(top) = results.first()
            && let Some(skill) = self.find(&top.id)
        {
            tracing::debug!(
                query = %name,
                matched = %skill.name,
                "Skill resolved via BM25 semantic fallback"
            );
            return self.invoke_skill(skill);
        }

        anyhow::bail!(
            "Skill '{name}' not found. Available: {}",
            self.skills
                .iter()
                .map(|s| s.name.as_str())
                .collect::<Vec<_>>()
                .join(", ")
        )
    }

    fn invoke_skill(&self, skill: &SkillMeta) -> anyhow::Result<SkillInvocation> {
        let body = discovery::load_skill_body(&skill.path)?;
        let resources = discovery::list_skill_resources(&skill.path);
        let resource_names: Vec<String> = resources
            .iter()
            .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
            .collect();
        Ok(SkillInvocation {
            name: skill.name.clone(),
            instructions: body,
            skill_dir: skill
                .path
                .parent()
                .unwrap_or_else(|| Path::new("."))
                .to_path_buf(),
            available_resources: resource_names,
        })
    }
}

impl Default for SkillRegistry {
    fn default() -> Self {
        Self::new()
    }
}

/// Result of invoking a skill (Level-2 loaded).
#[derive(Debug)]
pub struct SkillInvocation {
    /// Skill name.
    pub name: String,
    /// The instruction body from SKILL.md (markdown).
    pub instructions: String,
    /// Directory containing skill resources.
    pub skill_dir: std::path::PathBuf,
    /// Names of available resource files.
    pub available_resources: Vec<String>,
}

impl SkillInvocation {
    /// Format as a context injection for the conversation.
    pub fn to_context_string(&self) -> String {
        let mut result = format!("<skill name=\"{}\">\n{}\n", self.name, self.instructions,);

        if !self.available_resources.is_empty() {
            result.push_str("\n## Available Resources\n\n");
            result.push_str("The following files are available in the skill directory. ");
            result.push_str("Use `file_read` or `bash` to access them as needed:\n\n");
            for res in &self.available_resources {
                let path = self.skill_dir.join(res);
                result.push_str(&format!("- `{}`\n", path.display()));
            }
        }

        result.push_str("</skill>");
        result
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;

    fn create_test_skill(dir: &Path, name: &str, description: &str) {
        let skill_dir = dir.join(".claude").join("skills").join(name);
        fs::create_dir_all(&skill_dir).unwrap();
        fs::write(
            skill_dir.join("SKILL.md"),
            format!("---\nname: {name}\ndescription: {description}\n---\n\n# Instructions\nDo the {name} thing."),
        ).unwrap();
    }

    #[test]
    fn test_registry_discover() {
        let dir = tempfile::tempdir().unwrap();
        create_test_skill(dir.path(), "lint-code", "Run linting on code files");
        create_test_skill(
            dir.path(),
            "deploy-app",
            "Deploy the application to staging",
        );

        let reg = SkillRegistry::discover(dir.path());
        // Count only project-scoped skills (user skills from ~/.config may also be present)
        let project_count = reg
            .all()
            .iter()
            .filter(|s| s.source == super::discovery::SkillSource::Project)
            .count();
        assert_eq!(project_count, 2);
    }

    #[test]
    fn test_registry_find_exact() {
        let dir = tempfile::tempdir().unwrap();
        create_test_skill(dir.path(), "lint-code", "Run linting");

        let reg = SkillRegistry::discover(dir.path());
        assert!(reg.find("lint-code").is_some());
        assert!(reg.find("nonexistent").is_none());
    }

    #[test]
    fn test_registry_find_fuzzy() {
        let dir = tempfile::tempdir().unwrap();
        create_test_skill(dir.path(), "xyzlint-tool", "Run linting on xyz files");
        create_test_skill(dir.path(), "xyzdeploy-app", "Deploy the xyz application");

        let reg = SkillRegistry::discover(dir.path());

        // Substring match on unique prefixes (avoiding collision with user skills)
        assert_eq!(reg.find_fuzzy("xyzlint").unwrap().name, "xyzlint-tool");
        assert_eq!(reg.find_fuzzy("xyzdeploy").unwrap().name, "xyzdeploy-app");
    }

    #[test]
    fn test_registry_invoke() {
        let dir = tempfile::tempdir().unwrap();
        create_test_skill(dir.path(), "lint-code", "Run linting");

        let reg = SkillRegistry::discover(dir.path());
        let invocation = reg.invoke("lint-code").unwrap();

        assert_eq!(invocation.name, "lint-code");
        assert!(invocation.instructions.contains("Do the lint-code thing"));
    }

    #[test]
    fn test_registry_invoke_not_found() {
        let reg = SkillRegistry::new();
        assert!(reg.invoke("missing").is_err());
    }

    #[test]
    fn test_system_prompt_metadata() {
        let dir = tempfile::tempdir().unwrap();
        create_test_skill(dir.path(), "lint-code", "Run linting on code");

        let reg = SkillRegistry::discover(dir.path());
        let metadata = reg.system_prompt_metadata().unwrap();

        assert!(metadata.contains("lint-code"));
        assert!(metadata.contains("Run linting on code"));
        assert!(metadata.contains("Available Skills"));
    }

    #[test]
    fn test_system_prompt_metadata_empty() {
        let reg = SkillRegistry::new();
        assert!(reg.system_prompt_metadata().is_none());
    }

    #[test]
    fn test_invocation_with_resources() {
        let dir = tempfile::tempdir().unwrap();
        let skill_dir = dir.path().join(".claude").join("skills").join("my-skill");
        fs::create_dir_all(&skill_dir).unwrap();
        fs::write(
            skill_dir.join("SKILL.md"),
            "---\nname: my-skill\ndescription: Test\n---\n# Body",
        )
        .unwrap();
        fs::write(skill_dir.join("REFERENCE.md"), "# Ref").unwrap();
        fs::write(skill_dir.join("helper.py"), "print('hi')").unwrap();

        let reg = SkillRegistry::discover(dir.path());
        let inv = reg.invoke("my-skill").unwrap();

        assert_eq!(inv.available_resources.len(), 2);
        let ctx = inv.to_context_string();
        assert!(ctx.contains("<skill name=\"my-skill\">"));
        assert!(ctx.contains("Available Resources"));
        assert!(ctx.contains("REFERENCE.md"));
        assert!(ctx.contains("helper.py"));
        assert!(ctx.contains("</skill>"));
    }
}