mdvault_core/vault/
walker.rs

1//! Recursive vault directory walker.
2
3use std::path::{Path, PathBuf};
4use std::time::SystemTime;
5use thiserror::Error;
6use walkdir::WalkDir;
7
8#[derive(Debug, Error)]
9pub enum VaultWalkerError {
10    #[error("vault root does not exist: {0}")]
11    MissingRoot(String),
12
13    #[error("failed to walk vault directory {0}: {1}")]
14    WalkError(String, #[source] walkdir::Error),
15
16    #[error("failed to read file metadata {0}: {1}")]
17    MetadataError(String, #[source] std::io::Error),
18}
19
20/// Information about a discovered markdown file.
21#[derive(Debug, Clone)]
22pub struct WalkedFile {
23    /// Absolute path to the file.
24    pub absolute_path: PathBuf,
25    /// Path relative to vault root.
26    pub relative_path: PathBuf,
27    /// File modification time.
28    pub modified: SystemTime,
29    /// File size in bytes.
30    pub size: u64,
31}
32
33/// Walker for discovering markdown files in a vault.
34#[derive(Debug)]
35pub struct VaultWalker {
36    root: PathBuf,
37}
38
39impl VaultWalker {
40    /// Create a new walker for the given vault root.
41    pub fn new(root: &Path) -> Result<Self, VaultWalkerError> {
42        let root = root
43            .canonicalize()
44            .map_err(|_| VaultWalkerError::MissingRoot(root.display().to_string()))?;
45
46        if !root.exists() {
47            return Err(VaultWalkerError::MissingRoot(root.display().to_string()));
48        }
49
50        Ok(Self { root })
51    }
52
53    /// Walk the vault and return all markdown files.
54    /// Excludes hidden directories and common non-vault directories.
55    pub fn walk(&self) -> Result<Vec<WalkedFile>, VaultWalkerError> {
56        let mut files = Vec::new();
57
58        for entry in WalkDir::new(&self.root)
59            .follow_links(false)
60            .into_iter()
61            .filter_entry(|e| !is_hidden_or_ignored(e))
62        {
63            let entry = entry.map_err(|e| {
64                VaultWalkerError::WalkError(self.root.display().to_string(), e)
65            })?;
66
67            let path = entry.path();
68            if !path.is_file() || !is_markdown_file(path) {
69                continue;
70            }
71
72            let metadata = path.metadata().map_err(|e| {
73                VaultWalkerError::MetadataError(path.display().to_string(), e)
74            })?;
75
76            let relative_path =
77                path.strip_prefix(&self.root).unwrap_or(path).to_path_buf();
78
79            files.push(WalkedFile {
80                absolute_path: path.to_path_buf(),
81                relative_path,
82                modified: metadata.modified().unwrap_or(std::time::UNIX_EPOCH),
83                size: metadata.len(),
84            });
85        }
86
87        files.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
88        Ok(files)
89    }
90
91    /// Get the vault root path.
92    pub fn root(&self) -> &Path {
93        &self.root
94    }
95}
96
97fn is_markdown_file(path: &Path) -> bool {
98    path.extension().and_then(|e| e.to_str()).is_some_and(|e| e == "md")
99}
100
101fn is_hidden_or_ignored(entry: &walkdir::DirEntry) -> bool {
102    // Never filter the root directory (depth 0)
103    if entry.depth() == 0 {
104        return false;
105    }
106
107    let name = entry.file_name().to_string_lossy();
108
109    // Skip hidden files and directories
110    if name.starts_with('.') {
111        return true;
112    }
113
114    // Skip common non-vault directories
115    matches!(name.as_ref(), "node_modules" | "target" | "__pycache__" | "venv")
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use std::fs;
122    use tempfile::TempDir;
123
124    fn create_test_vault() -> TempDir {
125        let dir = TempDir::new().unwrap();
126        let root = dir.path();
127
128        // Create some markdown files
129        fs::write(root.join("note1.md"), "# Note 1").unwrap();
130        fs::write(root.join("note2.md"), "# Note 2").unwrap();
131
132        // Create subdirectory with notes
133        fs::create_dir(root.join("subdir")).unwrap();
134        fs::write(root.join("subdir/note3.md"), "# Note 3").unwrap();
135
136        // Create hidden directory (should be skipped)
137        fs::create_dir(root.join(".hidden")).unwrap();
138        fs::write(root.join(".hidden/secret.md"), "# Secret").unwrap();
139
140        // Create non-markdown file (should be skipped)
141        fs::write(root.join("readme.txt"), "Not markdown").unwrap();
142
143        dir
144    }
145
146    #[test]
147    fn test_walk_finds_markdown_files() {
148        let vault = create_test_vault();
149        let walker = VaultWalker::new(vault.path()).unwrap();
150        let files = walker.walk().unwrap();
151
152        assert_eq!(files.len(), 3);
153
154        let paths: Vec<_> = files.iter().map(|f| f.relative_path.clone()).collect();
155        assert!(paths.contains(&PathBuf::from("note1.md")));
156        assert!(paths.contains(&PathBuf::from("note2.md")));
157        assert!(paths.contains(&PathBuf::from("subdir/note3.md")));
158    }
159
160    #[test]
161    fn test_walk_skips_hidden_directories() {
162        let vault = create_test_vault();
163        let walker = VaultWalker::new(vault.path()).unwrap();
164        let files = walker.walk().unwrap();
165
166        let paths: Vec<_> =
167            files.iter().map(|f| f.relative_path.to_string_lossy().to_string()).collect();
168
169        assert!(!paths.iter().any(|p| p.contains(".hidden")));
170    }
171
172    #[test]
173    fn test_walk_skips_non_markdown() {
174        let vault = create_test_vault();
175        let walker = VaultWalker::new(vault.path()).unwrap();
176        let files = walker.walk().unwrap();
177
178        let paths: Vec<_> =
179            files.iter().map(|f| f.relative_path.to_string_lossy().to_string()).collect();
180
181        assert!(!paths.iter().any(|p| p.contains("readme.txt")));
182    }
183
184    #[test]
185    fn test_walk_results_sorted() {
186        let vault = create_test_vault();
187        let walker = VaultWalker::new(vault.path()).unwrap();
188        let files = walker.walk().unwrap();
189
190        let paths: Vec<_> = files.iter().map(|f| &f.relative_path).collect();
191        let mut sorted = paths.clone();
192        sorted.sort();
193
194        assert_eq!(paths, sorted);
195    }
196
197    #[test]
198    fn test_missing_root() {
199        let result = VaultWalker::new(Path::new("/nonexistent/path"));
200        assert!(result.is_err());
201        assert!(matches!(result.unwrap_err(), VaultWalkerError::MissingRoot(_)));
202    }
203}