claude_agent/common/
directory.rs

1use std::future::Future;
2use std::path::{Path, PathBuf};
3
4pub async fn load_files<T, F, Fut>(
5    dir: &Path,
6    filter: impl Fn(&Path) -> bool,
7    loader: F,
8) -> crate::Result<Vec<T>>
9where
10    F: Fn(PathBuf) -> Fut,
11    Fut: Future<Output = crate::Result<T>>,
12{
13    let mut items = Vec::new();
14
15    if !dir.exists() {
16        return Ok(items);
17    }
18
19    let mut entries = tokio::fs::read_dir(dir).await.map_err(|e| {
20        crate::Error::Config(format!("Failed to read directory {}: {}", dir.display(), e))
21    })?;
22
23    while let Some(entry) = entries
24        .next_entry()
25        .await
26        .map_err(|e| crate::Error::Config(format!("Failed to read directory entry: {}", e)))?
27    {
28        let path = entry.path();
29        if filter(&path) {
30            match loader(path.clone()).await {
31                Ok(item) => items.push(item),
32                Err(e) => tracing::warn!("Failed to load {}: {}", path.display(), e),
33            }
34        }
35    }
36
37    Ok(items)
38}
39
40pub fn is_markdown(path: &Path) -> bool {
41    path.extension().is_some_and(|e| e == "md")
42}
43
44pub fn is_skill_file(path: &Path) -> bool {
45    path.file_name()
46        .and_then(|n| n.to_str())
47        .is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md") || name.ends_with(".skill.md"))
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53    use std::path::PathBuf;
54
55    #[test]
56    fn test_is_markdown() {
57        assert!(is_markdown(Path::new("file.md")));
58        assert!(is_markdown(Path::new("/path/to/file.md")));
59        assert!(!is_markdown(Path::new("file.txt")));
60        assert!(!is_markdown(Path::new("file")));
61    }
62
63    #[test]
64    fn test_is_skill_file() {
65        assert!(is_skill_file(Path::new("SKILL.md")));
66        assert!(is_skill_file(Path::new("skill.md"))); // case insensitive
67        assert!(is_skill_file(Path::new("commit.skill.md")));
68        assert!(!is_skill_file(Path::new("README.md")));
69        assert!(!is_skill_file(Path::new("file.md")));
70    }
71
72    #[tokio::test]
73    async fn test_load_files_empty_dir() {
74        let result = load_files(
75            Path::new("/nonexistent/path"),
76            |_| true,
77            |_| async { Ok::<_, crate::Error>(()) },
78        )
79        .await
80        .unwrap();
81        assert!(result.is_empty());
82    }
83
84    #[tokio::test]
85    async fn test_load_files_from_temp() {
86        let temp = tempfile::tempdir().unwrap();
87        let file1 = temp.path().join("test.md");
88        let file2 = temp.path().join("test.txt");
89
90        tokio::fs::write(&file1, "content1").await.unwrap();
91        tokio::fs::write(&file2, "content2").await.unwrap();
92
93        let result: Vec<PathBuf> = load_files(temp.path(), is_markdown, |p| async move { Ok(p) })
94            .await
95            .unwrap();
96
97        assert_eq!(result.len(), 1);
98        assert!(result[0].ends_with("test.md"));
99    }
100}