1use std::collections::HashMap;
5use std::io;
6use std::path::{Path, PathBuf};
7
8use crate::skill::{Skill, SkillFrontmatter};
9
10const SKILL_FILE_NAME: &str = "SKILL.md";
12
13#[derive(Debug)]
19pub enum SkillLoadError {
20 IoError { path: PathBuf, source: io::Error },
21 NoFrontmatter { path: PathBuf },
22 NoClosingDelimiter { path: PathBuf },
23 YamlParseError { path: PathBuf, source: serde_yaml::Error },
24 MissingSkillFile { dir: PathBuf },
25}
26
27impl std::fmt::Display for SkillLoadError {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 match self {
30 SkillLoadError::IoError { path, source } => {
31 write!(f, "Failed to read file {}: {}", path.display(), source)
32 }
33 SkillLoadError::NoFrontmatter { path } => {
34 write!(f, "File {} is missing YAML frontmatter (must start with ---)", path.display())
35 }
36 SkillLoadError::NoClosingDelimiter { path } => {
37 write!(f, "File {} is missing closing --- delimiter", path.display())
38 }
39 SkillLoadError::YamlParseError { path, source } => {
40 write!(f, "Failed to parse YAML in file {}: {}", path.display(), source)
41 }
42 SkillLoadError::MissingSkillFile { dir } => {
43 write!(
44 f,
45 "Skill directory {} does not contain a {} file",
46 dir.display(),
47 SKILL_FILE_NAME
48 )
49 }
50 }
51 }
52}
53
54pub fn parse_skill_file(path: &Path) -> Result<Skill, SkillLoadError> {
70 let content = std::fs::read_to_string(path)
71 .map_err(|e| SkillLoadError::IoError { path: path.to_path_buf(), source: e })?;
72
73 let rest = content
75 .strip_prefix("---")
76 .ok_or_else(|| SkillLoadError::NoFrontmatter { path: path.to_path_buf() })?;
77
78 let rest = rest.strip_prefix('\r').unwrap_or(rest);
80 let rest = rest.strip_prefix('\n').unwrap_or(rest);
81
82 let closing_pos = find_closing_delimiter(rest)
84 .ok_or_else(|| SkillLoadError::NoClosingDelimiter { path: path.to_path_buf() })?;
85
86 let yaml_text = &rest[..closing_pos];
87 let markdown_body = &rest[closing_pos + 3..];
89 let markdown_body = markdown_body.strip_prefix('\r').unwrap_or(markdown_body);
90 let markdown_body = markdown_body.strip_prefix('\n').unwrap_or(markdown_body);
91
92 let frontmatter: SkillFrontmatter = serde_yaml::from_str(yaml_text.trim())
93 .map_err(|e| SkillLoadError::YamlParseError { path: path.to_path_buf(), source: e })?;
94
95 Ok(Skill {
96 frontmatter,
97 content: markdown_body.to_string(),
98 source_path: path.to_path_buf(),
99 })
100}
101
102fn find_closing_delimiter(text: &str) -> Option<usize> {
104 let mut search_start = 0;
105 while let Some(pos) = text[search_start..].find('\n') {
106 let abs_pos = search_start + pos;
107 if let Some(line_start) = text.get(abs_pos + 1..) {
108 let line = line_start.lines().next().unwrap_or("");
109 if line.trim_end() == "---" {
110 return Some(abs_pos + 1);
111 }
112 }
113 search_start = abs_pos + 1;
114 if search_start >= text.len() {
115 break;
116 }
117 }
118 if text.starts_with("---") {
119 return Some(0);
120 }
121 None
122}
123
124pub fn load_skills(
137 global_dir: Option<PathBuf>,
138 project_dir: Option<PathBuf>,
139) -> (Vec<Skill>, Vec<SkillLoadError>) {
140 let mut skills_by_name: HashMap<String, Skill> = HashMap::new();
141 let mut errors: Vec<SkillLoadError> = Vec::new();
142
143 if let Some(dir) = global_dir {
145 load_from_directory(&dir, &mut skills_by_name, &mut errors);
146 }
147
148 if let Some(dir) = project_dir {
150 load_from_directory(&dir, &mut skills_by_name, &mut errors);
151 }
152
153 let skills: Vec<Skill> = skills_by_name
155 .into_values()
156 .filter(|s| s.frontmatter.enabled)
157 .collect();
158
159 (skills, errors)
160}
161
162fn load_from_directory(
167 dir: &Path,
168 skills_by_name: &mut HashMap<String, Skill>,
169 errors: &mut Vec<SkillLoadError>,
170) {
171 if !dir.is_dir() {
172 return;
173 }
174
175 let entries = match std::fs::read_dir(dir) {
176 Ok(e) => e,
177 Err(e) => {
178 errors.push(SkillLoadError::IoError {
179 path: dir.to_path_buf(),
180 source: e,
181 });
182 return;
183 }
184 };
185
186 let mut dirs: Vec<PathBuf> = entries
188 .filter_map(|e| e.ok())
189 .map(|e| e.path())
190 .filter(|p| p.is_dir())
191 .collect();
192 dirs.sort();
193
194 for skill_dir in &dirs {
195 let skill_file = skill_dir.join(SKILL_FILE_NAME);
196 if !skill_file.exists() {
197 errors.push(SkillLoadError::MissingSkillFile {
198 dir: skill_dir.clone(),
199 });
200 continue;
201 }
202
203 match parse_skill_file(&skill_file) {
204 Ok(skill) => {
205 if skills_by_name.contains_key(&skill.frontmatter.name) {
206 tracing::info!(
207 "Skill '{}' ({}) overridden by {}",
208 skill.frontmatter.name,
209 skills_by_name[&skill.frontmatter.name]
210 .source_path
211 .display(),
212 skill_file.display()
213 );
214 }
215 skills_by_name.insert(skill.frontmatter.name.clone(), skill);
216 }
217 Err(e) => {
218 errors.push(e);
219 }
220 }
221 }
222}
223
224#[cfg(test)]
229mod tests {
230 use super::*;
231 use std::io::Write;
232
233 fn write_skill_dir(parent: &Path, dir_name: &str, content: &str) -> PathBuf {
235 let skill_dir = parent.join(dir_name);
236 std::fs::create_dir_all(&skill_dir).unwrap();
237 let mut f = std::fs::File::create(skill_dir.join(SKILL_FILE_NAME)).unwrap();
238 f.write_all(content.as_bytes()).unwrap();
239 skill_dir
240 }
241
242 #[test]
243 fn test_parse_valid_skill_file() {
244 let content = r#"---
245name: test-skill
246description: A test skill
247version: 2.0.0
248triggers:
249 - /test
250enabled: true
251---
252
253# Test Skill
254
255This is the body content."#;
256
257 let temp_dir = std::env::temp_dir().join("robit_test_skills");
258 std::fs::create_dir_all(&temp_dir).unwrap();
259 let skill_dir = write_skill_dir(&temp_dir, "test", content);
260 let skill = parse_skill_file(&skill_dir.join(SKILL_FILE_NAME)).unwrap();
261
262 assert_eq!(skill.frontmatter.name, "test-skill");
263 assert_eq!(skill.frontmatter.description, "A test skill");
264 assert_eq!(skill.frontmatter.version, "2.0.0");
265 assert_eq!(skill.frontmatter.triggers, vec!["/test"]);
266 assert!(skill.frontmatter.enabled);
267 assert!(skill.content.contains("# Test Skill"));
268 assert!(skill.content.contains("body content"));
269
270 std::fs::remove_dir_all(&temp_dir).ok();
271 }
272
273 #[test]
274 fn test_parse_minimal_frontmatter() {
275 let content = "---\nname: min\ndescription: minimal\n---\n\nBody";
276
277 let temp_dir = std::env::temp_dir().join("robit_test_min");
278 std::fs::create_dir_all(&temp_dir).unwrap();
279 let skill_dir = write_skill_dir(&temp_dir, "min", content);
280 let skill = parse_skill_file(&skill_dir.join(SKILL_FILE_NAME)).unwrap();
281
282 assert_eq!(skill.frontmatter.name, "min");
283 assert_eq!(skill.frontmatter.description, "minimal");
284 assert_eq!(skill.frontmatter.version, "1.0.0"); assert!(skill.frontmatter.triggers.is_empty()); assert!(skill.frontmatter.enabled); assert_eq!(skill.content, "\nBody");
288
289 std::fs::remove_dir_all(&temp_dir).ok();
290 }
291
292 #[test]
293 fn test_parse_no_frontmatter() {
294 let content = "# Just markdown, no frontmatter";
295
296 let temp_dir = std::env::temp_dir().join("robit_test_nofm");
297 std::fs::create_dir_all(&temp_dir).unwrap();
298 let skill_dir = write_skill_dir(&temp_dir, "nofm", content);
299 let result = parse_skill_file(&skill_dir.join(SKILL_FILE_NAME));
300 assert!(matches!(result, Err(SkillLoadError::NoFrontmatter { .. })));
301
302 std::fs::remove_dir_all(&temp_dir).ok();
303 }
304
305 #[test]
306 fn test_parse_no_closing_delimiter() {
307 let content = "---\nname: broken\n";
308
309 let temp_dir = std::env::temp_dir().join("robit_test_nocl");
310 std::fs::create_dir_all(&temp_dir).unwrap();
311 let skill_dir = write_skill_dir(&temp_dir, "nocl", content);
312 let result = parse_skill_file(&skill_dir.join(SKILL_FILE_NAME));
313 assert!(matches!(result, Err(SkillLoadError::NoClosingDelimiter { .. })));
314
315 std::fs::remove_dir_all(&temp_dir).ok();
316 }
317
318 #[test]
319 fn test_parse_invalid_yaml() {
320 let content = "---\nname: [broken yaml\n---\n\nBody";
321
322 let temp_dir = std::env::temp_dir().join("robit_test_badym");
323 std::fs::create_dir_all(&temp_dir).unwrap();
324 let skill_dir = write_skill_dir(&temp_dir, "badym", content);
325 let result = parse_skill_file(&skill_dir.join(SKILL_FILE_NAME));
326 assert!(matches!(result, Err(SkillLoadError::YamlParseError { .. })));
327
328 std::fs::remove_dir_all(&temp_dir).ok();
329 }
330
331 #[test]
332 fn test_load_skills_with_priority() {
333 let temp = std::env::temp_dir().join("robit_test_priority");
334 let global_dir = temp.join("global");
335 let project_dir = temp.join("project");
336 std::fs::create_dir_all(&global_dir).unwrap();
337 std::fs::create_dir_all(&project_dir).unwrap();
338
339 write_skill_dir(
341 &global_dir,
342 "shared",
343 "---\nname: shared\ndescription: global version\n---\n\nglobal body",
344 );
345 write_skill_dir(
347 &global_dir,
348 "only-global",
349 "---\nname: only-global\ndescription: only in global\n---\n\nonly body",
350 );
351 write_skill_dir(
353 &project_dir,
354 "shared",
355 "---\nname: shared\ndescription: project version\n---\n\nproject body",
356 );
357
358 let (skills, errors) = load_skills(Some(global_dir), Some(project_dir));
359 assert!(errors.is_empty());
360 assert_eq!(skills.len(), 2);
361
362 let shared = skills.iter().find(|s| s.frontmatter.name == "shared").unwrap();
363 assert_eq!(shared.frontmatter.description, "project version");
364 assert!(shared.content.contains("project body"));
365
366 let only_global = skills
367 .iter()
368 .find(|s| s.frontmatter.name == "only-global")
369 .unwrap();
370 assert_eq!(only_global.frontmatter.description, "only in global");
371
372 std::fs::remove_dir_all(temp).ok();
373 }
374
375 #[test]
376 fn test_load_skills_filters_disabled() {
377 let temp = std::env::temp_dir().join("robit_test_disabled");
378 std::fs::create_dir_all(&temp).unwrap();
379
380 write_skill_dir(
381 &temp,
382 "disabled",
383 "---\nname: disabled\ndescription: should be filtered\nenabled: false\n---\n\nbody",
384 );
385 write_skill_dir(
386 &temp,
387 "enabled",
388 "---\nname: enabled\ndescription: should be kept\n---\n\nbody",
389 );
390
391 let (skills, errors) = load_skills(Some(temp.clone()), None);
392 assert!(errors.is_empty());
393 assert_eq!(skills.len(), 1);
394 assert_eq!(skills[0].frontmatter.name, "enabled");
395
396 std::fs::remove_dir_all(temp).ok();
397 }
398
399 #[test]
400 fn test_load_skills_missing_file_warned() {
401 let temp = std::env::temp_dir().join("robit_test_miss");
402 std::fs::create_dir_all(&temp).unwrap();
403
404 std::fs::create_dir_all(temp.join("no-file")).unwrap();
406 write_skill_dir(
408 &temp,
409 "valid",
410 "---\nname: valid\ndescription: ok\n---\n\nbody",
411 );
412
413 let (skills, errors) = load_skills(Some(temp.clone()), None);
414 assert_eq!(skills.len(), 1);
415 assert_eq!(skills[0].frontmatter.name, "valid");
416 assert!(errors.iter().any(|e| matches!(e, SkillLoadError::MissingSkillFile { .. })));
418
419 std::fs::remove_dir_all(temp).ok();
420 }
421}