Skip to main content

adk_skill/
index.rs

1use crate::discovery::discover_instruction_files;
2use crate::error::SkillResult;
3use crate::model::{SkillDocument, SkillIndex};
4use crate::parser::parse_instruction_markdown;
5use sha2::{Digest, Sha256};
6use std::fs;
7use std::path::Path;
8use std::time::UNIX_EPOCH;
9
10pub fn load_skill_index(root: impl AsRef<Path>) -> SkillResult<SkillIndex> {
11    let mut skills = Vec::new();
12    for path in discover_instruction_files(root)? {
13        let content = fs::read_to_string(&path)?;
14        let parsed = parse_instruction_markdown(&path, &content)?;
15
16        let mut hasher = Sha256::new();
17        hasher.update(content.as_bytes());
18        let hash = format!("{:x}", hasher.finalize());
19
20        let last_modified = fs::metadata(&path)
21            .ok()
22            .and_then(|meta| meta.modified().ok())
23            .and_then(|ts| ts.duration_since(UNIX_EPOCH).ok())
24            .map(|d| d.as_secs() as i64);
25
26        let id = format!(
27            "{}-{}",
28            normalize_id(&parsed.name),
29            &hash.chars().take(12).collect::<String>()
30        );
31
32        skills.push(SkillDocument {
33            id,
34            name: parsed.name,
35            description: parsed.description,
36            tags: parsed.tags,
37            body: parsed.body,
38            path,
39            hash,
40            last_modified,
41        });
42    }
43
44    skills.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.path.cmp(&b.path)));
45    Ok(SkillIndex::new(skills))
46}
47
48fn normalize_id(value: &str) -> String {
49    let mut out = String::new();
50    for c in value.chars() {
51        if c.is_ascii_alphanumeric() {
52            out.push(c.to_ascii_lowercase());
53        } else if c == ' ' || c == '-' || c == '_' {
54            out.push('-');
55        }
56    }
57    if out.is_empty() { "skill".to_string() } else { out }
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63    use std::fs;
64
65    #[test]
66    fn loads_index_with_hash_and_summary_fields() {
67        let temp = tempfile::tempdir().unwrap();
68        let root = temp.path();
69        fs::create_dir_all(root.join(".skills")).unwrap();
70        fs::write(
71            root.join(".skills/search.md"),
72            "---\nname: search\ndescription: Search docs\n---\nUse rg first.",
73        )
74        .unwrap();
75
76        let index = load_skill_index(root).unwrap();
77        assert_eq!(index.len(), 1);
78        let skill = &index.skills()[0];
79        assert_eq!(skill.name, "search");
80        assert!(!skill.hash.is_empty());
81        assert!(skill.last_modified.is_some());
82    }
83
84    #[test]
85    fn loads_agents_md_as_skill_document() {
86        let temp = tempfile::tempdir().unwrap();
87        let root = temp.path();
88        fs::write(root.join("AGENTS.md"), "# Repo Instructions\nUse cargo test before commit.\n")
89            .unwrap();
90
91        let index = load_skill_index(root).unwrap();
92        assert_eq!(index.len(), 1);
93        let skill = &index.skills()[0];
94        assert_eq!(skill.name, "agents");
95        assert!(skill.tags.iter().any(|t| t == "agents-md"));
96        assert!(skill.body.contains("Use cargo test before commit."));
97    }
98
99    #[test]
100    fn loads_root_soul_md_as_skill_document() {
101        let temp = tempfile::tempdir().unwrap();
102        let root = temp.path();
103        fs::write(root.join("SOUL.MD"), "# Soul\nBias toward deterministic workflows.\n").unwrap();
104        fs::create_dir_all(root.join("pkg")).unwrap();
105        fs::write(root.join("pkg/SOUL.md"), "# Nested soul should not load\n").unwrap();
106
107        let index = load_skill_index(root).unwrap();
108        assert_eq!(index.len(), 1);
109        let skill = &index.skills()[0];
110        assert_eq!(skill.name, "soul");
111        assert!(skill.tags.iter().any(|t| t == "soul-md"));
112        assert!(skill.body.contains("deterministic workflows"));
113    }
114}