use super::agent::Agent; use super::app_config::AppConfig; use super::paths; use super::role::Role; use super::session::Session; use anyhow::{Result, anyhow, bail}; use std::collections::HashSet; #[derive(Debug)] pub struct SkillPolicy { pub skills_enabled: bool, pub enabled: HashSet, } impl SkillPolicy { pub fn effective( global: &AppConfig, role: Option<&Role>, agent: Option<&Agent>, session: Option<&Session>, ) -> Result { Self::effective_with( global, role, agent, session, &paths::has_skill, &paths::list_skills, ) } fn effective_with( global: &AppConfig, role: Option<&Role>, agent: Option<&Agent>, session: Option<&Session>, skill_exists: &F, list_installed: &G, ) -> Result where F: Fn(&str) -> bool, G: Fn() -> Vec, { let mut skills_enabled = global.skills_enabled; if let Some(r) = role && let Some(false) = r.skills_enabled() { skills_enabled = false; } if let Some(a) = agent && let Some(false) = a.skills_enabled() { skills_enabled = false; } if let Some(s) = session && let Some(false) = s.skills_enabled() { skills_enabled = false; } let visible: Option> = global .visible_skills .as_ref() .map(|v| v.iter().cloned().collect()); let enabled_raw: Option> = session .and_then(|s| s.enabled_skills().map(|v| v.to_vec())) .or_else(|| agent.and_then(|a| a.enabled_skills().map(|v| v.to_vec()))) .or_else(|| role.and_then(|r| r.enabled_skills().map(|v| v.to_vec()))) .or_else(|| global.enabled_skills.clone()); let enabled: HashSet = match enabled_raw { Some(explicit) => { let set: HashSet = explicit.into_iter().collect(); for name in &set { paths::validate_skill_name(name).map_err(|e| { anyhow!("enabled_skills contains invalid name '{name}': {e}") })?; match &visible { Some(vs) => { if !vs.contains(name) { bail!( "enabled_skills references skill '{name}' which is not in the global 'visible_skills' allow-list" ); } } None => { if !skill_exists(name) { bail!( "enabled_skills references skill '{name}' which is not installed" ); } } } } set } None => match &visible { Some(v) => v.clone(), None => list_installed().into_iter().collect(), }, }; Ok(Self { skills_enabled, enabled, }) } pub fn allows(&self, name: &str) -> bool { self.skills_enabled && self.enabled.contains(name) } } #[cfg(test)] mod tests { use super::super::csv_to_vec; use super::*; fn always_true(_: &str) -> bool { true } fn empty_installed() -> Vec { Vec::new() } fn make_app_config( skills_enabled: bool, enabled: Option<&str>, visible: Option<&[&str]>, ) -> AppConfig { AppConfig { skills_enabled, enabled_skills: enabled.map(csv_to_vec), visible_skills: visible.map(|v| v.iter().map(|s| s.to_string()).collect()), ..AppConfig::default() } } #[test] fn defaults_yield_skills_enabled_with_empty_universe() { let global = AppConfig::default(); let policy = SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed) .unwrap(); assert!(policy.skills_enabled); assert!(policy.enabled.is_empty()); } #[test] fn falls_back_to_all_installed_when_no_level_sets_enabled_skills() { let global = AppConfig::default(); let installed = || vec!["alpha".to_string(), "beta".to_string()]; let policy = SkillPolicy::effective_with(&global, None, None, None, &always_true, &installed) .unwrap(); assert_eq!(policy.enabled.len(), 2); assert!(policy.enabled.contains("alpha")); assert!(policy.enabled.contains("beta")); } #[test] fn falls_back_to_visible_when_visible_set_but_no_enabled() { let global = make_app_config(true, None, Some(&["alpha", "beta"])); let policy = SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed) .unwrap(); assert_eq!(policy.enabled.len(), 2); assert!(policy.enabled.contains("alpha")); assert!(policy.enabled.contains("beta")); } #[test] fn global_enabled_skills_is_effective_when_no_other_levels() { let global = make_app_config(true, Some("alpha,beta"), Some(&["alpha", "beta", "gamma"])); let policy = SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed) .unwrap(); assert!(policy.enabled.contains("alpha")); assert!(policy.enabled.contains("beta")); assert!(!policy.enabled.contains("gamma")); } #[test] fn role_overrides_global_enabled_skills() { let global = make_app_config(true, Some("alpha"), Some(&["alpha", "beta"])); let role = Role::new("test", "---\nenabled_skills: beta\n---\nbody"); let policy = SkillPolicy::effective_with( &global, Some(&role), None, None, &always_true, &empty_installed, ) .unwrap(); assert!(policy.enabled.contains("beta")); assert!(!policy.enabled.contains("alpha")); } #[test] fn any_skills_enabled_false_disables_globally() { let global = make_app_config(true, None, None); let role = Role::new("test", "---\nskills_enabled: false\n---\nbody"); let policy = SkillPolicy::effective_with( &global, Some(&role), None, None, &always_true, &empty_installed, ) .unwrap(); assert!(!policy.skills_enabled); } #[test] fn allows_returns_false_when_skills_disabled() { let global = AppConfig { skills_enabled: false, ..AppConfig::default() }; let policy = SkillPolicy::effective_with(&global, None, None, None, &always_true, &|| { vec!["alpha".to_string()] }) .unwrap(); assert!(!policy.allows("alpha")); } #[test] fn allows_returns_true_when_skill_in_enabled_set() { let global = make_app_config(true, Some("alpha"), None); let policy = SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed) .unwrap(); assert!(policy.allows("alpha")); assert!(!policy.allows("beta")); } #[test] fn validation_rejects_uninstalled_skill_reference() { let global = make_app_config(true, Some("ghost"), None); let err = SkillPolicy::effective_with(&global, None, None, None, &|_| false, &empty_installed) .unwrap_err(); assert!(err.to_string().contains("not installed")); assert!(err.to_string().contains("ghost")); } #[test] fn validation_rejects_skill_not_in_visible_set() { let global = make_app_config(true, Some("beta"), Some(&["alpha"])); let err = SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed) .unwrap_err(); assert!( err.to_string() .contains("not in the global 'visible_skills'") ); assert!(err.to_string().contains("beta")); } #[test] fn validation_skipped_when_no_explicit_enabled_skills() { let global = make_app_config(true, None, None); let policy = SkillPolicy::effective_with(&global, None, None, None, &|_| false, &empty_installed) .unwrap(); assert!(policy.enabled.is_empty()); } #[test] fn empty_string_enabled_skills_resolves_to_empty_override() { let global = make_app_config(true, Some("alpha,beta"), Some(&["alpha", "beta"])); let role = Role::new("test", "---\nenabled_skills: \"\"\n---\nbody"); let policy = SkillPolicy::effective_with( &global, Some(&role), None, None, &always_true, &empty_installed, ) .unwrap(); assert!(policy.enabled.is_empty()); } }