collet 0.1.0

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
//! Skill discovery — scan filesystem for SKILL.md files and parse YAML frontmatter.

use std::path::{Path, PathBuf};

/// Level-1 metadata parsed from YAML frontmatter.
#[derive(Debug, Clone)]
pub struct SkillMeta {
    /// Unique skill name (lowercase, hyphens only).
    pub name: String,
    /// Human-readable description (used for LLM matching).
    pub description: String,
    /// Discovery tags parsed from frontmatter `tags:` or `categories:` field.
    pub tags: Vec<String>,
    /// First 800 bytes of skill body for richer BM25 indexing.
    pub body_excerpt: String,
    /// Absolute path to the SKILL.md file.
    pub path: PathBuf,
    /// Source: "project" or "user".
    pub source: SkillSource,
}

#[derive(Debug, Clone, PartialEq)]
pub enum SkillSource {
    Project,
    User,
    Plugin,
}

impl std::fmt::Display for SkillSource {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SkillSource::Project => write!(f, "project"),
            SkillSource::User => write!(f, "user"),
            SkillSource::Plugin => write!(f, "plugin"),
        }
    }
}

/// Discover all skills from project, user, and plugin directories.
pub fn discover_all(working_dir: &Path) -> Vec<SkillMeta> {
    let mut skills = Vec::new();

    // 1. Project skills: <working_dir>/.claude/skills/*/SKILL.md
    let project_dir = working_dir.join(".claude").join("skills");
    skills.extend(scan_directory(&project_dir, SkillSource::Project));

    // 2. User skills: ~/.collet/skills/*/SKILL.md
    let user_dir = crate::config::config_dir().join("skills");
    skills.extend(scan_directory(&user_dir, SkillSource::User));

    // 3. Plugin skills: ~/.collet/plugins/*/skills/*/SKILL.md
    let plugins_dir = crate::config::config_dir().join("plugins");
    if let Ok(entries) = std::fs::read_dir(&plugins_dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            if !path.is_dir() {
                continue;
            }
            let plugin_skills_dir = path.join("skills");
            if plugin_skills_dir.exists() {
                skills.extend(scan_directory(&plugin_skills_dir, SkillSource::Plugin));
            }
        }
    }

    tracing::info!(
        project_count = skills
            .iter()
            .filter(|s| s.source == SkillSource::Project)
            .count(),
        user_count = skills
            .iter()
            .filter(|s| s.source == SkillSource::User)
            .count(),
        plugin_count = skills
            .iter()
            .filter(|s| s.source == SkillSource::Plugin)
            .count(),
        "Skills discovered",
    );

    skills
}

/// Scan a directory for skill subdirectories containing SKILL.md.
fn scan_directory(dir: &Path, source: SkillSource) -> Vec<SkillMeta> {
    let mut skills = Vec::new();

    let entries = match std::fs::read_dir(dir) {
        Ok(e) => e,
        Err(_) => return skills,
    };

    for entry in entries.flatten() {
        let path = 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, source.clone()) {
            Ok(meta) => {
                tracing::debug!(name = %meta.name, source = %meta.source, "Skill found");
                skills.push(meta);
            }
            Err(e) => {
                tracing::warn!("Failed to parse {:?}: {e}", skill_md);
            }
        }
    }

    skills
}

/// Parse YAML frontmatter from a SKILL.md file.
///
/// Expected format:
/// ```markdown
/// ---
/// name: my-skill
/// description: Does something useful. Use when the user asks about X.
/// ---
///
/// # Skill body (Level 2 - loaded on demand)
/// ```
fn parse_skill_md(path: &Path, source: SkillSource) -> anyhow::Result<SkillMeta> {
    let content = std::fs::read_to_string(path)?;

    // Extract YAML frontmatter between --- delimiters
    let frontmatter = extract_frontmatter(&content)
        .ok_or_else(|| anyhow::anyhow!("No YAML frontmatter found"))?;

    let name = extract_yaml_field(&frontmatter, "name")
        .ok_or_else(|| anyhow::anyhow!("Missing 'name' field in frontmatter"))?;

    let description = extract_yaml_field(&frontmatter, "description")
        .ok_or_else(|| anyhow::anyhow!("Missing 'description' field in frontmatter"))?;

    // Support tags: or categories: (alias), all three YAML list formats
    let tags: Vec<String> = {
        let t = crate::config::extract_yaml_list(&frontmatter, "tags");
        if t.is_empty() {
            crate::config::extract_yaml_list(&frontmatter, "categories")
        } else {
            t
        }
    };

    // Validate name
    if name.len() > 64 {
        anyhow::bail!("Skill name too long (max 64 chars)");
    }
    if !name
        .chars()
        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
    {
        anyhow::bail!("Skill name must contain only lowercase letters, digits, and hyphens");
    }
    if description.is_empty() || description.len() > 1024 {
        anyhow::bail!("Description must be 1-1024 characters");
    }

    // Extract body excerpt for rich BM25 indexing
    let body_excerpt = {
        let trimmed = content.trim_start();
        let after_first = &trimmed[3..]; // skip first ---
        if let Some(end) = after_first.find("---") {
            let body = after_first[end + 3..].trim();
            let mut byte_end = body.len().min(800);
            while byte_end > 0 && !body.is_char_boundary(byte_end) {
                byte_end -= 1;
            }
            body[..byte_end].to_string()
        } else {
            String::new()
        }
    };

    Ok(SkillMeta {
        name,
        description,
        tags,
        body_excerpt,
        path: path.to_path_buf(),
        source,
    })
}

