use super::role::{Role, RoleLike}; use super::skill::Skill; use super::skill_policy::SkillPolicy; use anyhow::{Result, anyhow, bail}; use indexmap::IndexMap; use std::collections::BTreeSet; #[derive(Clone, Default)] pub struct SkillRegistry { loaded: IndexMap, } impl SkillRegistry { pub fn insert(&mut self, skill: Skill) -> Result<()> { let name = skill.name().to_string(); if self.loaded.contains_key(&name) { bail!("Skill '{name}' is already loaded"); } self.loaded.insert(name, skill); Ok(()) } pub fn unload(&mut self, name: &str) -> Result { self.loaded .shift_remove(name) .ok_or_else(|| anyhow!("Skill '{name}' is not loaded")) } pub fn loaded_names(&self) -> Vec { self.loaded.keys().cloned().collect() } pub fn loaded_mcp_servers(&self) -> BTreeSet { let mut out = BTreeSet::new(); for skill in self.loaded.values() { if let Some(servers) = skill.enabled_mcp_servers() { for token in servers { let t = token.trim(); if !t.is_empty() { out.insert(t.to_string()); } } } } out } pub fn is_loaded(&self, name: &str) -> bool { self.loaded.contains_key(name) } pub fn sweep_auto_unload(&mut self) { self.loaded.retain(|_, skill| !skill.auto_unload()); } pub fn effective_role(&self, base: &Role, policy: &SkillPolicy) -> Role { if !policy.skills_enabled || self.loaded.is_empty() { return base.clone(); } let mut effective = base.clone(); let skip_body = effective.is_embedded_prompt(); let base_tools = effective.enabled_tools(); let base_tools_set = base_tools.is_some(); let base_mcps = effective.enabled_mcp_servers(); let base_mcps_set = base_mcps.is_some(); let mut tools: BTreeSet = base_tools.unwrap_or_default().into_iter().collect(); let mut mcps: BTreeSet = base_mcps.unwrap_or_default().into_iter().collect(); for (name, skill) in &self.loaded { if !policy.allows(name) { continue; } if let Some(skill_tools) = skill.enabled_tools() { tools.extend(skill_tools.iter().cloned()); } if let Some(servers) = skill.enabled_mcp_servers() { mcps.extend(servers.iter().cloned()); } if !skip_body && !skill.body().is_empty() { let separator = if effective.is_empty_prompt() { "" } else { "\n\n" }; effective.append_to_prompt(separator); effective.append_to_prompt(skill.body()); } } if base_tools_set || !tools.is_empty() { effective.set_enabled_tools(Some(tools.into_iter().collect())); } if base_mcps_set || !mcps.is_empty() { effective.set_enabled_mcp_servers(Some(mcps.into_iter().collect())); } effective } } #[cfg(test)] impl SkillRegistry { fn insert_for_test(&mut self, skill: Skill) { self.loaded.insert(skill.name().to_string(), skill); } fn effective_role_for_test(&self, base: &Role) -> Role { let policy = SkillPolicy { skills_enabled: true, enabled: self.loaded.keys().cloned().collect(), compatible_enabled: self.loaded.keys().cloned().collect(), }; self.effective_role(base, &policy) } } #[cfg(test)] mod tests { use super::*; fn make_skill(name: &str, frontmatter: &str, body: &str) -> Skill { let content = if frontmatter.is_empty() { body.to_string() } else { format!("---\n{frontmatter}\n---\n{body}") }; Skill::new(name, &content) } #[test] fn empty_registry_returns_base_clone() { let base = Role::new("test", "You are a helper"); let registry = SkillRegistry::default(); let effective = registry.effective_role_for_test(&base); assert_eq!(effective.prompt(), base.prompt()); } #[test] fn one_skill_appends_body_after_base_with_separator() { let mut registry = SkillRegistry::default(); registry.insert_for_test(make_skill("git-master", "description: D", "Git knowledge")); let base = Role::new("test", "You are a helper"); let effective = registry.effective_role_for_test(&base); assert_eq!(effective.prompt(), "You are a helper\n\nGit knowledge"); } #[test] fn two_skills_compose_bodies_in_insertion_order() { let mut registry = SkillRegistry::default(); registry.insert_for_test(make_skill("a", "", "Alpha body")); registry.insert_for_test(make_skill("b", "", "Beta body")); let base = Role::new("test", "Base"); let effective = registry.effective_role_for_test(&base); assert_eq!(effective.prompt(), "Base\n\nAlpha body\n\nBeta body"); } #[test] fn empty_base_prompt_omits_leading_separator() { let mut registry = SkillRegistry::default(); registry.insert_for_test(make_skill("a", "", "Alpha")); registry.insert_for_test(make_skill("b", "", "Beta")); let base = Role::new("test", ""); let effective = registry.effective_role_for_test(&base); assert_eq!(effective.prompt(), "Alpha\n\nBeta"); } #[test] fn embedded_prompt_base_skips_body_composition() { let mut registry = SkillRegistry::default(); registry.insert_for_test(make_skill( "git-master", "enabled_tools: shell", "should not appear", )); let base = Role::new("test", "Process: __INPUT__"); let effective = registry.effective_role_for_test(&base); assert_eq!(effective.prompt(), "Process: __INPUT__"); let tools = effective.enabled_tools().expect("tools set by skill"); assert!(tools.iter().any(|s| s == "shell")); } #[test] fn skills_with_empty_body_do_not_inject_separator() { let mut registry = SkillRegistry::default(); registry.insert_for_test(make_skill("knowledge", "enabled_tools: fs", "")); let base = Role::new("test", "Base"); let effective = registry.effective_role_for_test(&base); assert_eq!(effective.prompt(), "Base"); } #[test] fn tools_and_mcps_are_unioned_and_deduplicated() { let mut registry = SkillRegistry::default(); registry.insert_for_test(make_skill( "a", "enabled_tools: shell,fs\nenabled_mcp_servers: github", "body", )); registry.insert_for_test(make_skill( "b", "enabled_tools: fs,git\nenabled_mcp_servers: github,jira", "body", )); let mut base = Role::new("test", "body"); base.set_enabled_tools(Some(vec!["web_search".to_string()])); let effective = registry.effective_role_for_test(&base); let tools_vec = effective.enabled_tools().unwrap(); let tools: BTreeSet<&str> = tools_vec.iter().map(|s| s.as_str()).collect(); assert_eq!(tools, BTreeSet::from(["fs", "git", "shell", "web_search"])); let mcps_vec = effective.enabled_mcp_servers().unwrap(); let mcps: BTreeSet<&str> = mcps_vec.iter().map(|s| s.as_str()).collect(); assert_eq!(mcps, BTreeSet::from(["github", "jira"])); } #[test] fn no_skill_tool_contributions_preserves_base_none() { let mut registry = SkillRegistry::default(); registry.insert_for_test(make_skill("knowledge", "", "Pure knowledge")); let base = Role::new("test", "Base"); let effective = registry.effective_role_for_test(&base); assert!(effective.enabled_tools().is_none()); assert!(effective.enabled_mcp_servers().is_none()); } #[test] fn base_some_empty_tools_is_preserved() { let mut registry = SkillRegistry::default(); registry.insert_for_test(make_skill("knowledge", "", "Pure knowledge")); let mut base = Role::new("test", "Base"); base.set_enabled_tools(Some(Vec::new())); let effective = registry.effective_role_for_test(&base); assert_eq!(effective.enabled_tools().as_deref(), Some([].as_slice())); } #[test] fn unload_not_loaded_returns_error() { let mut registry = SkillRegistry::default(); let err = registry.unload("missing").unwrap_err(); assert!(err.to_string().contains("not loaded")); } #[test] fn unload_existing_succeeds_and_removes() { let mut registry = SkillRegistry::default(); registry.insert_for_test(make_skill("git-master", "", "body")); assert!(registry.is_loaded("git-master")); registry.unload("git-master").unwrap(); assert!(!registry.is_loaded("git-master")); } #[test] fn loaded_names_returns_insertion_order() { let mut registry = SkillRegistry::default(); registry.insert_for_test(make_skill("zulu", "", "body")); registry.insert_for_test(make_skill("alpha", "", "body")); registry.insert_for_test(make_skill("mike", "", "body")); assert_eq!( registry.loaded_names(), vec!["zulu".to_string(), "alpha".to_string(), "mike".to_string()] ); } #[test] fn sweep_removes_only_auto_unload_skills() { let mut registry = SkillRegistry::default(); registry.insert_for_test(make_skill("ephemeral", "auto_unload: true", "body")); registry.insert_for_test(make_skill("persistent", "", "body")); registry.sweep_auto_unload(); assert!(!registry.is_loaded("ephemeral")); assert!(registry.is_loaded("persistent")); } #[test] fn is_loaded_returns_false_for_unknown() { let registry = SkillRegistry::default(); assert!(!registry.is_loaded("nothing")); } }