1use std::path::{Path, PathBuf};
2
3use sha2::{Digest, Sha256};
4
5use roboticus_core::{InstructionSkill, Result, RoboticusError, SkillManifest, SkillTrigger};
6
7#[derive(Debug, Clone)]
8pub enum LoadedSkill {
9 Structured(SkillManifest, String, PathBuf),
10 Instruction(InstructionSkill, String, PathBuf),
11}
12
13impl LoadedSkill {
14 pub fn name(&self) -> &str {
15 match self {
16 LoadedSkill::Structured(m, _, _) => &m.name,
17 LoadedSkill::Instruction(i, _, _) => &i.name,
18 }
19 }
20
21 pub fn triggers(&self) -> &SkillTrigger {
22 match self {
23 LoadedSkill::Structured(m, _, _) => &m.triggers,
24 LoadedSkill::Instruction(i, _, _) => &i.triggers,
25 }
26 }
27
28 pub fn hash(&self) -> &str {
29 match self {
30 LoadedSkill::Structured(_, h, _) | LoadedSkill::Instruction(_, h, _) => h,
31 }
32 }
33
34 pub fn source_path(&self) -> &Path {
35 match self {
36 LoadedSkill::Structured(_, _, p) | LoadedSkill::Instruction(_, _, p) => p.as_path(),
37 }
38 }
39
40 pub fn structured_manifest(&self) -> Option<&SkillManifest> {
41 match self {
42 LoadedSkill::Structured(m, _, _) => Some(m),
43 LoadedSkill::Instruction(_, _, _) => None,
44 }
45 }
46
47 pub fn description(&self) -> Option<&str> {
48 match self {
49 LoadedSkill::Structured(m, _, _) => Some(&m.description),
50 LoadedSkill::Instruction(i, _, _) => Some(&i.description),
51 }
52 }
53
54 pub fn version(&self) -> &str {
55 match self {
56 LoadedSkill::Structured(m, _, _) => &m.version,
57 LoadedSkill::Instruction(i, _, _) => &i.version,
58 }
59 }
60
61 pub fn author(&self) -> &str {
62 match self {
63 LoadedSkill::Structured(m, _, _) => &m.author,
64 LoadedSkill::Instruction(i, _, _) => &i.author,
65 }
66 }
67}
68
69fn content_hash(data: &[u8]) -> String {
70 let mut hasher = Sha256::new();
71 hasher.update(data);
72 format!("{:x}", hasher.finalize())
73}
74
75pub struct SkillLoader;
76
77impl SkillLoader {
78 pub fn load_from_dir(dir: &Path) -> Result<Vec<LoadedSkill>> {
79 let mut skills = Vec::new();
80
81 if !dir.exists() {
82 return Ok(skills);
83 }
84
85 Self::load_entries(dir, &mut skills)?;
86
87 match std::fs::read_dir(dir) {
89 Ok(entries) => {
90 for entry in entries.flatten() {
91 let path = entry.path();
92 if path.is_dir()
93 && let Err(e) = Self::load_entries(&path, &mut skills)
94 {
95 tracing::warn!(
96 dir = %path.display(),
97 error = %e,
98 "failed to load skills from subdirectory, skipping"
99 );
100 }
101 }
102 }
103 Err(e) => {
104 tracing::warn!(
105 dir = %dir.display(),
106 error = %e,
107 "failed to enumerate skill subdirectories"
108 );
109 }
110 }
111
112 Ok(skills)
113 }
114
115 fn load_entries(dir: &Path, skills: &mut Vec<LoadedSkill>) -> Result<()> {
117 let entries = std::fs::read_dir(dir)?;
118
119 for entry in entries {
120 let entry = entry?;
121 let path = entry.path();
122
123 if path.is_file() {
124 match path.extension().and_then(|e| e.to_str()) {
125 Some("toml") => {
126 let raw = std::fs::read_to_string(&path)?;
127 let hash = content_hash(raw.as_bytes());
128 let manifest: SkillManifest = toml::from_str(&raw).map_err(|e| {
129 RoboticusError::Skill(format!(
130 "failed to parse {}: {e}",
131 path.display()
132 ))
133 })?;
134 skills.push(LoadedSkill::Structured(manifest, hash, path.clone()));
135 }
136 Some("md") => {
137 let raw = std::fs::read_to_string(&path)?;
138 let hash = content_hash(raw.as_bytes());
139 let skill = parse_instruction_md(&raw, &path)?;
140 skills.push(LoadedSkill::Instruction(skill, hash, path.clone()));
141 }
142 _ => {}
143 }
144 }
145 }
146
147 Ok(())
148 }
149}
150
151fn parse_instruction_md(content: &str, path: &Path) -> Result<InstructionSkill> {
152 let trimmed = content.trim();
153
154 if !trimmed.starts_with("---") {
155 return Err(RoboticusError::Skill(format!(
156 "no YAML frontmatter in {}",
157 path.display()
158 )));
159 }
160
161 let rest = &trimmed[3..];
162 let end = rest.find("---").ok_or_else(|| {
163 RoboticusError::Skill(format!("unclosed YAML frontmatter in {}", path.display()))
164 })?;
165
166 let yaml_str = &rest[..end];
167 let body = rest[end + 3..].trim().to_string();
168
169 #[derive(serde::Deserialize)]
170 struct FrontMatter {
171 name: String,
172 description: String,
173 #[serde(default)]
174 triggers: SkillTrigger,
175 #[serde(default = "default_priority")]
176 priority: u32,
177 #[serde(default)]
178 version: Option<String>,
179 #[serde(default)]
180 author: Option<String>,
181 }
182
183 fn default_priority() -> u32 {
184 5
185 }
186
187 let fm: FrontMatter = serde_yaml::from_str(yaml_str).map_err(|e| {
188 RoboticusError::Skill(format!(
189 "invalid YAML frontmatter in {}: {e}",
190 path.display()
191 ))
192 })?;
193
194 Ok(InstructionSkill {
195 name: fm.name,
196 description: fm.description,
197 triggers: fm.triggers,
198 priority: fm.priority,
199 body,
200 version: fm.version.unwrap_or_else(|| "0.0.0".into()),
201 author: fm.author.unwrap_or_else(|| "local".into()),
202 })
203}
204
205pub struct SkillRegistry {
206 skills: Vec<LoadedSkill>,
207}
208
209impl SkillRegistry {
210 pub fn new() -> Self {
211 Self { skills: Vec::new() }
212 }
213
214 pub fn register(&mut self, skill: LoadedSkill) {
215 self.skills.push(skill);
216 }
217
218 pub fn match_skills(&self, keywords: &[&str]) -> Vec<&LoadedSkill> {
219 self.skills
220 .iter()
221 .filter(|skill| {
222 let triggers = skill.triggers();
223 keywords.iter().any(|kw| {
224 let kw_lower = kw.to_lowercase();
225 triggers
226 .keywords
227 .iter()
228 .any(|t| t.to_lowercase().contains(&kw_lower))
229 })
230 })
231 .collect()
232 }
233}
234
235impl Default for SkillRegistry {
236 fn default() -> Self {
237 Self::new()
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use std::fs;
245
246 #[test]
247 fn parse_toml_skill_manifest() {
248 let dir = tempfile::tempdir().unwrap();
249 let toml_content = r#"
250name = "code_review"
251description = "Reviews code for quality"
252kind = "Structured"
253priority = 3
254risk_level = "Safe"
255
256[triggers]
257keywords = ["review", "code"]
258tool_names = []
259regex_patterns = []
260"#;
261 fs::write(dir.path().join("code_review.toml"), toml_content).unwrap();
262
263 let skills = SkillLoader::load_from_dir(dir.path()).unwrap();
264 assert_eq!(skills.len(), 1);
265
266 match &skills[0] {
267 LoadedSkill::Structured(manifest, hash, _) => {
268 assert_eq!(manifest.name, "code_review");
269 assert_eq!(manifest.priority, 3);
270 assert!(!hash.is_empty());
271 }
272 _ => panic!("expected Structured skill"),
273 }
274 }
275
276 #[test]
277 fn parse_md_instruction_skill() {
278 let dir = tempfile::tempdir().unwrap();
279 let md_content = r#"---
280name: greeting
281description: Greets the user warmly
282triggers:
283 keywords:
284 - hello
285 - greet
286priority: 2
287---
288Always greet the user with enthusiasm and warmth.
289"#;
290 fs::write(dir.path().join("greeting.md"), md_content).unwrap();
291
292 let skills = SkillLoader::load_from_dir(dir.path()).unwrap();
293 assert_eq!(skills.len(), 1);
294
295 match &skills[0] {
296 LoadedSkill::Instruction(skill, hash, _) => {
297 assert_eq!(skill.name, "greeting");
298 assert_eq!(skill.priority, 2);
299 assert!(skill.body.contains("enthusiasm"));
300 assert!(!hash.is_empty());
301 }
302 _ => panic!("expected Instruction skill"),
303 }
304 }
305
306 #[test]
307 fn trigger_matching() {
308 let mut registry = SkillRegistry::new();
309
310 let skill_a = LoadedSkill::Instruction(
311 InstructionSkill {
312 name: "code_review".into(),
313 description: "Reviews code".into(),
314 triggers: SkillTrigger {
315 keywords: vec!["review".into(), "code".into()],
316 tool_names: vec![],
317 regex_patterns: vec![],
318 },
319 priority: 5,
320 body: "Review the code.".into(),
321 version: "0.0.0".into(),
322 author: "local".into(),
323 },
324 "hash_a".into(),
325 PathBuf::from("/tmp/hash_a"),
326 );
327
328 let skill_b = LoadedSkill::Instruction(
329 InstructionSkill {
330 name: "deploy".into(),
331 description: "Deploys services".into(),
332 triggers: SkillTrigger {
333 keywords: vec!["deploy".into(), "release".into()],
334 tool_names: vec![],
335 regex_patterns: vec![],
336 },
337 priority: 5,
338 body: "Deploy the service.".into(),
339 version: "0.0.0".into(),
340 author: "local".into(),
341 },
342 "hash_b".into(),
343 PathBuf::from("/tmp/hash_b"),
344 );
345
346 registry.register(skill_a);
347 registry.register(skill_b);
348
349 let matches = registry.match_skills(&["review"]);
350 assert_eq!(matches.len(), 1);
351 assert_eq!(matches[0].name(), "code_review");
352
353 let matches = registry.match_skills(&["deploy"]);
354 assert_eq!(matches.len(), 1);
355 assert_eq!(matches[0].name(), "deploy");
356
357 let matches = registry.match_skills(&["unrelated"]);
358 assert!(matches.is_empty());
359 }
360
361 #[test]
364 fn loaded_skill_structured_accessors() {
365 let manifest = SkillManifest {
366 name: "code_review".into(),
367 description: "Reviews code".into(),
368 kind: roboticus_core::SkillKind::Structured,
369 priority: 3,
370 risk_level: roboticus_core::RiskLevel::Safe,
371 triggers: SkillTrigger {
372 keywords: vec!["review".into()],
373 tool_names: vec![],
374 regex_patterns: vec![],
375 },
376 tool_chain: None,
377 policy_overrides: None,
378 script_path: None,
379 version: "1.0.0".into(),
380 author: "tester".into(),
381 };
382 let skill = LoadedSkill::Structured(
383 manifest.clone(),
384 "abc123".into(),
385 PathBuf::from("/tmp/test.toml"),
386 );
387
388 assert_eq!(skill.name(), "code_review");
389 assert_eq!(skill.hash(), "abc123");
390 assert_eq!(skill.source_path(), Path::new("/tmp/test.toml"));
391 assert_eq!(skill.description(), Some("Reviews code"));
392 assert!(skill.structured_manifest().is_some());
393 assert_eq!(skill.structured_manifest().unwrap().name, "code_review");
394 let triggers = skill.triggers();
395 assert!(triggers.keywords.contains(&"review".to_string()));
396 }
397
398 #[test]
399 fn loaded_skill_instruction_accessors() {
400 let instr = InstructionSkill {
401 name: "greeting".into(),
402 description: "Greets user".into(),
403 triggers: SkillTrigger {
404 keywords: vec!["hello".into()],
405 tool_names: vec![],
406 regex_patterns: vec![],
407 },
408 priority: 5,
409 body: "Greet warmly.".into(),
410 version: "0.0.0".into(),
411 author: "local".into(),
412 };
413 let skill =
414 LoadedSkill::Instruction(instr, "def456".into(), PathBuf::from("/tmp/greet.md"));
415
416 assert_eq!(skill.name(), "greeting");
417 assert_eq!(skill.hash(), "def456");
418 assert_eq!(skill.source_path(), Path::new("/tmp/greet.md"));
419 assert_eq!(skill.description(), Some("Greets user"));
420 assert!(skill.structured_manifest().is_none());
421 let triggers = skill.triggers();
422 assert!(triggers.keywords.contains(&"hello".to_string()));
423 }
424
425 #[test]
428 fn skill_registry_default() {
429 let registry = SkillRegistry::default();
430 assert!(registry.match_skills(&["anything"]).is_empty());
431 }
432
433 #[test]
436 fn skill_loader_nonexistent_dir() {
437 let result = SkillLoader::load_from_dir(Path::new("/nonexistent/skills/dir"));
438 assert!(result.is_ok());
439 assert!(result.unwrap().is_empty());
440 }
441
442 #[test]
445 fn skill_loader_ignores_unknown_extensions() {
446 let dir = tempfile::tempdir().unwrap();
447 fs::write(dir.path().join("readme.txt"), "just text").unwrap();
448 fs::write(dir.path().join("config.json"), "{}").unwrap();
449 let skills = SkillLoader::load_from_dir(dir.path()).unwrap();
450 assert!(skills.is_empty());
451 }
452
453 #[test]
456 fn parse_instruction_md_no_frontmatter() {
457 let content = "This is just plain text without frontmatter.";
458 let result = parse_instruction_md(content, Path::new("test.md"));
459 assert!(result.is_err());
460 }
461
462 #[test]
463 fn parse_instruction_md_unclosed_frontmatter() {
464 let content = "---\nname: test\n";
465 let result = parse_instruction_md(content, Path::new("test.md"));
466 assert!(result.is_err());
467 }
468
469 #[test]
470 fn parse_instruction_md_invalid_yaml() {
471 let content = "---\ninvalid: [unclosed\n---\nBody here.";
472 let result = parse_instruction_md(content, Path::new("test.md"));
473 assert!(result.is_err());
474 }
475
476 #[test]
477 fn parse_instruction_md_default_priority() {
478 let content = "---\nname: test_skill\ndescription: A test\n---\nBody content here.";
479 let skill = parse_instruction_md(content, Path::new("test.md")).unwrap();
480 assert_eq!(skill.priority, 5); assert_eq!(skill.name, "test_skill");
482 assert!(skill.body.contains("Body content"));
483 }
484
485 #[test]
488 fn skill_loader_recurses_into_subdirectories() {
489 let dir = tempfile::tempdir().unwrap();
490
491 let top_md = "---\nname: top_skill\ndescription: Top-level\n---\nTop body.";
493 fs::write(dir.path().join("top.md"), top_md).unwrap();
494
495 let sub_dir = dir.path().join("learned");
497 fs::create_dir(&sub_dir).unwrap();
498 let sub_md = "---\nname: learned_skill\ndescription: Auto-learned\n---\nLearned body.";
499 fs::write(sub_dir.join("auto.md"), sub_md).unwrap();
500
501 let skills = SkillLoader::load_from_dir(dir.path()).unwrap();
502 assert_eq!(skills.len(), 2);
503
504 let names: Vec<&str> = skills.iter().map(|s| s.name()).collect();
505 assert!(names.contains(&"top_skill"));
506 assert!(names.contains(&"learned_skill"));
507 }
508
509 #[test]
510 fn skill_loader_does_not_recurse_deeper_than_one_level() {
511 let dir = tempfile::tempdir().unwrap();
512 let nested = dir.path().join("learned").join("nested");
513 fs::create_dir_all(&nested).unwrap();
514 let deep_md = "---\nname: deep_skill\ndescription: Too deep\n---\nDeep body.";
515 fs::write(nested.join("deep.md"), deep_md).unwrap();
516
517 let skills = SkillLoader::load_from_dir(dir.path()).unwrap();
518 assert!(skills.is_empty());
520 }
521
522 #[test]
525 fn trigger_matching_case_insensitive() {
526 let mut registry = SkillRegistry::new();
527 let skill = LoadedSkill::Instruction(
528 InstructionSkill {
529 name: "test".into(),
530 description: "Test".into(),
531 triggers: SkillTrigger {
532 keywords: vec!["Review".into()],
533 tool_names: vec![],
534 regex_patterns: vec![],
535 },
536 priority: 5,
537 body: "test".into(),
538 version: "0.0.0".into(),
539 author: "local".into(),
540 },
541 "h".into(),
542 PathBuf::from("/tmp/t"),
543 );
544 registry.register(skill);
545
546 let matches = registry.match_skills(&["REVIEW"]);
547 assert_eq!(matches.len(), 1);
548
549 let matches = registry.match_skills(&["review"]);
550 assert_eq!(matches.len(), 1);
551 }
552}