1use super::validator::SkillValidator;
7use super::{Skill, SkillKind};
8use anyhow::Context;
9use std::collections::{HashMap, HashSet};
10use std::path::{Path, PathBuf};
11use std::sync::{Arc, RwLock};
12
13pub struct SkillRegistry {
18 skills: Arc<RwLock<HashMap<String, Arc<Skill>>>>,
19 builtin_names: Arc<RwLock<HashSet<String>>>,
20 validator: Arc<RwLock<Option<Arc<dyn SkillValidator>>>>,
21}
22
23impl SkillRegistry {
24 pub fn new() -> Self {
26 Self {
27 skills: Arc::new(RwLock::new(HashMap::new())),
28 builtin_names: Arc::new(RwLock::new(HashSet::new())),
29 validator: Arc::new(RwLock::new(None)),
30 }
31 }
32
33 pub fn with_builtins() -> Self {
35 let registry = Self::new();
36 for skill in super::builtin::builtin_skills() {
37 registry.register_builtin(skill);
39 }
40 registry
41 }
42
43 pub fn fork(&self) -> Self {
49 let skills = self.skills.read().unwrap().clone();
50 let builtin_names = self.builtin_names.read().unwrap().clone();
51 Self {
52 skills: Arc::new(RwLock::new(skills)),
53 builtin_names: Arc::new(RwLock::new(builtin_names)),
54 validator: Arc::new(RwLock::new(self.validator.read().unwrap().clone())),
55 }
56 }
57
58 pub fn set_validator(&self, validator: Arc<dyn SkillValidator>) {
60 *self.validator.write().unwrap() = Some(validator);
61 }
62
63 pub fn register(
68 &self,
69 skill: Arc<Skill>,
70 ) -> Result<(), super::validator::SkillValidationError> {
71 if let Some(ref validator) = *self.validator.read().unwrap() {
73 validator.validate(&skill)?;
74 }
75 self.register_unchecked(skill);
76 Ok(())
77 }
78
79 pub fn register_unchecked(&self, skill: Arc<Skill>) {
84 self.builtin_names.write().unwrap().remove(&skill.name);
85 let mut skills = self.skills.write().unwrap();
86 skills.insert(skill.name.clone(), skill);
87 }
88
89 fn register_builtin(&self, skill: Arc<Skill>) {
90 let name = skill.name.clone();
91 self.skills.write().unwrap().insert(name.clone(), skill);
92 self.builtin_names.write().unwrap().insert(name);
93 }
94
95 pub fn get(&self, name: &str) -> Option<Arc<Skill>> {
97 let skills = self.skills.read().unwrap();
98 skills.get(name).cloned()
99 }
100
101 pub fn list(&self) -> Vec<String> {
103 let skills = self.skills.read().unwrap();
104 skills.keys().cloned().collect()
105 }
106
107 pub fn all(&self) -> Vec<Arc<Skill>> {
109 let skills = self.skills.read().unwrap();
110 skills.values().cloned().collect()
111 }
112
113 pub fn load_from_dir(&self, dir: impl AsRef<Path>) -> anyhow::Result<usize> {
125 let dir = dir.as_ref();
126
127 if !dir.exists() {
128 return Ok(0);
129 }
130
131 if !dir.is_dir() {
132 anyhow::bail!("Path is not a directory: {}", dir.display());
133 }
134
135 let mut loaded = 0;
136 for candidate in Self::collect_skill_candidates(dir)? {
137 match Skill::from_file(&candidate) {
138 Ok(skill) => {
139 let name = skill.name.clone();
140 if skill.allowed_tools.is_none() {
141 tracing::warn!(
142 skill = %name,
143 path = %candidate.display(),
144 "Skill omits allowed-tools; Skill invocation is fail-secure and will deny tool use until allowed-tools is declared"
145 );
146 } else if skill.uses_legacy_allowed_tools_syntax() {
147 tracing::warn!(
148 skill = %name,
149 path = %candidate.display(),
150 "Skill uses legacy whitespace-separated allowed-tools; use comma-separated permissions such as Read(*), Write(*), Bash(*) or a YAML list"
151 );
152 }
153 let skill = Arc::new(skill);
154 if self.get(&name).is_some() {
155 tracing::warn!(
156 skill = %name,
157 path = %candidate.display(),
158 "Duplicate skill name encountered during directory load — overriding previous definition"
159 );
160 }
161 match self.register(skill) {
162 Ok(()) => loaded += 1,
163 Err(e) => {
164 tracing::warn!(
165 "Skill validation failed for {}: {}",
166 candidate.display(),
167 e
168 );
169 }
170 }
171 }
172 Err(e) => {
173 tracing::debug!("Skipped {}: {}", candidate.display(), e);
174 }
175 }
176 }
177
178 Ok(loaded)
179 }
180
181 fn collect_skill_candidates(dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
182 fn visit(dir: &Path, out: &mut Vec<PathBuf>) -> anyhow::Result<()> {
183 let mut entries = std::fs::read_dir(dir)
184 .with_context(|| format!("Failed to read directory: {}", dir.display()))?
185 .collect::<Result<Vec<_>, std::io::Error>>()?;
186 entries.sort_by_key(|entry| entry.path());
187
188 for entry in entries {
189 let path = entry.path();
190 if path.is_dir() {
191 let skill_md = path.join("SKILL.md");
192 if skill_md.is_file() {
193 out.push(skill_md);
194 }
195 visit(&path, out)?;
196 } else if path.extension().and_then(|s| s.to_str()) == Some("md") {
197 out.push(path);
198 }
199 }
200 Ok(())
201 }
202
203 let mut out = Vec::new();
204 visit(dir, &mut out)?;
205 out.sort();
206 out.dedup();
207 Ok(out)
208 }
209
210 pub fn load_from_file(&self, path: impl AsRef<Path>) -> anyhow::Result<Arc<Skill>> {
212 let skill = Skill::from_file(path)?;
213 let skill = Arc::new(skill);
214 self.register(skill.clone())
215 .map_err(|e| anyhow::anyhow!("Skill validation failed: {}", e))?;
216 Ok(skill)
217 }
218
219 pub fn remove(&self, name: &str) -> Option<Arc<Skill>> {
221 let mut skills = self.skills.write().unwrap();
222 skills.remove(name)
223 }
224
225 pub fn clear(&self) {
227 let mut skills = self.skills.write().unwrap();
228 skills.clear();
229 }
230
231 pub fn len(&self) -> usize {
233 let skills = self.skills.read().unwrap();
234 skills.len()
235 }
236
237 pub fn is_empty(&self) -> bool {
239 self.len() == 0
240 }
241
242 pub fn by_kind(&self, kind: super::SkillKind) -> Vec<Arc<Skill>> {
244 let skills = self.skills.read().unwrap();
245 skills
246 .values()
247 .filter(|s| s.kind == kind)
248 .cloned()
249 .collect()
250 }
251
252 pub fn global_tool_restricting_skills(&self) -> Vec<Arc<Skill>> {
259 let skills = self.skills.read().unwrap();
260 let builtin_names = self.builtin_names.read().unwrap();
261 skills
262 .values()
263 .filter(|skill| {
264 skill.kind == SkillKind::Instruction
265 && skill.allowed_tools.is_some()
266 && !builtin_names.contains(&skill.name)
267 })
268 .cloned()
269 .collect()
270 }
271
272 pub fn by_tag(&self, tag: &str) -> Vec<Arc<Skill>> {
274 let skills = self.skills.read().unwrap();
275 skills
276 .values()
277 .filter(|s| s.tags.iter().any(|t| t == tag))
278 .cloned()
279 .collect()
280 }
281
282 pub fn personas(&self) -> Vec<Arc<Skill>> {
287 self.by_kind(super::SkillKind::Persona)
288 }
289
290 pub fn search(&self, query: &str, limit: usize) -> Vec<Arc<Skill>> {
292 let skills = self.skills.read().unwrap();
293 let query_lower = query.to_lowercase();
294 let query_tokens: Vec<&str> = query_lower
295 .split_whitespace()
296 .map(|w| w.trim_matches(|c: char| !c.is_alphanumeric()))
297 .filter(|w| w.len() >= 2)
298 .collect();
299
300 let mut scored: Vec<(u32, String, Arc<Skill>)> = skills
301 .values()
302 .filter(|s| Self::is_discoverable_skill(s))
303 .filter_map(|skill| {
304 let score = Self::skill_search_score(skill, &query_lower, &query_tokens);
305 if score == 0 {
306 None
307 } else {
308 Some((score, skill.name.clone(), Arc::clone(skill)))
309 }
310 })
311 .collect();
312
313 scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
314 scored
315 .into_iter()
316 .take(limit.max(1))
317 .map(|(_, _, skill)| skill)
318 .collect()
319 }
320
321 fn is_discoverable_skill(skill: &Skill) -> bool {
322 skill.kind == super::SkillKind::Instruction || skill.kind == super::SkillKind::Tool
323 }
324
325 fn skill_search_score(skill: &Skill, query_lower: &str, query_tokens: &[&str]) -> u32 {
326 if query_lower.trim().is_empty() {
327 return 1;
328 }
329
330 let name = skill.name.to_lowercase();
331 let description = skill.description.to_lowercase();
332 let tags: Vec<String> = skill.tags.iter().map(|t| t.to_lowercase()).collect();
333 let content = skill.content.to_lowercase();
334 let mut score = 0;
335
336 if query_lower.contains(&name) {
337 score += 100;
338 }
339 if tags.iter().any(|tag| query_lower.contains(tag)) {
340 score += 80;
341 }
342
343 for token in query_tokens {
344 if name.contains(token) {
345 score += 20;
346 }
347 if tags.iter().any(|tag| tag.contains(token)) {
348 score += 15;
349 }
350 if description.contains(token) {
351 score += 8;
352 }
353 if content.contains(token) {
354 score += 2;
355 }
356 }
357
358 score
359 }
360
361 pub fn to_system_prompt(&self) -> String {
371 let skills = self.skills.read().unwrap();
372
373 let has_discoverable_skill = skills.values().any(|s| Self::is_discoverable_skill(s));
374
375 if !has_discoverable_skill {
376 return String::new();
377 }
378
379 String::from(crate::prompts::SKILLS_CATALOG_HEADER)
380 }
381
382 pub fn match_skills(&self, user_input: &str) -> String {
387 let matched = self.search(user_input, 3);
388
389 if matched.is_empty() {
390 return String::new();
391 }
392
393 let mut out = String::from("# Skill Instructions\n\n");
394 for skill in matched {
395 out.push_str(&skill.to_system_prompt());
396 out.push_str("\n\n---\n\n");
397 }
398 out
399 }
400}
401
402impl Default for SkillRegistry {
403 fn default() -> Self {
404 Self::new()
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411 use crate::skills::SkillKind;
412 use std::io::Write;
413 use tempfile::TempDir;
414
415 #[test]
416 fn test_new_registry() {
417 let registry = SkillRegistry::new();
418 assert_eq!(registry.len(), 0);
419 assert!(registry.is_empty());
420 }
421
422 #[test]
423 fn test_with_builtins() {
424 let registry = SkillRegistry::with_builtins();
425 assert_eq!(registry.len(), 4, "Expected 4 built-in skills");
426 assert!(!registry.is_empty());
427
428 assert!(registry.get("code-search").is_some());
430 assert!(registry.get("code-review").is_some());
431 assert!(registry.get("explain-code").is_some());
432 assert!(registry.get("find-bugs").is_some());
433 }
434
435 #[test]
436 fn test_register_and_get() {
437 let registry = SkillRegistry::new();
438
439 let skill = Arc::new(Skill {
440 name: "test-skill".to_string(),
441 description: "A test skill".to_string(),
442 allowed_tools: None,
443 disable_model_invocation: false,
444 kind: SkillKind::Instruction,
445 content: "Test content".to_string(),
446 tags: vec![],
447 version: None,
448 });
449
450 registry.register(skill.clone()).unwrap();
451
452 assert_eq!(registry.len(), 1);
453 let retrieved = registry.get("test-skill").unwrap();
454 assert_eq!(retrieved.name, "test-skill");
455 }
456
457 #[test]
458 fn test_list() {
459 let registry = SkillRegistry::with_builtins();
460 let names = registry.list();
461
462 assert_eq!(names.len(), 4, "Expected 4 built-in skills");
463 assert!(names.contains(&"code-search".to_string()));
464 assert!(names.contains(&"code-review".to_string()));
465 assert!(names.contains(&"explain-code".to_string()));
466 assert!(names.contains(&"find-bugs".to_string()));
467 }
468
469 #[test]
470 fn test_remove() {
471 let registry = SkillRegistry::with_builtins();
472 assert_eq!(registry.len(), 4);
473
474 let removed = registry.remove("code-search");
475 assert!(removed.is_some());
476 assert_eq!(registry.len(), 3);
477 assert!(registry.get("code-search").is_none());
478 }
479
480 #[test]
481 fn test_clear() {
482 let registry = SkillRegistry::with_builtins();
483 assert_eq!(registry.len(), 4);
484
485 registry.clear();
486 assert_eq!(registry.len(), 0);
487 assert!(registry.is_empty());
488 }
489
490 #[test]
491 fn test_by_kind() {
492 let registry = SkillRegistry::with_builtins();
493 let instruction_skills = registry.by_kind(SkillKind::Instruction);
494
495 assert_eq!(instruction_skills.len(), 4, "Expected 4 instruction skills");
496
497 let persona_skills = registry.by_kind(SkillKind::Persona);
498 assert_eq!(persona_skills.len(), 0);
499 }
500
501 #[test]
502 fn test_builtin_skills_do_not_restrict_global_tools() {
503 let registry = SkillRegistry::with_builtins();
504 assert!(registry.global_tool_restricting_skills().is_empty());
505 }
506
507 #[test]
508 fn test_replacing_builtin_name_reenables_global_tool_restriction() {
509 let registry = SkillRegistry::with_builtins();
510 registry.register_unchecked(Arc::new(Skill {
511 name: "code-review".to_string(),
512 description: "External replacement".to_string(),
513 allowed_tools: Some("read(*)".to_string()),
514 disable_model_invocation: false,
515 kind: SkillKind::Instruction,
516 content: String::new(),
517 tags: Vec::new(),
518 version: None,
519 }));
520
521 let restricting = registry.global_tool_restricting_skills();
522 assert_eq!(restricting.len(), 1);
523 assert_eq!(restricting[0].name, "code-review");
524 }
525
526 #[test]
527 fn test_by_tag() {
528 let registry = SkillRegistry::with_builtins();
529 let search_skills = registry.by_tag("search");
530
531 assert_eq!(search_skills.len(), 1);
532 let names: Vec<&str> = search_skills.iter().map(|s| s.name.as_str()).collect();
533 assert!(names.contains(&"code-search"));
534
535 let security_skills = registry.by_tag("security");
536 assert_eq!(security_skills.len(), 1);
537 assert_eq!(security_skills[0].name, "find-bugs");
538 }
539
540 #[test]
541 fn test_load_from_dir() -> anyhow::Result<()> {
542 let temp_dir = TempDir::new()?;
543
544 let skill_path = temp_dir.path().join("test-skill.md");
546 let mut file = std::fs::File::create(&skill_path)?;
547 writeln!(file, "---")?;
548 writeln!(file, "name: test-skill")?;
549 writeln!(file, "description: A test skill")?;
550 writeln!(file, "kind: instruction")?;
551 writeln!(file, "---")?;
552 writeln!(file, "# Test Skill")?;
553 writeln!(file, "This is a test skill.")?;
554 drop(file);
555
556 let readme_path = temp_dir.path().join("README.md");
558 std::fs::write(&readme_path, "# README\nNot a skill")?;
559
560 let txt_path = temp_dir.path().join("notes.txt");
562 std::fs::write(&txt_path, "Some notes")?;
563
564 let registry = SkillRegistry::new();
565 let loaded = registry.load_from_dir(temp_dir.path())?;
566
567 assert_eq!(loaded, 1);
568 assert_eq!(registry.len(), 1);
569 assert!(registry.get("test-skill").is_some());
570
571 Ok(())
572 }
573
574 #[test]
575 fn test_load_from_dir_recurses_into_nested_skill_dirs() -> anyhow::Result<()> {
576 let temp_dir = TempDir::new()?;
577 let nested = temp_dir.path().join("nested").join("code-review-helper");
578 std::fs::create_dir_all(&nested)?;
579
580 let skill_path = nested.join("SKILL.md");
581 let mut file = std::fs::File::create(&skill_path)?;
582 writeln!(file, "---")?;
583 writeln!(file, "name: nested-skill")?;
584 writeln!(file, "description: A nested skill")?;
585 writeln!(file, "kind: instruction")?;
586 writeln!(file, "---")?;
587 writeln!(file, "# Nested Skill")?;
588 writeln!(file, "This skill lives in a nested SKILL.md.")?;
589 drop(file);
590
591 let registry = SkillRegistry::new();
592 let loaded = registry.load_from_dir(temp_dir.path())?;
593
594 assert_eq!(loaded, 1);
595 assert!(registry.get("nested-skill").is_some());
596 Ok(())
597 }
598
599 #[test]
600 fn test_load_from_dir_accepts_yaml_list_allowed_tools() -> anyhow::Result<()> {
601 let temp_dir = TempDir::new()?;
602 let skill_path = temp_dir.path().join("ci-review.md");
603 std::fs::write(
604 &skill_path,
605 r#"---
606name: ci-review
607description: Review CI failures
608allowed-tools:
609 - Read
610 - Grep
611 - Bash(cargo test -p a3s-code-core:*)
612kind: instruction
613---
614# CI Review
615"#,
616 )?;
617
618 let registry = SkillRegistry::new();
619 let loaded = registry.load_from_dir(temp_dir.path())?;
620
621 assert_eq!(loaded, 1);
622 let skill = registry.get("ci-review").unwrap();
623 assert_eq!(
624 skill.allowed_tools.as_deref(),
625 Some("Read, Grep, Bash(cargo test -p a3s-code-core:*)")
626 );
627 let permissions = skill.parse_allowed_tools();
628 assert!(permissions
629 .iter()
630 .any(|perm| perm.tool == "Read" && perm.pattern == "*"));
631 assert!(permissions.iter().any(|perm| {
632 perm.tool == "Bash" && perm.pattern == "cargo test -p a3s-code-core:*"
633 }));
634 Ok(())
635 }
636
637 #[test]
638 fn test_load_from_file() -> anyhow::Result<()> {
639 let temp_dir = TempDir::new()?;
640 let skill_path = temp_dir.path().join("my-skill.md");
641
642 let mut file = std::fs::File::create(&skill_path)?;
643 writeln!(file, "---")?;
644 writeln!(file, "name: my-skill")?;
645 writeln!(file, "description: My custom skill")?;
646 writeln!(file, "---")?;
647 writeln!(file, "# My Skill")?;
648 drop(file);
649
650 let registry = SkillRegistry::new();
651 let skill = registry.load_from_file(&skill_path)?;
652
653 assert_eq!(skill.name, "my-skill");
654 assert_eq!(registry.len(), 1);
655
656 Ok(())
657 }
658
659 #[test]
660 fn test_to_system_prompt() {
661 let registry = SkillRegistry::with_builtins();
662 let prompt = registry.to_system_prompt();
663
664 assert!(prompt.contains("# Skills"));
665 assert!(prompt.contains("search_skills"));
666 assert!(prompt.contains("Skill"));
667 assert!(!prompt.contains("code-search"));
668 assert!(!prompt.contains("code-review"));
669 }
670
671 #[test]
672 fn test_load_from_nonexistent_dir() {
673 let registry = SkillRegistry::new();
674 let result = registry.load_from_dir("/nonexistent/path");
675
676 assert!(result.is_ok());
677 assert_eq!(result.unwrap(), 0);
678 }
679
680 #[test]
681 fn test_load_from_dir_rejects_file_path() -> anyhow::Result<()> {
682 let temp_dir = TempDir::new()?;
683 let path = temp_dir.path().join("not-a-directory.md");
684 std::fs::write(&path, "# not a directory")?;
685
686 let registry = SkillRegistry::new();
687 let err = registry.load_from_dir(&path).unwrap_err();
688 assert!(err.to_string().contains("Path is not a directory"));
689 Ok(())
690 }
691
692 #[test]
693 fn test_load_from_dir_duplicate_name_overrides_previous_definition() -> anyhow::Result<()> {
694 let temp_dir = TempDir::new()?;
695
696 let first = temp_dir.path().join("first.md");
697 std::fs::write(
698 &first,
699 "---\nname: duplicate-skill\ndescription: First copy\n---\n# First\nalpha\n",
700 )?;
701
702 let nested = temp_dir.path().join("nested");
703 std::fs::create_dir_all(&nested)?;
704 let second = nested.join("SKILL.md");
705 std::fs::write(
706 &second,
707 "---\nname: duplicate-skill\ndescription: Second copy\n---\n# Second\nbeta\n",
708 )?;
709
710 let registry = SkillRegistry::new();
711 let loaded = registry.load_from_dir(temp_dir.path())?;
712
713 assert_eq!(loaded, 2);
714 assert_eq!(registry.len(), 1);
715 assert_eq!(
716 registry.get("duplicate-skill").unwrap().description,
717 "Second copy"
718 );
719 Ok(())
720 }
721
722 #[test]
725 fn test_register_with_validator_rejects_reserved() {
726 use crate::skills::validator::DefaultSkillValidator;
727
728 let registry = SkillRegistry::new();
729 registry.set_validator(Arc::new(DefaultSkillValidator::default()));
730
731 let skill = Arc::new(Skill {
732 name: "code-search".to_string(), description: "Override builtin".to_string(),
734 allowed_tools: None,
735 disable_model_invocation: false,
736 kind: SkillKind::Instruction,
737 content: "Malicious override".to_string(),
738 tags: vec![],
739 version: None,
740 });
741
742 let result = registry.register(skill);
743 assert!(result.is_err());
744 assert_eq!(registry.len(), 0);
745 }
746
747 #[test]
748 fn test_register_with_validator_accepts_valid() {
749 use crate::skills::validator::DefaultSkillValidator;
750
751 let registry = SkillRegistry::new();
752 registry.set_validator(Arc::new(DefaultSkillValidator::default()));
753
754 let skill = Arc::new(Skill {
755 name: "my-custom-skill".to_string(),
756 description: "A valid skill".to_string(),
757 allowed_tools: Some("read(*), grep(*)".to_string()),
758 disable_model_invocation: false,
759 kind: SkillKind::Instruction,
760 content: "Help with code review.".to_string(),
761 tags: vec![],
762 version: None,
763 });
764
765 assert!(registry.register(skill).is_ok());
766 assert_eq!(registry.len(), 1);
767 }
768
769 #[test]
770 fn test_register_without_validator_accepts_anything() {
771 let registry = SkillRegistry::new();
772 let skill = Arc::new(Skill {
775 name: "code-search".to_string(), description: "test".to_string(),
777 allowed_tools: None,
778 disable_model_invocation: false,
779 kind: SkillKind::Instruction,
780 content: "test".to_string(),
781 tags: vec![],
782 version: None,
783 });
784
785 assert!(registry.register(skill).is_ok());
786 }
787
788 #[test]
789 fn test_all_and_personas() {
790 let registry = SkillRegistry::new();
791
792 registry.register_unchecked(Arc::new(Skill {
793 name: "persona-skill".to_string(),
794 description: "Persona".to_string(),
795 allowed_tools: None,
796 disable_model_invocation: false,
797 kind: SkillKind::Persona,
798 content: "Persona content".to_string(),
799 tags: vec!["voice".to_string()],
800 version: None,
801 }));
802 registry.register_unchecked(Arc::new(Skill {
803 name: "instruction-skill".to_string(),
804 description: "Instruction".to_string(),
805 allowed_tools: None,
806 disable_model_invocation: false,
807 kind: SkillKind::Instruction,
808 content: "Instruction content".to_string(),
809 tags: vec!["workflow".to_string()],
810 version: None,
811 }));
812
813 assert_eq!(registry.all().len(), 2);
814 assert_eq!(registry.personas().len(), 1);
815 assert_eq!(registry.personas()[0].name, "persona-skill");
816 }
817
818 #[test]
819 fn test_load_from_file_with_validator_rejects() {
820 use crate::skills::validator::DefaultSkillValidator;
821
822 let temp_dir = TempDir::new().unwrap();
823 let skill_path = temp_dir.path().join("code-search.md");
824
825 let mut file = std::fs::File::create(&skill_path).unwrap();
826 writeln!(file, "---").unwrap();
827 writeln!(file, "name: code-search").unwrap(); writeln!(file, "description: Override").unwrap();
829 writeln!(file, "---").unwrap();
830 writeln!(file, "# Override").unwrap();
831 drop(file);
832
833 let registry = SkillRegistry::new();
834 registry.set_validator(Arc::new(DefaultSkillValidator::default()));
835
836 let result = registry.load_from_file(&skill_path);
837 assert!(result.is_err());
838 assert_eq!(registry.len(), 0);
839 }
840
841 #[test]
842 fn test_fork_is_independent() {
843 let original = SkillRegistry::with_builtins();
844 let fork = original.fork();
845
846 assert_eq!(fork.len(), original.len());
848
849 fork.register_unchecked(Arc::new(Skill {
851 name: "session-only".to_string(),
852 description: "Only in fork".to_string(),
853 allowed_tools: None,
854 disable_model_invocation: false,
855 kind: SkillKind::Instruction,
856 content: "content".to_string(),
857 tags: vec![],
858 version: None,
859 }));
860
861 assert_eq!(fork.len(), original.len() + 1);
862 assert!(fork.get("session-only").is_some());
863 assert!(original.get("session-only").is_none());
864 }
865
866 #[test]
867 fn test_fork_inherits_builtins() {
868 let fork = SkillRegistry::with_builtins().fork();
869 assert!(fork.get("code-search").is_some());
870 assert!(fork.get("code-review").is_some());
871 assert!(fork.get("find-bugs").is_some());
872 }
873
874 #[test]
875 fn test_fork_preserves_validator() {
876 use crate::skills::validator::DefaultSkillValidator;
877
878 let original = SkillRegistry::new();
879 original.set_validator(Arc::new(DefaultSkillValidator::default()));
880
881 let fork = original.fork();
882 let invalid = Arc::new(Skill {
883 name: "BadName".to_string(),
884 description: "invalid".to_string(),
885 allowed_tools: None,
886 disable_model_invocation: false,
887 kind: SkillKind::Instruction,
888 content: "content".to_string(),
889 tags: vec![],
890 version: None,
891 });
892
893 assert!(fork.register(invalid).is_err());
894 }
895
896 #[test]
897 fn test_search_skills_ranks_matches() {
898 let registry = SkillRegistry::new();
899
900 registry.register_unchecked(Arc::new(Skill {
901 name: "build-planner".to_string(),
902 description: "Plan complex builds".to_string(),
903 allowed_tools: None,
904 disable_model_invocation: false,
905 kind: SkillKind::Instruction,
906 content: "Planner instructions".to_string(),
907 tags: vec!["architecture".to_string()],
908 version: None,
909 }));
910 let matches = registry.search("architecture plan", 5);
911 assert_eq!(matches.len(), 1);
912 assert_eq!(matches[0].name, "build-planner");
913 }
914
915 #[test]
916 fn test_match_skills_matches_name_tag_and_description() {
917 let registry = SkillRegistry::new();
918
919 registry.register_unchecked(Arc::new(Skill {
920 name: "build-planner".to_string(),
921 description: "Plan complex builds".to_string(),
922 allowed_tools: None,
923 disable_model_invocation: false,
924 kind: SkillKind::Instruction,
925 content: "Planner instructions".to_string(),
926 tags: vec!["architecture".to_string()],
927 version: None,
928 }));
929 let by_name = registry.match_skills("please use build-planner for this task");
930 assert!(by_name.contains("Planner instructions"));
931
932 let by_tag = registry.match_skills("need architecture guidance");
933 assert!(by_tag.contains("Planner instructions"));
934
935 let by_description = registry.match_skills("help me plan the release");
936 assert!(by_description.contains("Planner instructions"));
937
938 assert!(registry
939 .match_skills("totally unrelated request")
940 .is_empty());
941 }
942}