Skip to main content

robit_agent/skill/
registry.rs

1//! Skill registry — stores loaded skills and provides trigger matching.
2
3use std::collections::{HashMap, HashSet};
4
5use crate::skill::Skill;
6
7/// Registry of loaded skills with trigger command matching.
8pub struct SkillRegistry {
9    skills: HashMap<String, Skill>,
10    /// Maps trigger command (e.g. "/review") → skill name.
11    trigger_map: HashMap<String, String>,
12}
13
14impl SkillRegistry {
15    /// Create a registry from a list of skills.
16    ///
17    /// Builds an internal trigger map for fast lookup and checks
18    /// `tools_required` against the available tools, logging warnings for missing tools.
19    pub fn new(skills: Vec<Skill>, available_tools: &[&str]) -> Self {
20        let mut skill_map = HashMap::new();
21        let mut trigger_map = HashMap::new();
22        let tool_set: HashSet<&str> = available_tools.iter().copied().collect();
23
24        for skill in skills {
25            // Warn about missing required tools
26            for required in &skill.frontmatter.tools_required {
27                if !tool_set.contains(required.as_str()) {
28                    tracing::warn!(
29                        "Skill '{}' requires tool '{}' but that tool is not enabled",
30                        skill.frontmatter.name,
31                        required
32                    );
33                }
34            }
35
36            // Build trigger map — warn on duplicate triggers
37            for trigger in &skill.frontmatter.triggers {
38                if let Some(existing_name) = trigger_map.get(trigger) {
39                    tracing::warn!(
40                        "Skill trigger '{}' is already used by skill '{}', now being overwritten by '{}'",
41                        trigger,
42                        existing_name,
43                        skill.frontmatter.name
44                    );
45                }
46                trigger_map.insert(trigger.clone(), skill.frontmatter.name.clone());
47            }
48
49            skill_map.insert(skill.frontmatter.name.clone(), skill);
50        }
51
52        Self {
53            skills: skill_map,
54            trigger_map,
55        }
56    }
57
58    /// Get a skill by name.
59    pub fn get(&self, name: &str) -> Option<&Skill> {
60        self.skills.get(name)
61    }
62
63    /// Match a user input against registered trigger commands.
64    ///
65    /// Uses longest-match priority: if both `/review` and `/review-quick` are registered,
66    /// `/review-quick src/main.rs` matches the longer trigger.
67    ///
68    /// Trigger must be followed by whitespace or end-of-string.
69    /// Returns the matched skill and any remaining arguments.
70    pub fn match_trigger(&self, input: &str) -> Option<(&Skill, String)> {
71        let mut best_trigger: Option<&str> = None;
72
73        for trigger in self.trigger_map.keys() {
74            if input.starts_with(trigger.as_str()) {
75                let rest = &input[trigger.len()..];
76                // Must be followed by whitespace or end-of-string
77                if rest.is_empty() || rest.starts_with(' ') {
78                    // Pick the longest matching trigger
79                    if best_trigger.is_none() || trigger.len() > best_trigger.unwrap().len() {
80                        best_trigger = Some(trigger);
81                    }
82                }
83            }
84        }
85
86        best_trigger.and_then(|trigger| {
87            let skill_name = self.trigger_map.get(trigger)?;
88            let skill = self.skills.get(skill_name.as_str())?;
89            let args = input[trigger.len()..].trim().to_string();
90            Some((skill, args))
91        })
92    }
93
94    /// Get all skill names and descriptions for system prompt injection.
95    pub fn skill_descriptions(&self) -> Vec<(&str, &str)> {
96        self.skills
97            .values()
98            .map(|s| {
99                (
100                    s.frontmatter.name.as_str(),
101                    s.frontmatter.description.as_str(),
102                )
103            })
104            .collect()
105    }
106
107    /// Get all skill names.
108    pub fn skill_names(&self) -> Vec<&str> {
109        self.skills.keys().map(|s| s.as_str()).collect()
110    }
111
112    /// Number of enabled skills.
113    pub fn count(&self) -> usize {
114        self.skills.len()
115    }
116
117    /// Get references to all skills.
118    pub fn skills(&self) -> Vec<&Skill> {
119        self.skills.values().collect()
120    }
121}
122
123// ============================================================================
124// Tests
125// ============================================================================
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::skill::SkillFrontmatter;
131    use std::path::PathBuf;
132
133    fn make_skill(name: &str, triggers: Vec<&str>) -> Skill {
134        Skill {
135            frontmatter: SkillFrontmatter {
136                name: name.to_string(),
137                description: format!("Description for {}", name),
138                version: "1.0.0".to_string(),
139                triggers: triggers.into_iter().map(String::from).collect(),
140                tools_required: vec![],
141                enabled: true,
142            },
143            content: format!("Content for {}", name),
144            source_path: PathBuf::from("test.md"),
145        }
146    }
147
148    #[test]
149    fn test_match_trigger_exact() {
150        let skills = vec![
151            make_skill("code-review", vec!["/review"]),
152            make_skill("refactor", vec!["/refactor"]),
153        ];
154        let registry = SkillRegistry::new(skills, &[]);
155
156        let (skill, args) = registry.match_trigger("/review").unwrap();
157        assert_eq!(skill.frontmatter.name, "code-review");
158        assert_eq!(args, "");
159    }
160
161    #[test]
162    fn test_match_trigger_with_args() {
163        let skills = vec![make_skill("code-review", vec!["/review"])];
164        let registry = SkillRegistry::new(skills, &[]);
165
166        let (skill, args) = registry.match_trigger("/review src/main.rs").unwrap();
167        assert_eq!(skill.frontmatter.name, "code-review");
168        assert_eq!(args, "src/main.rs");
169    }
170
171    #[test]
172    fn test_match_trigger_longest_match() {
173        let skills = vec![
174            make_skill("review", vec!["/review"]),
175            make_skill("review-quick", vec!["/review-quick"]),
176        ];
177        let registry = SkillRegistry::new(skills, &[]);
178
179        let (skill, _) = registry.match_trigger("/review-quick src/main.rs").unwrap();
180        assert_eq!(skill.frontmatter.name, "review-quick");
181    }
182
183    #[test]
184    fn test_match_trigger_partial_not_matched() {
185        let skills = vec![make_skill("review", vec!["/review"])];
186        let registry = SkillRegistry::new(skills, &[]);
187
188        // /reviews should NOT match /review (no space after trigger)
189        assert!(registry.match_trigger("/reviews").is_none());
190    }
191
192    #[test]
193    fn test_match_trigger_no_match() {
194        let skills = vec![make_skill("review", vec!["/review"])];
195        let registry = SkillRegistry::new(skills, &[]);
196
197        assert!(registry.match_trigger("/unknown").is_none());
198        assert!(registry.match_trigger("plain text").is_none());
199    }
200
201    #[test]
202    fn test_skill_descriptions() {
203        let skills = vec![
204            make_skill("review", vec!["/review"]),
205            make_skill("refactor", vec!["/refactor"]),
206        ];
207        let registry = SkillRegistry::new(skills, &["read", "bash"]);
208
209        let descs = registry.skill_descriptions();
210        assert_eq!(descs.len(), 2);
211        assert!(descs.iter().any(|(n, d)| *n == "review" && d.contains("review")));
212    }
213
214    #[test]
215    fn test_warns_missing_tools() {
216        // This test just verifies the warn! path is exercised.
217        // In practice, tracing::warn! to stderr during tests is fine.
218        let skills = vec![make_skill("review", vec!["/review"])];
219        let mut front = skills[0].frontmatter.clone();
220        front.tools_required = vec!["bash".to_string(), "nonexistent_tool".to_string()];
221        let skill = Skill {
222            frontmatter: front,
223            ..skills[0].clone()
224        };
225
226        let _registry = SkillRegistry::new(vec![skill], &["bash", "read"]);
227        // If no panic, the test passes — warnings are logged
228    }
229}