Skip to main content

adk_skill/
index.rs

1use crate::discovery::{discover_instruction_files, discover_instruction_files_with_extras};
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, PathBuf};
8use std::time::UNIX_EPOCH;
9
10/// Loads a [`SkillIndex`] by discovering and parsing all instruction files under `root`.
11///
12/// Each file is read, parsed, and assigned a content-hash-based identifier.
13/// The resulting index is sorted by skill name and path.
14pub fn load_skill_index(root: impl AsRef<Path>) -> SkillResult<SkillIndex> {
15    let mut skills = Vec::new();
16    for path in discover_instruction_files(root)? {
17        let content = match fs::read_to_string(&path) {
18            Ok(c) => c,
19            Err(_) => continue,
20        };
21        // Skip files that don't have valid skill/instruction format.
22        // This allows non-skill .md files (reference docs, READMEs, etc.)
23        // to coexist under .skills/ without causing parse errors.
24        let parsed = match parse_instruction_markdown(&path, &content) {
25            Ok(p) => p,
26            Err(_) => continue,
27        };
28
29        let mut hasher = Sha256::new();
30        hasher.update(content.as_bytes());
31        let hash = format!("{:x}", hasher.finalize());
32
33        let last_modified = fs::metadata(&path)
34            .ok()
35            .and_then(|meta| meta.modified().ok())
36            .and_then(|ts| ts.duration_since(UNIX_EPOCH).ok())
37            .map(|d| d.as_secs() as i64);
38
39        let id = format!(
40            "{}-{}",
41            normalize_id(&parsed.name),
42            &hash.chars().take(12).collect::<String>()
43        );
44
45        skills.push(SkillDocument {
46            id,
47            name: parsed.name,
48            description: parsed.description,
49            version: parsed.version,
50            license: parsed.license,
51            compatibility: parsed.compatibility,
52            tags: parsed.tags,
53            allowed_tools: parsed.allowed_tools,
54            references: parsed.references,
55            trigger: parsed.trigger,
56            hint: parsed.hint,
57            metadata: parsed.metadata,
58            body: parsed.body,
59            path,
60            hash,
61            last_modified,
62            triggers: parsed.triggers,
63        });
64    }
65
66    skills.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.path.cmp(&b.path)));
67    Ok(SkillIndex::new(skills))
68}
69
70/// Loads a [`SkillIndex`] by discovering and parsing all instruction files under `root`,
71/// plus any additional directories in `extra_dirs`.
72///
73/// Merges project-local instruction files with files from the provided extra directories.
74/// Non-existent or non-directory extra paths are silently skipped.
75/// Each file is read, parsed, and assigned a content-hash-based identifier.
76/// The resulting index is sorted by skill name and path, then deduplicated by name.
77/// Project-local skills (`.skills/`, `.claude/skills/`) take precedence over global/extra
78/// paths because discovery lists project-local directories first, and deduplication
79/// keeps the first occurrence.
80pub fn load_skill_index_with_extras(
81    root: impl AsRef<Path>,
82    extra_dirs: &[PathBuf],
83) -> SkillResult<SkillIndex> {
84    let root = root.as_ref();
85    let mut skills = Vec::new();
86    for path in discover_instruction_files_with_extras(root, extra_dirs)? {
87        let content = match fs::read_to_string(&path) {
88            Ok(c) => c,
89            Err(_) => continue,
90        };
91        let parsed = match parse_instruction_markdown(&path, &content) {
92            Ok(p) => p,
93            Err(_) => continue,
94        };
95
96        let mut hasher = Sha256::new();
97        hasher.update(content.as_bytes());
98        let hash = format!("{:x}", hasher.finalize());
99
100        let last_modified = fs::metadata(&path)
101            .ok()
102            .and_then(|meta| meta.modified().ok())
103            .and_then(|ts| ts.duration_since(UNIX_EPOCH).ok())
104            .map(|d| d.as_secs() as i64);
105
106        let id = format!(
107            "{}-{}",
108            normalize_id(&parsed.name),
109            &hash.chars().take(12).collect::<String>()
110        );
111
112        skills.push(SkillDocument {
113            id,
114            name: parsed.name,
115            description: parsed.description,
116            version: parsed.version,
117            license: parsed.license,
118            compatibility: parsed.compatibility,
119            tags: parsed.tags,
120            allowed_tools: parsed.allowed_tools,
121            references: parsed.references,
122            trigger: parsed.trigger,
123            hint: parsed.hint,
124            metadata: parsed.metadata,
125            body: parsed.body,
126            path,
127            hash,
128            last_modified,
129            triggers: parsed.triggers,
130        });
131    }
132
133    // Deduplicate by name, preferring project-local skills (.skills/, .claude/skills/)
134    // over global/extra paths. We build a map keyed by name; project-local entries
135    // always win over non-local entries, and among entries of the same locality the
136    // first one encountered (lowest path order) wins.
137    let local_prefixes = [root.join(".skills"), root.join(".claude").join("skills")];
138    let is_project_local =
139        |path: &Path| local_prefixes.iter().any(|prefix| path.starts_with(prefix));
140
141    let mut by_name: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
142    let mut deduped: Vec<SkillDocument> = Vec::with_capacity(skills.len());
143
144    for skill in skills {
145        match by_name.get(&skill.name) {
146            Some(&idx) => {
147                // Replace only if the new skill is project-local and the existing one is not
148                if is_project_local(&skill.path) && !is_project_local(&deduped[idx].path) {
149                    deduped[idx] = skill;
150                }
151                // Otherwise keep the existing entry (first wins within same locality)
152            }
153            None => {
154                by_name.insert(skill.name.clone(), deduped.len());
155                deduped.push(skill);
156            }
157        }
158    }
159
160    deduped.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.path.cmp(&b.path)));
161    Ok(SkillIndex::new(deduped))
162}
163
164fn normalize_id(value: &str) -> String {
165    let mut out = String::new();
166    for c in value.chars() {
167        if c.is_ascii_alphanumeric() {
168            out.push(c.to_ascii_lowercase());
169        } else if c == ' ' || c == '-' || c == '_' {
170            out.push('-');
171        }
172    }
173    if out.is_empty() { "skill".to_string() } else { out }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use std::fs;
180
181    #[test]
182    fn loads_index_with_hash_and_summary_fields() {
183        let temp = tempfile::tempdir().unwrap();
184        let root = temp.path();
185        fs::create_dir_all(root.join(".skills")).unwrap();
186        fs::write(
187            root.join(".skills/search.md"),
188            "---\nname: search\ndescription: Search docs\n---\nUse rg first.",
189        )
190        .unwrap();
191
192        let index = load_skill_index(root).unwrap();
193        assert_eq!(index.len(), 1);
194        let skill = &index.skills()[0];
195        assert_eq!(skill.name, "search");
196        assert!(!skill.hash.is_empty());
197        assert!(skill.last_modified.is_some());
198    }
199
200    #[test]
201    fn loads_agents_md_as_skill_document() {
202        let temp = tempfile::tempdir().unwrap();
203        let root = temp.path();
204        fs::write(root.join("AGENTS.md"), "# Repo Instructions\nUse cargo test before commit.\n")
205            .unwrap();
206
207        let index = load_skill_index(root).unwrap();
208        assert_eq!(index.len(), 1);
209        let skill = &index.skills()[0];
210        assert_eq!(skill.name, "agents");
211        assert!(skill.tags.iter().any(|t| t == "agents-md"));
212        assert!(skill.body.contains("Use cargo test before commit."));
213    }
214
215    #[test]
216    fn skips_non_skill_md_files_in_subdirectories() {
217        // Reproduces issue #204: reference docs without frontmatter
218        // should be silently skipped, not cause InvalidFrontmatter errors
219        let temp = tempfile::tempdir().unwrap();
220        let root = temp.path();
221        fs::create_dir_all(root.join(".skills/my-skill/references")).unwrap();
222        fs::create_dir_all(root.join(".skills/my-skill/assets")).unwrap();
223
224        // Valid skill
225        fs::write(
226            root.join(".skills/my-skill/skill.md"),
227            "---\nname: my-skill\ndescription: A skill\n---\nBody",
228        )
229        .unwrap();
230
231        // Non-skill .md files (no frontmatter) — must not cause errors
232        fs::write(
233            root.join(".skills/my-skill/references/docs.md"),
234            "# Reference Documentation\nThis is supporting docs.",
235        )
236        .unwrap();
237        fs::write(root.join(".skills/my-skill/assets/notes.md"), "Just plain text notes.").unwrap();
238
239        // Also a random .md at skill level without frontmatter
240        fs::write(
241            root.join(".skills/my-skill/README.md"),
242            "# My Skill README\nNo frontmatter here.",
243        )
244        .unwrap();
245
246        let index = load_skill_index(root).unwrap();
247        // Only the valid skill.md should be indexed
248        assert_eq!(index.len(), 1);
249        assert_eq!(index.skills()[0].name, "my-skill");
250    }
251
252    #[test]
253    fn load_with_extras_deduplicates_by_name_preferring_project_local() {
254        let temp = tempfile::tempdir().unwrap();
255        let root = temp.path();
256        let extra = tempfile::tempdir().unwrap();
257
258        // Project-local skill in .skills/
259        fs::create_dir_all(root.join(".skills")).unwrap();
260        fs::write(
261            root.join(".skills/search.md"),
262            "---\nname: search\ndescription: Local search\n---\nLocal body.",
263        )
264        .unwrap();
265
266        // Same-named skill in extra dir (global)
267        fs::write(
268            extra.path().join("search.md"),
269            "---\nname: search\ndescription: Global search\n---\nGlobal body.",
270        )
271        .unwrap();
272
273        let index = load_skill_index_with_extras(root, &[extra.path().to_path_buf()]).unwrap();
274
275        // Only one "search" skill should remain
276        let search_skills: Vec<_> = index.skills().iter().filter(|s| s.name == "search").collect();
277        assert_eq!(search_skills.len(), 1);
278        // The project-local version wins
279        assert_eq!(search_skills[0].description, "Local search");
280        assert!(search_skills[0].path.starts_with(root));
281    }
282
283    #[test]
284    fn load_with_extras_keeps_distinct_names() {
285        let temp = tempfile::tempdir().unwrap();
286        let root = temp.path();
287        let extra = tempfile::tempdir().unwrap();
288
289        fs::create_dir_all(root.join(".skills")).unwrap();
290        fs::write(
291            root.join(".skills/alpha.md"),
292            "---\nname: alpha\ndescription: Alpha\n---\nAlpha body.",
293        )
294        .unwrap();
295
296        fs::write(
297            extra.path().join("beta.md"),
298            "---\nname: beta\ndescription: Beta\n---\nBeta body.",
299        )
300        .unwrap();
301
302        let index = load_skill_index_with_extras(root, &[extra.path().to_path_buf()]).unwrap();
303
304        assert_eq!(index.len(), 2);
305        assert!(index.find_by_name("alpha").is_some());
306        assert!(index.find_by_name("beta").is_some());
307    }
308
309    #[test]
310    fn loads_root_soul_md_as_skill_document() {
311        let temp = tempfile::tempdir().unwrap();
312        let root = temp.path();
313        fs::write(root.join("SOUL.MD"), "# Soul\nBias toward deterministic workflows.\n").unwrap();
314        fs::create_dir_all(root.join("pkg")).unwrap();
315        fs::write(root.join("pkg/SOUL.md"), "# Nested soul should not load\n").unwrap();
316
317        let index = load_skill_index(root).unwrap();
318        assert_eq!(index.len(), 1);
319        let skill = &index.skills()[0];
320        assert_eq!(skill.name, "soul");
321        assert!(skill.tags.iter().any(|t| t == "soul-md"));
322        assert!(skill.body.contains("deterministic workflows"));
323    }
324}