Skip to main content

roboticus_agent/
skills.rs

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        // Recurse into immediate subdirectories (e.g. learned/, custom/).
88        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    /// Load `.toml` and `.md` skill files from a single directory (non-recursive).
116    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    // ── Coverage for LoadedSkill accessor methods ─────────────────
362
363    #[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    // ── Coverage for SkillRegistry::default ──────────────────────
426
427    #[test]
428    fn skill_registry_default() {
429        let registry = SkillRegistry::default();
430        assert!(registry.match_skills(&["anything"]).is_empty());
431    }
432
433    // ── Coverage for SkillLoader with nonexistent dir ─────────────
434
435    #[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    // ── Coverage for SkillLoader ignores other file extensions ────
443
444    #[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    // ── Coverage for parse_instruction_md error paths ─────────────
454
455    #[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); // default_priority()
481        assert_eq!(skill.name, "test_skill");
482        assert!(skill.body.contains("Body content"));
483    }
484
485    // ── Coverage for subdirectory loading ─────────────────────────
486
487    #[test]
488    fn skill_loader_recurses_into_subdirectories() {
489        let dir = tempfile::tempdir().unwrap();
490
491        // Top-level skill
492        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        // Subdirectory skill (simulates learned/)
496        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        // Should NOT find the deeply nested skill
519        assert!(skills.is_empty());
520    }
521
522    // ── Coverage for case-insensitive keyword matching ────────────
523
524    #[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}