use crate::skill::source::SkillSource;
use crate::skill::{
origin_precedence, DiagnosticLevel, LoadedSkill, SkillDiagnostic, SkillMetadata,
};
use std::collections::HashMap;
use tracing::debug;
pub struct SkillCatalog {
skills: HashMap<String, CatalogEntry>,
diagnostics: Vec<SkillDiagnostic>,
}
#[derive(Debug, Clone)]
struct CatalogEntry {
skill: LoadedSkill,
}
impl SkillCatalog {
pub fn new() -> Self {
Self {
skills: HashMap::new(),
diagnostics: Vec::new(),
}
}
pub fn discover(sources: &[Box<dyn SkillSource>]) -> Self {
let mut catalog = Self::new();
for source in sources.iter() {
let discovered = source.discover();
debug!(count = discovered.len(), "Discovered skills from source");
for (name, _metadata) in discovered {
if let Some(skill) = source.load(&name) {
catalog.insert(name, skill);
} else {
catalog.diagnostics.push(SkillDiagnostic {
level: DiagnosticLevel::Warning,
message: format!("Skill '{}' discovered but failed to load", name),
skill_name: Some(name),
});
}
}
let source_diagnostics = source.diagnostics();
catalog.diagnostics.extend(source_diagnostics);
}
catalog
}
pub fn load(&self, name: &str) -> Option<LoadedSkill> {
self.skills.get(name).map(|e| e.skill.clone())
}
pub fn list(&self) -> Vec<&SkillMetadata> {
self.skills.values().map(|e| &e.skill.metadata).collect()
}
pub fn list_all(&self) -> Vec<&LoadedSkill> {
self.skills.values().map(|e| &e.skill).collect()
}
pub fn get(&self, name: &str) -> Option<&SkillMetadata> {
self.skills.get(name).map(|e| &e.skill.metadata)
}
pub fn contains(&self, name: &str) -> bool {
self.skills.contains_key(name)
}
pub fn len(&self) -> usize {
self.skills.len()
}
pub fn is_empty(&self) -> bool {
self.skills.is_empty()
}
pub fn diagnostics(&self) -> &[SkillDiagnostic] {
&self.diagnostics
}
pub fn extend_diagnostics(&mut self, diagnostics: Vec<SkillDiagnostic>) {
self.diagnostics.extend(diagnostics);
}
pub fn register(&mut self, skill: LoadedSkill) {
self.insert(skill.metadata.id.clone(), skill);
}
fn insert(&mut self, name: String, skill: LoadedSkill) {
if let Some(existing) = self.skills.get(&name) {
let existing_prec = origin_precedence(existing.skill.metadata.origin);
let new_prec = origin_precedence(skill.metadata.origin);
if existing_prec >= new_prec {
self.diagnostics.push(SkillDiagnostic {
level: DiagnosticLevel::Info,
message: format!(
"Skill '{}' from {:?} was shadowed by {:?} (lower precedence)",
name, skill.metadata.origin, existing.skill.metadata.origin
),
skill_name: Some(name.clone()),
});
return;
} else {
self.diagnostics.push(SkillDiagnostic {
level: DiagnosticLevel::Info,
message: format!(
"Skill '{}' from {:?} replaced {:?} (higher precedence)",
name, skill.metadata.origin, existing.skill.metadata.origin
),
skill_name: Some(name.clone()),
});
}
}
self.skills.insert(name, CatalogEntry { skill });
}
}
impl Default for SkillCatalog {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::skill::source::StaticSkillSource;
use crate::skill::SkillOrigin;
fn make_skill(name: &str, origin: SkillOrigin) -> LoadedSkill {
LoadedSkill {
metadata: SkillMetadata {
id: name.to_string(),
display_name: name.to_string(),
description: format!("{} skill", name),
origin,
auto_activate: false,
tags: Vec::new(),
requires_tools: Vec::new(),
requires_capabilities: Vec::new(),
requires_trust: false,
},
location: None,
body: format!("# {}\nInstructions", name),
resources: Vec::new(),
}
}
#[test]
fn catalog_resolves_collisions_by_precedence() {
let mut user_source = StaticSkillSource::new();
user_source.register(make_skill("test", SkillOrigin::UserFilesystem));
let mut project_source = StaticSkillSource::new();
project_source.register(make_skill("test", SkillOrigin::ProjectFilesystem));
let sources: Vec<Box<dyn SkillSource>> =
vec![Box::new(user_source), Box::new(project_source)];
let catalog = SkillCatalog::discover(&sources);
assert_eq!(catalog.len(), 1);
let skill = catalog.get("test").unwrap();
assert_eq!(skill.origin, SkillOrigin::ProjectFilesystem);
}
#[test]
fn catalog_preserves_both_when_no_collision() {
let mut source1 = StaticSkillSource::new();
source1.register(make_skill("skill-a", SkillOrigin::UserFilesystem));
let mut source2 = StaticSkillSource::new();
source2.register(make_skill("skill-b", SkillOrigin::ProjectFilesystem));
let sources: Vec<Box<dyn SkillSource>> = vec![Box::new(source1), Box::new(source2)];
let catalog = SkillCatalog::discover(&sources);
assert_eq!(catalog.len(), 2);
assert!(catalog.contains("skill-a"));
assert!(catalog.contains("skill-b"));
}
#[test]
fn catalog_loads_from_correct_source() {
let mut source = StaticSkillSource::new();
source.register(make_skill("loadable", SkillOrigin::ClientProvided));
let sources: Vec<Box<dyn SkillSource>> = vec![Box::new(source)];
let catalog = SkillCatalog::discover(&sources);
let loaded = catalog.load("loadable").unwrap();
assert_eq!(loaded.metadata.display_name, "loadable");
}
#[test]
fn catalog_records_collision_diagnostics() {
let mut user_source = StaticSkillSource::new();
user_source.register(make_skill("test", SkillOrigin::UserFilesystem));
let mut project_source = StaticSkillSource::new();
project_source.register(make_skill("test", SkillOrigin::ProjectFilesystem));
let sources: Vec<Box<dyn SkillSource>> =
vec![Box::new(user_source), Box::new(project_source)];
let catalog = SkillCatalog::discover(&sources);
let diagnostics = catalog.diagnostics();
assert!(
!diagnostics.is_empty(),
"Should have recorded collision diagnostics"
);
let has_shadow_diagnostic = diagnostics
.iter()
.any(|d| d.message.contains("shadowed") || d.message.contains("replaced"));
assert!(
has_shadow_diagnostic,
"Should have a shadow/replace diagnostic: {:?}",
diagnostics
);
}
#[test]
fn catalog_list_returns_all_metadata() {
let mut source = StaticSkillSource::new();
source.register(make_skill("skill-a", SkillOrigin::ClientProvided));
source.register(make_skill("skill-b", SkillOrigin::ClientProvided));
let sources: Vec<Box<dyn SkillSource>> = vec![Box::new(source)];
let catalog = SkillCatalog::discover(&sources);
let list = catalog.list();
assert_eq!(list.len(), 2);
assert!(list.iter().any(|m| m.id == "skill-a"));
assert!(list.iter().any(|m| m.id == "skill-b"));
}
}