1use rayon::prelude::*;
4use serde::{Deserialize, Serialize};
5use std::{
6 collections::HashMap,
7 fs, io,
8 path::{Path, PathBuf},
9 time::SystemTime,
10};
11
12#[derive(Serialize, Deserialize, Debug, Clone)]
14pub struct DirStat {
15 pub(crate) path: PathBuf,
16 pub(crate) total_size: u64, pub(crate) file_count: u64,
18 pub(crate) last_scan: SystemTime, pub(crate) children: HashMap<PathBuf, DirStat>,
20}
21
22impl DirStat {
23 pub fn total_size(&self) -> u64 {
25 self.total_size
26 }
27
28 pub fn file_count(&self) -> u64 {
30 self.file_count
31 }
32
33 pub fn last_scan(&self) -> SystemTime {
35 self.last_scan
36 }
37
38 pub fn path(&self) -> &Path {
40 &self.path
41 }
42}
43
44fn prune_deleted_dirs(cached: &mut DirStat) -> bool {
49 let mut found_deletions = false;
50
51 cached.children.retain(|child_path, child_stat| {
53 if !child_path.exists() {
54 found_deletions = true;
55 false } else {
57 if prune_deleted_dirs(child_stat) {
59 found_deletions = true;
60 }
61 true }
63 });
64
65 found_deletions
66}
67
68fn dir_changed_since_last_scan(path: &Path, cached: &DirStat) -> bool {
79 match fs::metadata(path).and_then(|m| m.modified()) {
81 Ok(mtime) => {
82 if mtime > cached.last_scan {
83 return true;
84 }
85 }
86 Err(_) => return true, }
88
89 for (child_path, child_stat) in &cached.children {
93 if dir_changed_since_last_scan(child_path, child_stat) {
94 return true;
95 }
96 }
97
98 false
99}
100
101pub fn scan_directory(path: &Path, cache: Option<&DirStat>) -> io::Result<DirStat> {
110 if let Some(cached) = cache {
112 let mut pruned_cache = cached.clone();
113 let had_deletions = prune_deleted_dirs(&mut pruned_cache);
114
115 if had_deletions {
117 let mut total_size = 0;
119 let mut file_count = 0;
120
121 for child in pruned_cache.children.values() {
122 total_size += child.total_size;
123 file_count += child.file_count;
124 }
125
126 if let Ok(entries) = fs::read_dir(path) {
128 for entry in entries.flatten() {
129 if let Ok(meta) = entry.metadata() {
130 if meta.is_file() {
131 total_size += meta.len();
132 file_count += 1;
133 }
134 }
135 }
136 }
137
138 pruned_cache.total_size = total_size;
139 pruned_cache.file_count = file_count;
140 pruned_cache.last_scan = SystemTime::now();
141 }
142
143 if !dir_changed_since_last_scan(path, &pruned_cache) {
145 return Ok(pruned_cache);
146 }
147 }
148
149 let mut total_size = 0;
150 let mut file_count = 0;
151 let mut children = HashMap::new();
152
153 let entries: Vec<_> = fs::read_dir(path)?.filter_map(|e| e.ok()).collect();
155
156 let mut subdirs = Vec::new();
158
159 for entry in entries {
160 let entry_path = entry.path();
161 if let Ok(meta) = entry.metadata() {
162 if meta.is_file() {
163 total_size += meta.len();
164 file_count += 1;
165 } else if meta.is_dir() {
166 subdirs.push(entry_path);
167 }
168 }
169 }
170
171 if subdirs.len() > 1 {
173 let results: Vec<_> = subdirs
174 .par_iter()
175 .filter_map(|entry_path| {
176 let child_cache = cache.and_then(|c| c.children.get(entry_path));
177 scan_directory(entry_path, child_cache).ok()
178 })
179 .collect();
180
181 for child_stat in results {
182 total_size += child_stat.total_size;
183 file_count += child_stat.file_count;
184 children.insert(child_stat.path.clone(), child_stat);
185 }
186 } else {
187 for entry_path in subdirs {
189 let child_cache = cache.and_then(|c| c.children.get(&entry_path));
190 if let Ok(child_stat) = scan_directory(&entry_path, child_cache) {
191 total_size += child_stat.total_size;
192 file_count += child_stat.file_count;
193 children.insert(entry_path, child_stat);
194 }
195 }
196 }
197
198 Ok(DirStat {
199 path: path.to_path_buf(),
200 total_size,
201 file_count,
202 last_scan: SystemTime::now(),
203 children,
204 })
205}
206
207pub fn count_files(path: &Path) -> io::Result<u64> {
209 let mut count = 0;
210
211 for entry in fs::read_dir(path)? {
212 let entry = entry?;
213 let meta = entry.metadata()?;
214
215 if meta.is_file() {
216 count += 1;
217 } else if meta.is_dir() {
218 count += count_files(&entry.path())?;
219 }
220 }
221
222 Ok(count)
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use tempfile::TempDir;
229
230 fn create_test_structure(base: &Path) -> io::Result<()> {
231 fs::create_dir_all(base.join("subdir1"))?;
232 fs::create_dir_all(base.join("subdir2/nested"))?;
233
234 fs::write(base.join("file1.txt"), "Hello World")?; fs::write(base.join("file2.txt"), "Test content")?; fs::write(base.join("subdir1/nested_file.txt"), "Nested content here")?; fs::write(base.join("subdir2/another.txt"), "More content")?; fs::write(base.join("subdir2/nested/deep.txt"), "Deep file content")?; Ok(())
241 }
242
243 #[test]
244 fn test_scan_directory() -> io::Result<()> {
245 let temp_dir = TempDir::new()?;
246 let test_dir = temp_dir.path().join("test");
247 fs::create_dir(&test_dir)?;
248
249 create_test_structure(&test_dir)?;
250
251 let result = scan_directory(&test_dir, None)?;
252
253 assert_eq!(result.total_size(), 71);
255 assert_eq!(result.file_count(), 5);
256 assert_eq!(result.children.len(), 2); Ok(())
259 }
260
261 #[test]
262 fn test_count_files() -> io::Result<()> {
263 let temp_dir = TempDir::new()?;
264 let test_dir = temp_dir.path().join("test");
265 fs::create_dir(&test_dir)?;
266
267 create_test_structure(&test_dir)?;
268
269 let count = count_files(&test_dir)?;
270 assert_eq!(count, 5);
271
272 Ok(())
273 }
274
275 #[test]
276 fn test_scan_with_cache() -> io::Result<()> {
277 let temp_dir = TempDir::new()?;
278 let test_dir = temp_dir.path().join("test");
279 fs::create_dir(&test_dir)?;
280
281 create_test_structure(&test_dir)?;
282
283 let stats1 = scan_directory(&test_dir, None)?;
285 let scan_time1 = stats1.last_scan();
286
287 let stats2 = scan_directory(&test_dir, Some(&stats1))?;
289 let scan_time2 = stats2.last_scan();
290
291 assert_eq!(scan_time1, scan_time2);
293
294 Ok(())
295 }
296
297 #[test]
298 fn test_detects_new_nested_subdirectory() -> io::Result<()> {
299 use std::thread::sleep;
300 use std::time::Duration;
301
302 let temp_dir = TempDir::new()?;
303 let test_dir = temp_dir.path().join("test");
304 fs::create_dir(&test_dir)?;
305
306 fs::create_dir(test_dir.join("a"))?;
308 fs::write(test_dir.join("a/file1.txt"), "content")?;
309
310 let stats1 = scan_directory(&test_dir, None)?;
312 assert_eq!(stats1.file_count(), 1);
313
314 sleep(Duration::from_millis(10));
316
317 fs::create_dir(test_dir.join("a/b"))?;
319 fs::write(test_dir.join("a/b/file2.txt"), "new content")?;
320
321 let stats2 = scan_directory(&test_dir, Some(&stats1))?;
323
324 assert_eq!(stats2.file_count(), 2);
326 assert!(
327 stats2.last_scan() > stats1.last_scan(),
328 "Should have rescanned since new subdirectory was added"
329 );
330
331 Ok(())
332 }
333
334 #[test]
335 fn test_detects_deleted_subdirectory() -> io::Result<()> {
336 use std::thread::sleep;
337 use std::time::Duration;
338
339 let temp_dir = TempDir::new()?;
340 let test_dir = temp_dir.path().join("test");
341 fs::create_dir(&test_dir)?;
342
343 fs::create_dir(test_dir.join("a"))?;
345 fs::create_dir(test_dir.join("b"))?;
346 fs::write(test_dir.join("a/file1.txt"), "content")?;
347 fs::write(test_dir.join("b/file2.txt"), "content")?;
348
349 let stats1 = scan_directory(&test_dir, None)?;
351 assert_eq!(stats1.file_count(), 2);
352
353 sleep(Duration::from_millis(10));
355
356 fs::remove_file(test_dir.join("b/file2.txt"))?;
358 fs::remove_dir(test_dir.join("b"))?;
359
360 let stats2 = scan_directory(&test_dir, Some(&stats1))?;
362
363 assert_eq!(stats2.file_count(), 1);
365 assert!(
366 stats2.last_scan() > stats1.last_scan(),
367 "Should have rescanned since subdirectory was deleted"
368 );
369
370 Ok(())
371 }
372
373 #[test]
374 fn test_prunes_deeply_nested_deleted_directory() -> io::Result<()> {
375 use std::thread::sleep;
376 use std::time::Duration;
377
378 let temp_dir = TempDir::new()?;
379 let test_dir = temp_dir.path().join("test");
380 fs::create_dir(&test_dir)?;
381
382 fs::create_dir_all(test_dir.join("a/b/c/d"))?;
384 fs::write(test_dir.join("a/file1.txt"), "content1")?;
385 fs::write(test_dir.join("a/b/file2.txt"), "content2")?;
386 fs::write(test_dir.join("a/b/c/file3.txt"), "content3")?;
387 fs::write(test_dir.join("a/b/c/d/file4.txt"), "content4")?;
388
389 let stats1 = scan_directory(&test_dir, None)?;
391 assert_eq!(stats1.file_count(), 4);
392
393 sleep(Duration::from_millis(10));
395
396 fs::remove_file(test_dir.join("a/b/c/d/file4.txt"))?;
398 fs::remove_dir(test_dir.join("a/b/c/d"))?;
399 fs::remove_file(test_dir.join("a/b/c/file3.txt"))?;
400 fs::remove_dir(test_dir.join("a/b/c"))?;
401
402 let stats2 = scan_directory(&test_dir, Some(&stats1))?;
404
405 assert_eq!(stats2.file_count(), 2);
407
408 let a_stats = stats2.children.get(&test_dir.join("a")).unwrap();
410 let b_stats = a_stats.children.get(&test_dir.join("a/b")).unwrap();
411 assert!(
412 !b_stats.children.contains_key(&test_dir.join("a/b/c")),
413 "Deleted directory c should be pruned from cache"
414 );
415
416 Ok(())
417 }
418}