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}