Skip to main content

thulp_skill_files/
loader.rs

1//! Skill loader and directory scanner.
2//!
3//! Discovers and loads skills from configured directories with
4//! scope-based priority resolution.
5
6use crate::error::Result;
7use crate::parser::SkillFile;
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use walkdir::WalkDir;
11
12/// Scope levels for skill priority.
13///
14/// Higher scope values take precedence over lower ones when
15/// multiple skills have the same name.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
17pub enum SkillScope {
18    /// Lowest priority: project-level skills.
19    Project = 0,
20    /// Medium priority: user's personal skills.
21    Personal = 1,
22    /// Highest priority: enterprise/organization skills.
23    Enterprise = 2,
24    /// Plugin skills (namespaced, don't conflict).
25    Plugin = 3,
26}
27
28impl std::fmt::Display for SkillScope {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self {
31            SkillScope::Project => write!(f, "project"),
32            SkillScope::Personal => write!(f, "personal"),
33            SkillScope::Enterprise => write!(f, "enterprise"),
34            SkillScope::Plugin => write!(f, "plugin"),
35        }
36    }
37}
38
39/// Configuration for skill loading.
40#[derive(Debug, Clone)]
41pub struct SkillLoaderConfig {
42    /// Project skills directory (e.g., ./.claude/skills/).
43    pub project_dir: Option<PathBuf>,
44    /// Personal skills directory (e.g., ~/.claude/skills/).
45    pub personal_dir: Option<PathBuf>,
46    /// Enterprise skills directory.
47    pub enterprise_dir: Option<PathBuf>,
48    /// Plugin directories.
49    pub plugin_dirs: Vec<PathBuf>,
50    /// Maximum directory depth to scan.
51    pub max_depth: usize,
52}
53
54impl Default for SkillLoaderConfig {
55    fn default() -> Self {
56        Self {
57            project_dir: Some(PathBuf::from(".claude/skills")),
58            personal_dir: dirs::home_dir().map(|h| h.join(".claude/skills")),
59            enterprise_dir: None,
60            plugin_dirs: Vec::new(),
61            max_depth: 3,
62        }
63    }
64}
65
66impl SkillLoaderConfig {
67    /// Create a config with only project directory.
68    pub fn project_only(path: impl Into<PathBuf>) -> Self {
69        Self {
70            project_dir: Some(path.into()),
71            personal_dir: None,
72            enterprise_dir: None,
73            plugin_dirs: Vec::new(),
74            max_depth: 3,
75        }
76    }
77
78    /// Create a config for testing with a single directory.
79    pub fn single(path: impl Into<PathBuf>) -> Self {
80        Self::project_only(path)
81    }
82}
83
84/// Loaded skill with scope information.
85#[derive(Debug, Clone)]
86pub struct LoadedSkill {
87    /// The parsed skill file.
88    pub file: SkillFile,
89    /// Scope level of the skill.
90    pub scope: SkillScope,
91    /// For plugins, the plugin name prefix.
92    pub namespace: Option<String>,
93}
94
95impl LoadedSkill {
96    /// Get the fully qualified name (with namespace if applicable).
97    pub fn qualified_name(&self) -> String {
98        match &self.namespace {
99            Some(ns) => format!("{}:{}", ns, self.file.effective_name()),
100            None => self.file.effective_name(),
101        }
102    }
103
104    /// Get the effective description.
105    pub fn effective_description(&self) -> String {
106        self.file.effective_description()
107    }
108
109    /// Check if this skill can be invoked by the model.
110    pub fn is_model_invocable(&self) -> bool {
111        !self.file.frontmatter.disable_model_invocation
112    }
113
114    /// Check if this skill can be invoked by the user.
115    pub fn is_user_invocable(&self) -> bool {
116        self.file.frontmatter.user_invocable
117    }
118}
119
120/// Skill loader that discovers and loads skills from configured directories.
121pub struct SkillLoader {
122    config: SkillLoaderConfig,
123}
124
125impl SkillLoader {
126    /// Create a new skill loader with the given configuration.
127    pub fn new(config: SkillLoaderConfig) -> Self {
128        Self { config }
129    }
130
131    /// Create a skill loader with default configuration.
132    pub fn with_defaults() -> Self {
133        Self::new(SkillLoaderConfig::default())
134    }
135
136    /// Load all skills from configured directories.
137    pub fn load_all(&self) -> Result<Vec<LoadedSkill>> {
138        let mut skills = Vec::new();
139
140        // Load in priority order (lowest first, so higher priority overwrites)
141        if let Some(ref dir) = self.config.project_dir {
142            skills.extend(self.load_from_directory(dir, SkillScope::Project, None)?);
143        }
144
145        if let Some(ref dir) = self.config.personal_dir {
146            skills.extend(self.load_from_directory(dir, SkillScope::Personal, None)?);
147        }
148
149        if let Some(ref dir) = self.config.enterprise_dir {
150            skills.extend(self.load_from_directory(dir, SkillScope::Enterprise, None)?);
151        }
152
153        // Load plugins with namespace
154        for plugin_dir in &self.config.plugin_dirs {
155            if let Some(plugin_name) = plugin_dir.file_name().and_then(|n| n.to_str()) {
156                let skills_dir = plugin_dir.join("skills");
157                if skills_dir.exists() {
158                    skills.extend(self.load_from_directory(
159                        &skills_dir,
160                        SkillScope::Plugin,
161                        Some(plugin_name.to_string()),
162                    )?);
163                }
164            }
165        }
166
167        Ok(skills)
168    }
169
170    /// Load skills from a single directory.
171    fn load_from_directory(
172        &self,
173        dir: &Path,
174        scope: SkillScope,
175        namespace: Option<String>,
176    ) -> Result<Vec<LoadedSkill>> {
177        let mut skills = Vec::new();
178
179        if !dir.exists() {
180            return Ok(skills);
181        }
182
183        for entry in WalkDir::new(dir)
184            .max_depth(self.config.max_depth)
185            .into_iter()
186            .filter_map(|e| e.ok())
187        {
188            let path = entry.path();
189            if path.is_file() && path.file_name() == Some(std::ffi::OsStr::new("SKILL.md")) {
190                match SkillFile::parse(path) {
191                    Ok(file) => {
192                        skills.push(LoadedSkill {
193                            file,
194                            scope,
195                            namespace: namespace.clone(),
196                        });
197                    }
198                    Err(e) => {
199                        // Log warning but continue loading other skills
200                        eprintln!("Warning: Failed to load skill at {:?}: {}", path, e);
201                    }
202                }
203            }
204        }
205
206        Ok(skills)
207    }
208
209    /// Resolve skills by priority (higher scope wins for same name).
210    pub fn resolve_priority(skills: Vec<LoadedSkill>) -> HashMap<String, LoadedSkill> {
211        let mut resolved: HashMap<String, LoadedSkill> = HashMap::new();
212
213        // Sort by scope (lowest first)
214        let mut sorted = skills;
215        sorted.sort_by_key(|s| s.scope);
216
217        for skill in sorted {
218            let name = skill.qualified_name();
219            // Higher scope always wins
220            if let Some(existing) = resolved.get(&name) {
221                if skill.scope >= existing.scope {
222                    resolved.insert(name, skill);
223                }
224            } else {
225                resolved.insert(name, skill);
226            }
227        }
228
229        resolved
230    }
231
232    /// Find a skill by name.
233    pub fn find_skill<'a>(skills: &'a [LoadedSkill], name: &str) -> Option<&'a LoadedSkill> {
234        skills
235            .iter()
236            .find(|s| s.file.effective_name() == name || s.qualified_name() == name)
237    }
238
239    /// Filter skills that are invocable by the model.
240    pub fn model_invocable(skills: &[LoadedSkill]) -> Vec<&LoadedSkill> {
241        skills.iter().filter(|s| s.is_model_invocable()).collect()
242    }
243
244    /// Filter skills that are invocable by the user.
245    pub fn user_invocable(skills: &[LoadedSkill]) -> Vec<&LoadedSkill> {
246        skills.iter().filter(|s| s.is_user_invocable()).collect()
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn test_skill_scope_ordering() {
256        assert!(SkillScope::Enterprise > SkillScope::Personal);
257        assert!(SkillScope::Personal > SkillScope::Project);
258        assert!(SkillScope::Plugin > SkillScope::Enterprise);
259    }
260
261    #[test]
262    fn test_qualified_name_no_namespace() {
263        let skill = LoadedSkill {
264            file: create_mock_skill_file("test-skill"),
265            scope: SkillScope::Project,
266            namespace: None,
267        };
268        assert_eq!(skill.qualified_name(), "test-skill");
269    }
270
271    #[test]
272    fn test_qualified_name_with_namespace() {
273        let skill = LoadedSkill {
274            file: create_mock_skill_file("helper"),
275            scope: SkillScope::Plugin,
276            namespace: Some("myplugin".to_string()),
277        };
278        assert_eq!(skill.qualified_name(), "myplugin:helper");
279    }
280
281    #[test]
282    fn test_resolve_priority() {
283        let project_skill = LoadedSkill {
284            file: create_mock_skill_file("shared"),
285            scope: SkillScope::Project,
286            namespace: None,
287        };
288        let personal_skill = LoadedSkill {
289            file: create_mock_skill_file("shared"),
290            scope: SkillScope::Personal,
291            namespace: None,
292        };
293
294        let skills = vec![project_skill, personal_skill];
295        let resolved = SkillLoader::resolve_priority(skills);
296
297        // Personal should win over project
298        assert_eq!(resolved.get("shared").unwrap().scope, SkillScope::Personal);
299    }
300
301    fn create_mock_skill_file(name: &str) -> SkillFile {
302        use crate::frontmatter::SkillFrontmatter;
303
304        SkillFile {
305            frontmatter: SkillFrontmatter {
306                name: Some(name.to_string()),
307                ..Default::default()
308            },
309            content: String::new(),
310            path: PathBuf::new(),
311            directory: PathBuf::new(),
312            supporting_files: Vec::new(),
313        }
314    }
315}