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