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
10pub(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
49pub 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 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
81pub 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 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 if is_project_local(&skill.path) && !is_project_local(&deduped[idx].path) {
132 deduped[idx] = skill;
133 }
134 }
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 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 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 fs::write(
214 root.join(".skills/my-skill/skill.md"),
215 "---\nname: my-skill\ndescription: A skill\n---\nBody",
216 )
217 .unwrap();
218
219 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 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 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 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 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 let search_skills: Vec<_> = index.skills().iter().filter(|s| s.name == "search").collect();
265 assert_eq!(search_skills.len(), 1);
266 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}