mdvault_core/vault/
walker.rs1use 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#[derive(Debug, Clone)]
22pub struct WalkedFile {
23 pub absolute_path: PathBuf,
25 pub relative_path: PathBuf,
27 pub modified: SystemTime,
29 pub size: u64,
31}
32
33#[derive(Debug)]
35pub struct VaultWalker {
36 root: PathBuf,
37}
38
39impl VaultWalker {
40 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 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 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 if entry.depth() == 0 {
104 return false;
105 }
106
107 let name = entry.file_name().to_string_lossy();
108
109 if name.starts_with('.') {
111 return true;
112 }
113
114 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 fs::write(root.join("note1.md"), "# Note 1").unwrap();
130 fs::write(root.join("note2.md"), "# Note 2").unwrap();
131
132 fs::create_dir(root.join("subdir")).unwrap();
134 fs::write(root.join("subdir/note3.md"), "# Note 3").unwrap();
135
136 fs::create_dir(root.join(".hidden")).unwrap();
138 fs::write(root.join(".hidden/secret.md"), "# Secret").unwrap();
139
140 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}