procyon 0.1.2

Terminal development harness for Stellar and Soroban smart contracts, driven by a language model
use std::path::{Path, PathBuf};

use super::parser::parse_skill_file;

/// A discovered skill with its metadata and content.
#[derive(Debug, Clone)]
pub struct Skill {
    pub name: String,
    pub description: String,
    pub path: PathBuf,
    pub body: String,
    pub has_customize: bool,
    /// From the skill's own frontmatter, if it declares one.
    pub version: Option<String>,
    /// Of the whole `SKILL.md`, frontmatter included: a change to the description changes what the
    /// model was told just as much as a change to the body. See `crate::knowledge`.
    pub digest: String,
}

impl Skill {
    /// What to cite when this skill is loaded.
    pub fn provenance(&self) -> crate::knowledge::Provenance {
        crate::knowledge::Provenance {
            name: self.name.clone(),
            version: self.version.clone(),
            digest: self.digest.clone(),
            origin: self.path.clone(),
        }
    }
}

/// Discovers and loads skills from a list of directories.
///
/// Scans each directory for subdirectories containing a `SKILL.md` file.
/// Also checks for a `customize.toml` alongside the SKILL.md.
pub struct SkillLoader;

impl SkillLoader {
    /// Discovers all skills in the given directories.
    /// Returns loaded skills and any warnings from failed loads.
    pub fn discover(dirs: &[PathBuf]) -> (Vec<Skill>, Vec<String>) {
        let mut skills = Vec::new();
        let mut warnings = Vec::new();

        for dir in dirs {
            if !dir.exists() {
                continue;
            }
            Self::scan_dir(dir, &mut skills, &mut warnings);
        }

        // Deduplicate by name (first occurrence wins)
        let mut seen = std::collections::HashSet::new();
        skills.retain(|s| seen.insert(s.name.clone()));

        (skills, warnings)
    }

    /// Discovers skills from a single directory.
    /// Each subdirectory with a SKILL.md is treated as a skill.
    fn scan_dir(dir: &Path, skills: &mut Vec<Skill>, warnings: &mut Vec<String>) {
        let entries = match std::fs::read_dir(dir) {
            Ok(e) => e,
            Err(e) => {
                warnings.push(format!("Failed to read {}: {}", dir.display(), e));
                return;
            }
        };

        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;
            }

            // Read separately from the parse, and hashed whole: the digest has to describe the
            // file on disk, not this parser's view of it. A frontmatter key this build cannot model
            // is dropped by the parser (see `diag`), and a digest taken after that would call two
            // different files identical.
            let raw = std::fs::read_to_string(&skill_md).unwrap_or_default();

