claude_agent/common/
file_provider.rs

1//! Generic file-based provider for loading items from the filesystem.
2//!
3//! This module provides a reusable `FileProvider<T, L>` that can be used to load
4//! any type T from markdown files using a configurable loader L.
5
6use std::path::{Path, PathBuf};
7
8use async_trait::async_trait;
9
10use super::{Named, Provider, SourceType, is_markdown, load_files};
11
12/// Trait for loading items from files.
13///
14/// Implementors only need to implement `parse_content()`, `doc_type_name()`, and `file_filter()`.
15/// The `load_file()` and `load_directory()` methods have default implementations.
16#[async_trait]
17pub trait DocumentLoader<T: Send>: Clone + Send + Sync {
18    /// Parse content into the target type.
19    fn parse_content(&self, content: &str, path: Option<&Path>) -> crate::Result<T>;
20
21    /// Document type name for error messages (e.g., "skill", "subagent", "output style").
22    fn doc_type_name(&self) -> &'static str;
23
24    /// File filter function for directory loading.
25    fn file_filter(&self) -> fn(&Path) -> bool;
26
27    /// Load an item from a file path.
28    async fn load_file(&self, path: &Path) -> crate::Result<T> {
29        let content = tokio::fs::read_to_string(path).await.map_err(|e| {
30            crate::Error::Config(format!(
31                "Failed to read {} file {}: {}",
32                self.doc_type_name(),
33                path.display(),
34                e
35            ))
36        })?;
37        self.parse_content(&content, Some(path))
38    }
39
40    /// Load all items from a directory.
41    async fn load_directory(&self, dir: &Path) -> crate::Result<Vec<T>>
42    where
43        T: 'static,
44    {
45        let loader = self.clone();
46        let filter = self.file_filter();
47        load_files(dir, filter, move |p| {
48            let l = loader.clone();
49            async move { l.load_file(&p).await }
50        })
51        .await
52    }
53
54    /// Load from inline content.
55    fn load_inline(&self, content: &str) -> crate::Result<T> {
56        self.parse_content(content, None)
57    }
58}
59
60/// Trait for defining file lookup strategies.
61///
62/// Different item types may have different file naming conventions.
63/// For example, skills use `SKILL.md` in subdirectories or `*.skill.md`,
64/// while subagents use simple `*.md` files.
65pub trait LookupStrategy: Clone + Send + Sync {
66    /// Get the subdirectory name within `.claude/` for this item type.
67    /// e.g., "skills" or "agents"
68    fn config_subdir(&self) -> &'static str;
69
70    /// Check if a directory entry matches the lookup pattern.
71    fn matches_entry(&self, path: &Path) -> bool;
72
73    /// Try to find a file for a specific item name in a directory.
74    /// Returns the file path if found, None otherwise.
75    fn find_by_name(&self, dir: &Path, name: &str) -> Option<PathBuf>;
76}
77
78/// Lookup strategy for skills.
79///
80/// Skills can be defined as:
81/// - `<name>/SKILL.md` - A directory with a SKILL.md file
82/// - `<name>.skill.md` - A standalone skill file
83#[derive(Debug, Clone, Default)]
84pub struct SkillLookupStrategy;
85
86impl LookupStrategy for SkillLookupStrategy {
87    fn config_subdir(&self) -> &'static str {
88        "skills"
89    }
90
91    fn matches_entry(&self, path: &Path) -> bool {
92        // Match directories that contain SKILL.md
93        if path.is_dir() {
94            let skill_file = path.join("SKILL.md");
95            return skill_file.exists();
96        }
97
98        // Match *.skill.md files
99        path.extension().is_some_and(|e| e == "md")
100            && path
101                .file_name()
102                .is_some_and(|n| n.to_string_lossy().ends_with(".skill.md"))
103    }
104
105    fn find_by_name(&self, dir: &Path, name: &str) -> Option<PathBuf> {
106        // Check for directory-based skill first: <name>/SKILL.md
107        let skill_dir = dir.join(name);
108        let skill_file = skill_dir.join("SKILL.md");
109        if skill_file.exists() {
110            return Some(skill_file);
111        }
112
113        // Check for file-based skill: <name>.skill.md
114        let skill_file = dir.join(format!("{}.skill.md", name));
115        if skill_file.exists() {
116            return Some(skill_file);
117        }
118
119        None
120    }
121}
122
123fn is_markdown_file(path: &Path) -> bool {
124    path.is_file() && is_markdown(path)
125}
126
127fn find_markdown_by_name(dir: &Path, name: &str) -> Option<PathBuf> {
128    let file = dir.join(format!("{}.md", name));
129    file.exists().then_some(file)
130}
131
132/// Lookup strategy for subagents (`<name>.md` in `.claude/agents/`).
133#[derive(Debug, Clone, Copy, Default)]
134pub struct SubagentLookupStrategy;
135
136impl LookupStrategy for SubagentLookupStrategy {
137    fn config_subdir(&self) -> &'static str {
138        "agents"
139    }
140
141    fn matches_entry(&self, path: &Path) -> bool {
142        is_markdown_file(path)
143    }
144
145    fn find_by_name(&self, dir: &Path, name: &str) -> Option<PathBuf> {
146        find_markdown_by_name(dir, name)
147    }
148}
149
150/// Lookup strategy for output styles (`<name>.md` in `.claude/output-styles/`).
151#[derive(Debug, Clone, Copy, Default)]
152pub struct OutputStyleLookupStrategy;
153
154impl LookupStrategy for OutputStyleLookupStrategy {
155    fn config_subdir(&self) -> &'static str {
156        "output-styles"
157    }
158
159    fn matches_entry(&self, path: &Path) -> bool {
160        is_markdown_file(path)
161    }
162
163    fn find_by_name(&self, dir: &Path, name: &str) -> Option<PathBuf> {
164        find_markdown_by_name(dir, name)
165    }
166}
167
168/// Generic file-based provider for loading items from the filesystem.
169///
170/// This provider supports:
171/// - Multiple search paths with priority ordering
172/// - Configurable lookup strategies for different file patterns
173/// - Builder pattern for configuration
174///
175/// # Type Parameters
176///
177/// - `T`: The item type to load (must implement `Named + Clone + Send + Sync`)
178/// - `L`: The loader type (must implement `DocumentLoader<T>`)
179/// - `S`: The lookup strategy (must implement `LookupStrategy`)
180///
181/// # Example
182///
183/// ```ignore
184/// let provider = FileProvider::new(SkillLoader::new(), SkillLookupStrategy)
185///     .with_project_path(project_dir)
186///     .with_user_path()
187///     .with_priority(10);
188/// ```
189pub struct FileProvider<T, L, S>
190where
191    T: Named + Clone + Send + Sync,
192    L: DocumentLoader<T>,
193    S: LookupStrategy,
194{
195    paths: Vec<PathBuf>,
196    priority: i32,
197    source_type: SourceType,
198    loader: L,
199    strategy: S,
200    _marker: std::marker::PhantomData<T>,
201}
202
203impl<T, L, S> FileProvider<T, L, S>
204where
205    T: Named + Clone + Send + Sync,
206    L: DocumentLoader<T>,
207    S: LookupStrategy,
208{
209    /// Create a new file provider with the given loader and lookup strategy.
210    pub fn new(loader: L, strategy: S) -> Self {
211        Self {
212            paths: Vec::new(),
213            priority: 0,
214            source_type: SourceType::Project,
215            loader,
216            strategy,
217            _marker: std::marker::PhantomData,
218        }
219    }
220
221    /// Add a path to search for items.
222    pub fn with_path(mut self, path: impl Into<PathBuf>) -> Self {
223        self.paths.push(path.into());
224        self
225    }
226
227    /// Add the project-specific path (e.g., `<project>/.claude/skills`).
228    pub fn with_project_path(mut self, project_dir: &Path) -> Self {
229        self.paths.push(
230            project_dir
231                .join(".claude")
232                .join(self.strategy.config_subdir()),
233        );
234        self
235    }
236
237    /// Add the user-specific path (e.g., `~/.claude/skills`).
238    pub fn with_user_path(mut self) -> Self {
239        if let Some(home) = super::home_dir() {
240            self.paths
241                .push(home.join(".claude").join(self.strategy.config_subdir()));
242        }
243        self.source_type = SourceType::User;
244        self
245    }
246
247    /// Set the priority of this provider.
248    pub fn with_priority(mut self, priority: i32) -> Self {
249        self.priority = priority;
250        self
251    }
252
253    /// Set the source type for items loaded by this provider.
254    pub fn with_source_type(mut self, source_type: SourceType) -> Self {
255        self.source_type = source_type;
256        self
257    }
258
259    /// Get the configured paths.
260    pub fn paths(&self) -> &[PathBuf] {
261        &self.paths
262    }
263
264    /// Get the loader reference.
265    pub fn loader(&self) -> &L {
266        &self.loader
267    }
268}
269
270impl<T, L, S> Default for FileProvider<T, L, S>
271where
272    T: Named + Clone + Send + Sync,
273    L: DocumentLoader<T> + Default,
274    S: LookupStrategy + Default,
275{
276    fn default() -> Self {
277        Self::new(L::default(), S::default())
278    }
279}
280
281#[async_trait]
282impl<T, L, S> Provider<T> for FileProvider<T, L, S>
283where
284    T: Named + Clone + Send + Sync + 'static,
285    L: DocumentLoader<T> + 'static,
286    S: LookupStrategy + 'static,
287{
288    fn provider_name(&self) -> &str {
289        "file"
290    }
291
292    fn priority(&self) -> i32 {
293        self.priority
294    }
295
296    fn source_type(&self) -> SourceType {
297        self.source_type
298    }
299
300    async fn list(&self) -> crate::Result<Vec<String>> {
301        let items = self.load_all().await?;
302        Ok(items
303            .into_iter()
304            .map(|item| item.name().to_string())
305            .collect())
306    }
307
308    async fn get(&self, name: &str) -> crate::Result<Option<T>> {
309        for path in &self.paths {
310            if !path.exists() {
311                continue;
312            }
313
314            if let Some(file_path) = self.strategy.find_by_name(path, name) {
315                return Ok(Some(self.loader.load_file(&file_path).await?));
316            }
317        }
318        Ok(None)
319    }
320
321    async fn load_all(&self) -> crate::Result<Vec<T>> {
322        let mut items = Vec::new();
323
324        for path in &self.paths {
325            if path.exists() {
326                let loaded = self.loader.load_directory(path).await?;
327                items.extend(loaded);
328            }
329        }
330
331        Ok(items)
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[derive(Debug, Clone)]
340    struct TestItem {
341        name: String,
342        content: String,
343    }
344
345    impl Named for TestItem {
346        fn name(&self) -> &str {
347            &self.name
348        }
349    }
350
351    fn is_test_markdown(path: &Path) -> bool {
352        path.extension().is_some_and(|e| e == "md")
353    }
354
355    #[derive(Clone, Default)]
356    struct TestLoader;
357
358    impl DocumentLoader<TestItem> for TestLoader {
359        fn parse_content(&self, content: &str, path: Option<&Path>) -> crate::Result<TestItem> {
360            let name = path
361                .and_then(|p| p.file_stem())
362                .and_then(|s| s.to_str())
363                .unwrap_or("unknown")
364                .to_string();
365            Ok(TestItem {
366                name,
367                content: content.to_string(),
368            })
369        }
370
371        fn doc_type_name(&self) -> &'static str {
372            "test"
373        }
374
375        fn file_filter(&self) -> fn(&Path) -> bool {
376            is_test_markdown
377        }
378    }
379
380    #[derive(Clone, Default)]
381    struct TestLookupStrategy;
382
383    impl LookupStrategy for TestLookupStrategy {
384        fn config_subdir(&self) -> &'static str {
385            "test"
386        }
387
388        fn matches_entry(&self, path: &Path) -> bool {
389            path.extension().is_some_and(|e| e == "md")
390        }
391
392        fn find_by_name(&self, dir: &Path, name: &str) -> Option<PathBuf> {
393            let file = dir.join(format!("{}.md", name));
394            if file.exists() { Some(file) } else { None }
395        }
396    }
397
398    #[test]
399    fn test_skill_lookup_strategy() {
400        let strategy = SkillLookupStrategy;
401        assert_eq!(strategy.config_subdir(), "skills");
402    }
403
404    #[test]
405    fn test_subagent_lookup_strategy() {
406        let strategy = SubagentLookupStrategy;
407        assert_eq!(strategy.config_subdir(), "agents");
408    }
409
410    #[tokio::test]
411    async fn test_file_provider_empty() {
412        let provider: FileProvider<TestItem, TestLoader, TestLookupStrategy> =
413            FileProvider::default();
414
415        let items = provider.load_all().await.unwrap();
416        assert!(items.is_empty());
417    }
418
419    #[tokio::test]
420    async fn test_file_provider_with_temp_dir() {
421        let temp = tempfile::tempdir().unwrap();
422        let file = temp.path().join("test.md");
423        tokio::fs::write(&file, "test content").await.unwrap();
424
425        let provider: FileProvider<TestItem, TestLoader, TestLookupStrategy> =
426            FileProvider::new(TestLoader, TestLookupStrategy).with_path(temp.path());
427
428        let items = provider.load_all().await.unwrap();
429        assert_eq!(items.len(), 1);
430        assert_eq!(items[0].name, "test");
431        assert_eq!(items[0].content, "test content");
432    }
433
434    #[tokio::test]
435    async fn test_file_provider_get_by_name() {
436        let temp = tempfile::tempdir().unwrap();
437        let file = temp.path().join("myitem.md");
438        tokio::fs::write(&file, "my content").await.unwrap();
439
440        let provider: FileProvider<TestItem, TestLoader, TestLookupStrategy> =
441            FileProvider::new(TestLoader, TestLookupStrategy).with_path(temp.path());
442
443        let item = provider.get("myitem").await.unwrap();
444        assert!(item.is_some());
445        assert_eq!(item.unwrap().name, "myitem");
446
447        let missing = provider.get("nonexistent").await.unwrap();
448        assert!(missing.is_none());
449    }
450
451    #[tokio::test]
452    async fn test_file_provider_priority_and_source() {
453        let provider: FileProvider<TestItem, TestLoader, TestLookupStrategy> =
454            FileProvider::default()
455                .with_priority(42)
456                .with_source_type(SourceType::Builtin);
457
458        assert_eq!(provider.priority(), 42);
459        assert_eq!(provider.source_type(), SourceType::Builtin);
460        assert_eq!(provider.provider_name(), "file");
461    }
462}