1use anyhow::{Context, Result};
10use std::collections::HashMap;
11use std::fmt;
12use std::path::{Path, PathBuf};
13
14#[derive(Debug, Clone)]
16pub struct Skill {
17 pub name: String,
19 pub description: String,
21 pub content: String,
23}
24
25impl fmt::Display for Skill {
26 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27 write!(f, "{}: {}", self.name, self.description)
28 }
29}
30
31pub struct SkillManager {
33 skills: HashMap<String, Skill>,
34}
35
36impl SkillManager {
37 pub fn new() -> Self {
39 Self {
40 skills: HashMap::new(),
41 }
42 }
43
44 pub fn load_from_dir(dir: &Path) -> Result<Self> {
55 let mut skills = HashMap::new();
56
57 if !dir.exists() {
58 tracing::debug!("Skills directory does not exist: {}", dir.display());
59 return Ok(Self { skills });
60 }
61
62 let entries = std::fs::read_dir(dir)
63 .with_context(|| format!("Failed to read skills directory: {}", dir.display()))?;
64
65 for entry in entries {
66 let entry = entry?;
67 let path = entry.path();
68
69 if !path.is_dir() {
71 continue;
72 }
73
74 let skill_file = path.join("SKILL.md");
75 if !skill_file.exists() {
76 tracing::debug!(
77 "No SKILL.md found in {}",
78 path.file_name().unwrap_or_default().to_string_lossy()
79 );
80 continue;
81 }
82
83 let name = path
84 .file_name()
85 .unwrap_or_default()
86 .to_string_lossy()
87 .to_string();
88
89 match Self::load_skill(&name, &skill_file) {
90 Ok(skill) => {
91 tracing::debug!("Loaded skill: {}", skill.name);
92 skills.insert(name.to_lowercase(), skill);
93 }
94 Err(e) => {
95 tracing::warn!("Failed to load skill from {}: {}", skill_file.display(), e);
96 }
97 }
98 }
99
100 tracing::info!("Loaded {} skill(s) from {}", skills.len(), dir.display());
101 Ok(Self { skills })
102 }
103
104 fn load_skill(name: &str, path: &Path) -> Result<Skill> {
106 let content = std::fs::read_to_string(path)
107 .with_context(|| format!("Failed to read {}", path.display()))?;
108
109 let description = Self::extract_description(&content);
110
111 Ok(Skill {
112 name: name.to_string(),
113 description,
114 content,
115 })
116 }
117
118 fn extract_description(content: &str) -> String {
123 for line in content.lines() {
124 let trimmed = line.trim();
125 if trimmed.is_empty() || trimmed == "---" {
127 continue;
128 }
129 if trimmed.contains(':') && !trimmed.starts_with('#') && !trimmed.starts_with('-') {
131 continue;
132 }
133 if let Some(heading) = trimmed.strip_prefix('#') {
135 return heading.trim().to_string();
136 }
137 if !trimmed.starts_with('-') && !trimmed.starts_with('>') && trimmed.len() > 3 {
139 return trimmed.to_string();
140 }
141 }
142 "No description".to_string()
143 }
144
145 pub fn get(&self, name: &str) -> Option<&Skill> {
147 self.skills.get(&name.to_lowercase())
148 }
149
150 pub fn all(&self) -> Vec<&Skill> {
152 let mut skills: Vec<&Skill> = self.skills.values().collect();
153 skills.sort_by(|a, b| a.name.cmp(&b.name));
154 skills
155 }
156
157 pub fn search(&self, query: &str) -> Vec<&Skill> {
162 let query_lower = query.to_lowercase();
163 let mut name_matches: Vec<&Skill> = Vec::new();
164 let mut desc_matches: Vec<&Skill> = Vec::new();
165
166 for skill in self.skills.values() {
167 let name_lower = skill.name.to_lowercase();
168 let desc_lower = skill.description.to_lowercase();
169
170 if name_lower.contains(&query_lower) {
171 name_matches.push(skill);
172 } else if desc_lower.contains(&query_lower) {
173 desc_matches.push(skill);
174 }
175 }
176
177 for skill in self.skills.values() {
179 if !name_matches.iter().any(|s| s.name == skill.name)
180 && !desc_matches.iter().any(|s| s.name == skill.name)
181 && skill.content.to_lowercase().contains(&query_lower)
182 {
183 desc_matches.push(skill);
184 }
185 }
186
187 name_matches.sort_by(|a, b| a.name.cmp(&b.name));
188 desc_matches.sort_by(|a, b| a.name.cmp(&b.name));
189
190 name_matches.extend(desc_matches);
191 name_matches
192 }
193
194 pub fn len(&self) -> usize {
196 self.skills.len()
197 }
198
199 pub fn is_empty(&self) -> bool {
201 self.skills.is_empty()
202 }
203
204 pub fn skills_dir() -> Result<PathBuf> {
206 let home = dirs::home_dir().context("Cannot determine home directory")?;
207 Ok(home.join(".oxi").join("skills"))
208 }
209}
210
211impl fmt::Debug for SkillManager {
212 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
213 f.debug_struct("SkillManager")
214 .field("count", &self.skills.len())
215 .field(
216 "names",
217 &self.skills.keys().cloned().collect::<Vec<String>>(),
218 )
219 .finish()
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use std::fs;
227
228 #[test]
229 fn test_load_from_empty_dir() {
230 let tmp = tempfile::tempdir().unwrap();
231 let manager = SkillManager::load_from_dir(tmp.path()).unwrap();
232 assert!(manager.is_empty());
233 assert_eq!(manager.len(), 0);
234 }
235
236 #[test]
237 fn test_load_from_nonexistent_dir() {
238 let manager = SkillManager::load_from_dir(Path::new("/nonexistent/skills")).unwrap();
239 assert!(manager.is_empty());
240 }
241
242 #[test]
243 fn test_load_single_skill() {
244 let tmp = tempfile::tempdir().unwrap();
245 let skill_dir = tmp.path().join("my-skill");
246 fs::create_dir_all(&skill_dir).unwrap();
247 fs::write(
248 skill_dir.join("SKILL.md"),
249 "# My Skill\n\nThis skill does something cool.\n\n## Usage\nDo X then Y.",
250 )
251 .unwrap();
252
253 let manager = SkillManager::load_from_dir(tmp.path()).unwrap();
254 assert_eq!(manager.len(), 1);
255
256 let skill = manager.get("my-skill").unwrap();
257 assert_eq!(skill.name, "my-skill");
258 assert_eq!(skill.description, "My Skill");
259 assert!(skill.content.contains("This skill does something cool"));
260 }
261
262 #[test]
263 fn test_load_multiple_skills() {
264 let tmp = tempfile::tempdir().unwrap();
265
266 let dir_a = tmp.path().join("skill-a");
268 fs::create_dir_all(&dir_a).unwrap();
269 fs::write(dir_a.join("SKILL.md"), "# Skill A\nDescription A").unwrap();
270
271 let dir_b = tmp.path().join("skill-b");
273 fs::create_dir_all(&dir_b).unwrap();
274 fs::write(dir_b.join("SKILL.md"), "# Skill B\nDescription B").unwrap();
275
276 let dir_empty = tmp.path().join("empty-dir");
278 fs::create_dir_all(&dir_empty).unwrap();
279
280 fs::write(tmp.path().join("not-a-dir.txt"), "ignore me").unwrap();
282
283 let manager = SkillManager::load_from_dir(tmp.path()).unwrap();
284 assert_eq!(manager.len(), 2);
285 assert!(manager.get("skill-a").is_some());
286 assert!(manager.get("skill-b").is_some());
287 assert!(manager.get("empty-dir").is_none());
288 }
289
290 #[test]
291 fn test_get_case_insensitive() {
292 let tmp = tempfile::tempdir().unwrap();
293 let dir = tmp.path().join("My-Skill");
294 fs::create_dir_all(&dir).unwrap();
295 fs::write(dir.join("SKILL.md"), "# Test\nContent").unwrap();
296
297 let manager = SkillManager::load_from_dir(tmp.path()).unwrap();
298 assert!(manager.get("My-Skill").is_some());
300 }
301
302 #[test]
303 fn test_search_by_name() {
304 let tmp = tempfile::tempdir().unwrap();
305 let dir = tmp.path().join("rust-expert");
306 fs::create_dir_all(&dir).unwrap();
307 fs::write(dir.join("SKILL.md"), "# Rust Expert\nAn expert in Rust").unwrap();
308
309 let manager = SkillManager::load_from_dir(tmp.path()).unwrap();
310 let results = manager.search("rust");
311 assert_eq!(results.len(), 1);
312 assert_eq!(results[0].name, "rust-expert");
313 }
314
315 #[test]
316 fn test_search_by_description() {
317 let tmp = tempfile::tempdir().unwrap();
318 let dir = tmp.path().join("helper");
319 fs::create_dir_all(&dir).unwrap();
320 fs::write(
321 dir.join("SKILL.md"),
322 "# Helper\nA database optimization expert",
323 )
324 .unwrap();
325
326 let manager = SkillManager::load_from_dir(tmp.path()).unwrap();
327 let results = manager.search("database");
328 assert_eq!(results.len(), 1);
329 assert_eq!(results[0].name, "helper");
330 }
331
332 #[test]
333 fn test_search_by_content() {
334 let tmp = tempfile::tempdir().unwrap();
335 let dir = tmp.path().join("coder");
336 fs::create_dir_all(&dir).unwrap();
337 fs::write(
338 dir.join("SKILL.md"),
339 "# Coder\nA coding assistant\n\n## Details\nFocuses on async patterns",
340 )
341 .unwrap();
342
343 let manager = SkillManager::load_from_dir(tmp.path()).unwrap();
344 let results = manager.search("async");
345 assert_eq!(results.len(), 1);
346 }
347
348 #[test]
349 fn test_search_no_results() {
350 let tmp = tempfile::tempdir().unwrap();
351 let dir = tmp.path().join("skill");
352 fs::create_dir_all(&dir).unwrap();
353 fs::write(dir.join("SKILL.md"), "# Skill\nA skill").unwrap();
354
355 let manager = SkillManager::load_from_dir(tmp.path()).unwrap();
356 let results = manager.search("nonexistent");
357 assert!(results.is_empty());
358 }
359
360 #[test]
361 fn test_all_sorted() {
362 let tmp = tempfile::tempdir().unwrap();
363
364 for name in &["zebra", "alpha", "middle"] {
365 let dir = tmp.path().join(name);
366 fs::create_dir_all(&dir).unwrap();
367 fs::write(dir.join("SKILL.md"), format!("# {}\nDesc", name)).unwrap();
368 }
369
370 let manager = SkillManager::load_from_dir(tmp.path()).unwrap();
371 let all = manager.all();
372 assert_eq!(all.len(), 3);
373 assert_eq!(all[0].name, "alpha");
374 assert_eq!(all[1].name, "middle");
375 assert_eq!(all[2].name, "zebra");
376 }
377
378 #[test]
379 fn test_extract_description_from_heading() {
380 let content = "# My Cool Skill\n\nSome body text";
381 assert_eq!(SkillManager::extract_description(content), "My Cool Skill");
382 }
383
384 #[test]
385 fn test_extract_description_from_paragraph() {
386 let content = "This is a skill that does things.\n\nMore text.";
387 assert_eq!(
388 SkillManager::extract_description(content),
389 "This is a skill that does things."
390 );
391 }
392
393 #[test]
394 fn test_extract_description_empty() {
395 let content = "---\n---\n";
396 assert_eq!(SkillManager::extract_description(content), "No description");
397 }
398
399 #[test]
400 fn test_skills_dir() {
401 let dir = SkillManager::skills_dir().unwrap();
402 assert!(dir.to_string_lossy().contains(".oxi"));
403 assert!(dir.to_string_lossy().contains("skills"));
404 }
405}