1use std::path::{Path, PathBuf};
7
8use async_trait::async_trait;
9
10use super::{Named, Provider, SourceType, is_markdown, load_files};
11
12#[async_trait]
17pub trait DocumentLoader<T: Send>: Clone + Send + Sync {
18 fn parse_content(&self, content: &str, path: Option<&Path>) -> crate::Result<T>;
20
21 fn doc_type_name(&self) -> &'static str;
23
24 fn file_filter(&self) -> fn(&Path) -> bool;
26
27 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 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 fn load_inline(&self, content: &str) -> crate::Result<T> {
56 self.parse_content(content, None)
57 }
58}
59
60pub trait LookupStrategy: Clone + Send + Sync {
66 fn config_subdir(&self) -> &'static str;
69
70 fn matches_entry(&self, path: &Path) -> bool;
72
73 fn find_by_name(&self, dir: &Path, name: &str) -> Option<PathBuf>;
76}
77
78#[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 if path.is_dir() {
94 let skill_file = path.join("SKILL.md");
95 return skill_file.exists();
96 }
97
98 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 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 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#[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#[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
168pub 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 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 pub fn with_path(mut self, path: impl Into<PathBuf>) -> Self {
223 self.paths.push(path.into());
224 self
225 }
226
227 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 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 pub fn with_priority(mut self, priority: i32) -> Self {
249 self.priority = priority;
250 self
251 }
252
253 pub fn with_source_type(mut self, source_type: SourceType) -> Self {
255 self.source_type = source_type;
256 self
257 }
258
259 pub fn paths(&self) -> &[PathBuf] {
261 &self.paths
262 }
263
264 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}