            match parse_skill_file(&skill_md) {
                Ok(parsed) => {
                    let has_customize = path.join("customize.toml").exists();
                    let name = parsed.frontmatter.get("name").cloned().unwrap_or_else(|| {
                        path.file_name()
                            .map(|n| n.to_string_lossy().to_string())
                            .unwrap_or_default()
                    });
                    skills.push(Skill {
                        name,
                        description: parsed
                            .frontmatter
                            .get("description")
                            .cloned()
                            .unwrap_or_default(),
                        path,
                        body: parsed.body,
                        has_customize,
                        version: parsed.frontmatter.get("version").cloned(),
                        digest: crate::knowledge::digest(&raw),
                    });
                }
                Err(e) => {
                    warnings.push(format!("Failed to parse {}: {}", skill_md.display(), e));
                }
            }
        }
    }

    /// Returns default directories to search for skills.
    pub fn default_dirs() -> Vec<PathBuf> {
        let mut dirs = Vec::new();

        // Global: ~/.claude/skills/
        if let Some(home) = dirs::home_dir() {
            dirs.push(home.join(".claude").join("skills"));
        }

        // Global: ~/.config/procyon/skills/
        if let Some(config_dir) = dirs::config_dir() {
            dirs.push(config_dir.join("procyon").join("skills"));
        }

        // Project-local: .procyon/skills/ (resolved at runtime from cwd)
        // Not included here — caller should add it.

        dirs
    }

    /// Returns project-local skill directories.
    pub fn project_dirs(cwd: &Path) -> Vec<PathBuf> {
        vec![
            cwd.join(".procyon").join("skills"),
            cwd.join(".stellar-build").join("skills"),
        ]
    }
}

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

    #[test]
    fn discover_returns_empty_for_nonexistent_dirs() {
        let (skills, warnings) = SkillLoader::discover(&[PathBuf::from("/nonexistent/path")]);
        assert!(skills.is_empty());
        assert!(warnings.is_empty());
    }

    // Provenance is only worth anything if it describes the file as it is on disk.
    #[test]
    fn a_discovered_skill_carries_its_version_digest_and_origin() {
        let tmp = tempfile::tempdir().unwrap();
        let skill_dir = tmp.path().join("soroban");
        fs::create_dir_all(&skill_dir).unwrap();
        let content = "---\nname: soroban\nversion: 2.1.0\ndescription: Contracts\n---\n\nStorage.";
        fs::write(skill_dir.join("SKILL.md"), content).unwrap();

        let (skills, _) = SkillLoader::discover(&[tmp.path().to_path_buf()]);
        let skill = &skills[0];

        assert_eq!(skill.version.as_deref(), Some("2.1.0"));
        assert_eq!(skill.digest, crate::knowledge::digest(content));
        assert_eq!(skill.path, skill_dir);
        assert_eq!(
            skill.provenance().cite(),
            "soroban v2.1.0 ".to_string() + &format!("({})", skill.digest)
        );
    }

    // The digest covers the frontmatter as well as the body: changing a description changes what
    // the model is told about when to use the skill, which is a change in knowledge.
    #[test]
    fn editing_the_frontmatter_changes_the_digest() {
        let digest_of = |content: &str| {
            let tmp = tempfile::tempdir().unwrap();
            let dir = tmp.path().join("s");
            fs::create_dir_all(&dir).unwrap();
            fs::write(dir.join("SKILL.md"), content).unwrap();
            SkillLoader::discover(&[tmp.path().to_path_buf()]).0[0]
                .digest
                .clone()
        };

        assert_ne!(
            digest_of("---\nname: s\ndescription: use for testnet\n---\n\nBody."),
            digest_of("---\nname: s\ndescription: use for mainnet\n---\n\nBody.")
        );
    }

    #[test]
    fn a_skill_without_a_declared_version_reports_none() {
        let tmp = tempfile::tempdir().unwrap();
        let dir = tmp.path().join("s");
        fs::create_dir_all(&dir).unwrap();
        fs::write(dir.join("SKILL.md"), "---\nname: s\n---\n\nBody.").unwrap();

        assert!(SkillLoader::discover(&[tmp.path().to_path_buf()]).0[0]
            .version
            .is_none());
    }

    #[test]
    fn discover_finds_skills_in_directory() {
        let tmp = tempfile::tempdir().unwrap();
        let skill_dir = tmp.path().join("my-skill");
        fs::create_dir_all(&skill_dir).unwrap();
        fs::write(
            skill_dir.join("SKILL.md"),
            "---\nname: test-skill\ndescription: A test skill\n---\n\n# Test\n\nContent.",
        )
        .unwrap();

        let (skills, warnings) = SkillLoader::discover(&[tmp.path().to_path_buf()]);
        assert_eq!(skills.len(), 1);
        assert!(warnings.is_empty());

        let skill = &skills[0];
        assert_eq!(skill.name, "test-skill");
        assert_eq!(skill.description, "A test skill");
        assert!(skill.body.contains("# Test"));
        assert!(!skill.has_customize);
    }

    #[test]
    fn discover_detects_customize_toml() {
        let tmp = tempfile::tempdir().unwrap();
        let skill_dir = tmp.path().join("with-customize");
        fs::create_dir_all(&skill_dir).unwrap();
        fs::write(
            skill_dir.join("SKILL.md"),
            "---\nname: custom\ndescription: Has customize\n---\n\nBody.",
        )
        .unwrap();
        fs::write(
            skill_dir.join("customize.toml"),
            "[agent]\nname = \"Custom\"\n",
        )
        .unwrap();

        let (skills, _) = SkillLoader::discover(&[tmp.path().to_path_buf()]);
        assert_eq!(skills.len(), 1);
        assert!(skills[0].has_customize);
    }

    #[test]
    fn discover_deduplicates_by_name() {
        let tmp = tempfile::tempdir().unwrap();
        let dir1 = tmp.path().join("dir1").join("dup");
        let dir2 = tmp.path().join("dir2").join("dup");
        fs::create_dir_all(&dir1).unwrap();
        fs::create_dir_all(&dir2).unwrap();

        let md = "---\nname: dup\ndescription: Duplicate\n---\n\nBody.";
        fs::write(dir1.join("SKILL.md"), md).unwrap();
        fs::write(dir2.join("SKILL.md"), md).unwrap();

        let (skills, _) =
            SkillLoader::discover(&[tmp.path().join("dir1"), tmp.path().join("dir2")]);
        assert_eq!(skills.len(), 1, "should deduplicate by name");
    }

    #[test]
    fn discover_skips_directories_without_skill_md() {
        let tmp = tempfile::tempdir().unwrap();
        let no_skill = tmp.path().join("not-a-skill");
        fs::create_dir_all(&no_skill).unwrap();
        fs::write(no_skill.join("README.md"), "just a readme").unwrap();

        let (skills, _) = SkillLoader::discover(&[tmp.path().to_path_buf()]);
        assert!(skills.is_empty());
    }

    #[test]
    fn discover_warns_on_broken_skill_md() {
        let tmp = tempfile::tempdir().unwrap();
        let broken = tmp.path().join("broken");
        fs::create_dir_all(&broken).unwrap();
        fs::write(broken.join("SKILL.md"), "no frontmatter here").unwrap();

        let (skills, warnings) = SkillLoader::discover(&[tmp.path().to_path_buf()]);
        assert!(skills.is_empty());
        assert!(!warnings.is_empty());
        assert!(warnings[0].contains("broken"));
    }

    #[test]
    fn default_dirs_contains_home_claude_skills() {
        let dirs = SkillLoader::default_dirs();
        assert!(!dirs.is_empty());
        // At least one path should end with .claude/skills
        assert!(dirs
            .iter()
            .any(|d| d.to_string_lossy().contains(".claude/skills")));
    }

    #[test]
    fn project_dirs_contains_stellar_build() {
        let dirs = SkillLoader::project_dirs(Path::new("/workspace"));
        assert!(dirs.contains(&PathBuf::from("/workspace/.stellar-build/skills")));
        assert!(dirs.contains(&PathBuf::from("/workspace/.procyon/skills")));
    }
}