robit_agent/skill/
registry.rs1use std::collections::{HashMap, HashSet};
4
5use crate::skill::Skill;
6
7pub struct SkillRegistry {
9 skills: HashMap<String, Skill>,
10 trigger_map: HashMap<String, String>,
12}
13
14impl SkillRegistry {
15 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 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 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 pub fn get(&self, name: &str) -> Option<&Skill> {
60 self.skills.get(name)
61 }
62
63 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 if rest.is_empty() || rest.starts_with(' ') {
78 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 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 pub fn skill_names(&self) -> Vec<&str> {
109 self.skills.keys().map(|s| s.as_str()).collect()
110 }
111
112 pub fn count(&self) -> usize {
114 self.skills.len()
115 }
116
117 pub fn skills(&self) -> Vec<&Skill> {
119 self.skills.values().collect()
120 }
121}
122
123#[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 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 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 }
229}