/// Extract the body (everything after the second `---`) from a SKILL.md.
pub fn load_skill_body(path: &Path) -> anyhow::Result<String> {
    let content = std::fs::read_to_string(path)?;

    // Find the end of frontmatter
    let trimmed = content.trim_start();
    if !trimmed.starts_with("---") {
        return Ok(content);
    }

    // Find the second ---
    if let Some(end) = trimmed[3..].find("---") {
        let body_start = 3 + end + 3;
        Ok(trimmed[body_start..].trim().to_string())
    } else {
        Ok(content)
    }
}

/// List files in the same directory as SKILL.md (resources).
pub fn list_skill_resources(skill_path: &Path) -> Vec<PathBuf> {
    let dir = match skill_path.parent() {
        Some(d) => d,
        None => return Vec::new(),
    };

    let mut resources = Vec::new();
    if let Ok(entries) = std::fs::read_dir(dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_file()
                && path.file_name().map(|n| n.to_string_lossy().to_string())
                    != Some("SKILL.md".to_string())
            {
                resources.push(path);
            }
        }
    }
    resources
}

// ---------------------------------------------------------------------------
// YAML helpers (lightweight — no serde_yaml dependency)
// ---------------------------------------------------------------------------

fn extract_frontmatter(content: &str) -> Option<String> {
    let trimmed = content.trim_start();
    if !trimmed.starts_with("---") {
        return None;
    }

    let after_first = &trimmed[3..];
    let end = after_first.find("---")?;
    Some(after_first[..end].to_string())
}

fn extract_yaml_field(yaml: &str, field: &str) -> Option<String> {
    for line in yaml.lines() {
        let line = line.trim();
        if let Some(rest) = line.strip_prefix(field) {
            let rest = rest.trim_start();
            if let Some(value) = rest.strip_prefix(':') {
                let value = value.trim();
                // Strip quotes if present
                let value = value.trim_matches('"').trim_matches('\'');
                if !value.is_empty() {
                    return Some(value.to_string());
                }
            }
        }
    }
    None
}

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

    #[test]
    fn test_extract_frontmatter() {
        let content = "---\nname: test\ndescription: A test\n---\n\n# Body";
        let fm = extract_frontmatter(content).unwrap();
        assert!(fm.contains("name: test"));
        assert!(fm.contains("description: A test"));
    }

    #[test]
    fn test_extract_frontmatter_none() {
        assert!(extract_frontmatter("# No frontmatter").is_none());
    }

    #[test]
    fn test_extract_yaml_field() {
        let yaml = "name: my-skill\ndescription: Does things";
        assert_eq!(extract_yaml_field(yaml, "name").unwrap(), "my-skill");
        assert_eq!(
            extract_yaml_field(yaml, "description").unwrap(),
            "Does things"
        );
    }

    #[test]
    fn test_extract_yaml_field_quoted() {
        let yaml = "name: \"my-skill\"\ndescription: 'quoted desc'";
        assert_eq!(extract_yaml_field(yaml, "name").unwrap(), "my-skill");
        assert_eq!(
            extract_yaml_field(yaml, "description").unwrap(),
            "quoted desc"
        );
    }

    #[test]
    fn test_extract_yaml_field_missing() {
        let yaml = "name: test";
        assert!(extract_yaml_field(yaml, "description").is_none());
    }

    #[test]
    fn test_parse_skill_md() {
        let dir = tempfile::tempdir().unwrap();
        let skill_dir = dir.path().join("my-skill");
        fs::create_dir_all(&skill_dir).unwrap();

        let skill_md = skill_dir.join("SKILL.md");
        fs::write(&skill_md, "---\nname: my-skill\ndescription: A test skill for demos\n---\n\n# Instructions\nDo the thing.").unwrap();

        let meta = parse_skill_md(&skill_md, SkillSource::Project).unwrap();
        assert_eq!(meta.name, "my-skill");
        assert_eq!(meta.description, "A test skill for demos");
        assert_eq!(meta.source, SkillSource::Project);
    }

    #[test]
    fn test_parse_skill_md_invalid_name() {
        let dir = tempfile::tempdir().unwrap();
        let skill_md = dir.path().join("SKILL.md");
        fs::write(
            &skill_md,
            "---\nname: Bad Name!\ndescription: Invalid\n---\n",
        )
        .unwrap();

        assert!(parse_skill_md(&skill_md, SkillSource::Project).is_err());
    }

    #[test]
    fn test_load_skill_body() {
        let dir = tempfile::tempdir().unwrap();
        let skill_md = dir.path().join("SKILL.md");
        fs::write(
            &skill_md,
            "---\nname: test\ndescription: Test\n---\n\n# Instructions\nDo stuff.",
        )
        .unwrap();

        let body = load_skill_body(&skill_md).unwrap();
        assert!(body.contains("# Instructions"));
        assert!(body.contains("Do stuff."));
        assert!(!body.contains("name:"));
    }

    #[test]
    fn test_discover_all() {
        let dir = tempfile::tempdir().unwrap();

        // Create a project skill
        let skill_dir = dir.path().join(".claude").join("skills").join("test-skill");
        fs::create_dir_all(&skill_dir).unwrap();
        fs::write(
            skill_dir.join("SKILL.md"),
            "---\nname: test-skill\ndescription: A test\n---\n# Body",
        )
        .unwrap();

        let skills = discover_all(dir.path());
        let project_skills: Vec<_> = skills
            .iter()
            .filter(|s| s.source == SkillSource::Project)
            .collect();
        assert_eq!(project_skills.len(), 1);
        assert_eq!(project_skills[0].name, "test-skill");
        assert_eq!(project_skills[0].source, SkillSource::Project);
    }

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

        let resources = list_skill_resources(&skill_dir.join("SKILL.md"));
        assert_eq!(resources.len(), 2);
    }
}