claude_agent/skills/
index_loader.rs

1//! Skill index loader for progressive disclosure.
2//!
3//! Parses skill files to extract metadata (frontmatter) only,
4//! without loading the full content into memory.
5
6use std::path::Path;
7
8use serde::{Deserialize, Serialize};
9
10use super::SkillIndex;
11use crate::common::{ContentSource, SourceType, is_skill_file, parse_frontmatter};
12
13/// Frontmatter schema for skill files.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct SkillFrontmatter {
16    pub name: String,
17    pub description: String,
18    #[serde(default)]
19    pub triggers: Vec<String>,
20    #[serde(default, alias = "allowed-tools")]
21    pub allowed_tools: Vec<String>,
22    #[serde(default)]
23    pub source_type: Option<String>,
24    #[serde(default)]
25    pub model: Option<String>,
26    #[serde(default, alias = "argument-hint")]
27    pub argument_hint: Option<String>,
28}
29
30/// Loader for creating SkillIndex entries from files.
31///
32/// This loader extracts only the frontmatter metadata, creating a lightweight
33/// index entry. The full content is loaded on-demand via ContentSource.
34#[derive(Debug, Clone, Copy, Default)]
35pub struct SkillIndexLoader;
36
37impl SkillIndexLoader {
38    /// Create a new skill index loader.
39    pub fn new() -> Self {
40        Self
41    }
42
43    /// Parse a skill file to create a SkillIndex.
44    ///
45    /// Only the frontmatter is parsed; the body is NOT loaded into memory.
46    /// Instead, a ContentSource::File is created for lazy loading.
47    pub fn parse_index(&self, content: &str, path: &Path) -> crate::Result<SkillIndex> {
48        let doc = parse_frontmatter::<SkillFrontmatter>(content)?;
49        Ok(self.build_index(doc.frontmatter, path))
50    }
51
52    /// Parse frontmatter only from content, returning the index.
53    pub fn parse_frontmatter_only(&self, content: &str, path: &Path) -> crate::Result<SkillIndex> {
54        self.parse_index(content, path)
55    }
56
57    fn build_index(&self, fm: SkillFrontmatter, path: &Path) -> SkillIndex {
58        let source_type = SourceType::from_str_opt(fm.source_type.as_deref());
59
60        let mut index = SkillIndex::new(fm.name, fm.description)
61            .with_source(ContentSource::file(path))
62            .with_source_type(source_type);
63
64        if !fm.triggers.is_empty() {
65            index = index.with_triggers(fm.triggers);
66        }
67
68        if !fm.allowed_tools.is_empty() {
69            index = index.with_allowed_tools(fm.allowed_tools);
70        }
71
72        if let Some(model) = fm.model {
73            index = index.with_model(model);
74        }
75
76        if let Some(hint) = fm.argument_hint {
77            index = index.with_argument_hint(hint);
78        }
79
80        index
81    }
82
83    /// Load a skill index from a file.
84    pub async fn load_file(&self, path: &Path) -> crate::Result<SkillIndex> {
85        let content = tokio::fs::read_to_string(path).await.map_err(|e| {
86            crate::Error::Config(format!("Failed to read skill file {:?}: {}", path, e))
87        })?;
88
89        self.parse_index(&content, path)
90    }
91
92    /// Scan a directory for skill files and create indices.
93    ///
94    /// This recursively scans the directory for skill files (.skill.md or SKILL.md)
95    /// and creates SkillIndex entries for each.
96    pub async fn scan_directory(&self, dir: &Path) -> crate::Result<Vec<SkillIndex>> {
97        let mut indices = Vec::new();
98
99        if !dir.exists() {
100            return Ok(indices);
101        }
102
103        self.scan_directory_recursive(dir, &mut indices).await?;
104        Ok(indices)
105    }
106
107    fn scan_directory_recursive<'a>(
108        &'a self,
109        dir: &'a Path,
110        indices: &'a mut Vec<SkillIndex>,
111    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::Result<()>> + Send + 'a>> {
112        Box::pin(async move {
113            let mut entries = tokio::fs::read_dir(dir).await.map_err(|e| {
114                crate::Error::Config(format!("Failed to read directory {:?}: {}", dir, e))
115            })?;
116
117            while let Some(entry) = entries.next_entry().await.map_err(|e| {
118                crate::Error::Config(format!("Failed to read directory entry: {}", e))
119            })? {
120                let path = entry.path();
121
122                if path.is_dir() {
123                    // Check for SKILL.md in subdirectory (skill folder pattern)
124                    let skill_file = path.join("SKILL.md");
125                    if skill_file.exists() {
126                        if let Ok(index) = self.load_file(&skill_file).await {
127                            indices.push(index);
128                        }
129                    } else {
130                        // Recurse into subdirectory
131                        self.scan_directory_recursive(&path, indices).await?;
132                    }
133                } else if is_skill_file(&path)
134                    && let Ok(index) = self.load_file(&path).await
135                {
136                    indices.push(index);
137                }
138            }
139
140            Ok(())
141        })
142    }
143
144    /// Create an inline skill index with content already available.
145    pub fn create_inline(
146        &self,
147        name: impl Into<String>,
148        description: impl Into<String>,
149        content: impl Into<String>,
150    ) -> SkillIndex {
151        SkillIndex::new(name, description).with_source(ContentSource::in_memory(content))
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn test_parse_frontmatter() {
161        let content = r#"---
162name: test-skill
163description: A test skill
164triggers:
165  - /test
166  - test please
167allowed-tools:
168  - Read
169  - Grep
170model: claude-haiku-4-5-20251001
171---
172
173This is the skill content that should NOT be loaded into memory during indexing.
174"#;
175
176        let loader = SkillIndexLoader::new();
177        let index = loader
178            .parse_index(content, Path::new("/skills/test.skill.md"))
179            .unwrap();
180
181        assert_eq!(index.name, "test-skill");
182        assert_eq!(index.description, "A test skill");
183        assert_eq!(index.triggers, vec!["/test", "test please"]);
184        assert_eq!(index.allowed_tools, vec!["Read", "Grep"]);
185        assert_eq!(index.model, Some("claude-haiku-4-5-20251001".to_string()));
186
187        // Verify source is file-based (for lazy loading)
188        assert!(index.source.is_file());
189    }
190
191    #[test]
192    fn test_parse_minimal_frontmatter() {
193        let content = r#"---
194name: minimal
195description: Minimal skill
196---
197
198Content here.
199"#;
200
201        let loader = SkillIndexLoader::new();
202        let index = loader
203            .parse_index(content, Path::new("/skills/minimal.skill.md"))
204            .unwrap();
205
206        assert_eq!(index.name, "minimal");
207        assert!(index.triggers.is_empty());
208        assert!(index.allowed_tools.is_empty());
209        assert!(index.model.is_none());
210    }
211
212    #[test]
213    fn test_create_inline() {
214        let loader = SkillIndexLoader::new();
215        let index = loader.create_inline("inline", "Inline skill", "Full content");
216
217        assert_eq!(index.name, "inline");
218        assert!(index.source.is_in_memory());
219    }
220
221    #[tokio::test]
222    async fn test_load_file() {
223        use std::io::Write;
224        use tempfile::NamedTempFile;
225
226        let mut file = NamedTempFile::with_suffix(".skill.md").unwrap();
227        writeln!(
228            file,
229            r#"---
230name: file-skill
231description: From file
232---
233
234Content."#
235        )
236        .unwrap();
237
238        let loader = SkillIndexLoader::new();
239        let index = loader.load_file(file.path()).await.unwrap();
240
241        assert_eq!(index.name, "file-skill");
242        assert!(index.source.is_file());
243    }
244
245    #[tokio::test]
246    async fn test_scan_directory() {
247        use tempfile::tempdir;
248        use tokio::fs;
249
250        let dir = tempdir().unwrap();
251
252        // Create test skill files
253        fs::write(
254            dir.path().join("skill1.skill.md"),
255            r#"---
256name: skill1
257description: First skill
258---
259Content 1"#,
260        )
261        .await
262        .unwrap();
263
264        fs::write(
265            dir.path().join("skill2.skill.md"),
266            r#"---
267name: skill2
268description: Second skill
269---
270Content 2"#,
271        )
272        .await
273        .unwrap();
274
275        let loader = SkillIndexLoader::new();
276        let indices = loader.scan_directory(dir.path()).await.unwrap();
277
278        assert_eq!(indices.len(), 2);
279        let names: Vec<&str> = indices.iter().map(|i| i.name.as_str()).collect();
280        assert!(names.contains(&"skill1"));
281        assert!(names.contains(&"skill2"));
282    }
283
284    #[tokio::test]
285    async fn test_scan_directory_with_skill_folder() {
286        use tempfile::tempdir;
287        use tokio::fs;
288
289        let dir = tempdir().unwrap();
290
291        // Create skill folder pattern
292        let skill_dir = dir.path().join("my-skill");
293        fs::create_dir(&skill_dir).await.unwrap();
294        fs::write(
295            skill_dir.join("SKILL.md"),
296            r#"---
297name: folder-skill
298description: From folder
299---
300Content"#,
301        )
302        .await
303        .unwrap();
304
305        let loader = SkillIndexLoader::new();
306        let indices = loader.scan_directory(dir.path()).await.unwrap();
307
308        assert_eq!(indices.len(), 1);
309        assert_eq!(indices[0].name, "folder-skill");
310    }
311
312    #[tokio::test]
313    async fn test_scan_nonexistent_directory() {
314        let loader = SkillIndexLoader::new();
315        let indices = loader
316            .scan_directory(Path::new("/nonexistent/path"))
317            .await
318            .unwrap();
319        assert!(indices.is_empty());
320    }
321}