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#[cfg(unix)]
13use std::os::unix::fs::MetadataExt;
14
15fn get_block_size(meta: &fs::Metadata) -> u64 {
21 #[cfg(unix)]
22 {
23 meta.blocks() * 512
24 }
25 #[cfg(not(unix))]
26 {
27 meta.len()
28 }
29}
30
31#[derive(Serialize, Deserialize, Debug, Clone)]
33pub struct DirStat {
34 pub(crate) path: PathBuf, pub(crate) total_size: u64, pub(crate) file_count: u64, pub(crate) last_scan: SystemTime, pub(crate) children: HashMap<PathBuf, DirStat>, }
40
41impl DirStat {
42 pub fn total_size(&self) -> u64 {
44 self.total_size
45 }
46
47 pub fn file_count(&self) -> u64 {
49 self.file_count
50 }
51
52 pub fn last_scan(&self) -> SystemTime {
54 self.last_scan
55 }
56
57 pub fn path(&self) -> &Path {
59 &self.path
60 }
61}
62
63fn dir_changed_since_last_scan(path: &Path, cached: &DirStat) -> bool {
72 match fs::metadata(path).and_then(|m| m.modified()) {
74 Ok(mtime) => {
75 if mtime > cached.last_scan {
76 return true;
77 }
78 }
79 Err(_) => return true, }
81
82 cached
87 .children
88 .par_iter()
89 .any(|(child_path, child_stat)| dir_changed_since_last_scan(child_path, child_stat))
90}
91
92pub fn scan_directory(path: &Path, cache: Option<&DirStat>) -> io::Result<DirStat> {
101 if let Some(cached) = cache {
103 if !dir_changed_since_last_scan(path, cached) {
106 return Ok(cached.clone());
107 }
108 }
109
110 let mut total_size = 0;
111 let mut file_count = 0;
112 let mut children = HashMap::new();
113
114 let entries: Vec<_> = fs::read_dir(path)?.filter_map(|e| e.ok()).collect();
116
117 let mut subdirs = Vec::new();
119
120 for entry in entries {
121 let entry_path = entry.path();
122 if let Ok(meta) = entry.metadata() {
123 if meta.is_file() {
124 total_size += get_block_size(&meta);
125 file_count += 1;
126 } else if meta.is_dir() {
127 subdirs.push(entry_path);
128 }
129 }
130 }
131
132 if subdirs.len() > 1 {
134 let results: Vec<_> = subdirs
135 .par_iter()
136 .filter_map(|entry_path| {
137 let child_cache = cache.and_then(|c| c.children.get(entry_path));
138 scan_directory(entry_path, child_cache).ok()
139 })
140 .collect();
141
142 for child_stat in results {
143 total_size += child_stat.total_size;
144 file_count += child_stat.file_count;
145 children.insert(child_stat.path.clone(), child_stat);
146 }
147 } else {
148 for entry_path in subdirs {
150 let child_cache = cache.and_then(|c| c.children.get(&entry_path));
151 if let Ok(child_stat) = scan_directory(&entry_path, child_cache) {
152 total_size += child_stat.total_size;
153 file_count += child_stat.file_count;
154 children.insert(entry_path, child_stat);
155 }
156 }
157 }
158
159 Ok(DirStat {
160 path: path.to_path_buf(),
161 total_size,
162 file_count,
163 last_scan: SystemTime::now(),
164 children,
165 })
166}
167
168pub fn count_files(path: &Path) -> io::Result<u64> {
170 let mut count = 0;
171
172 for entry in fs::read_dir(path)? {
173 let entry = entry?;
174 let meta = entry.metadata()?;
175
176 if meta.is_file() {
177 count += 1;
178 } else if meta.is_dir() {
179 count += count_files(&entry.path())?;
180 }
181 }
182
183 Ok(count)
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189 use tempfile::TempDir;
190
191 fn create_test_structure(base: &Path) -> io::Result<()> {
192 fs::create_dir_all(base.join("subdir1"))?;
193 fs::create_dir_all(base.join("subdir2/nested"))?;
194
195 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(())
202 }
203
204 #[test]
205 fn test_scan_directory() -> io::Result<()> {
206 let temp_dir = TempDir::new()?;
211 let test_dir = temp_dir.path().join("test");
212 fs::create_dir(&test_dir)?;
213
214 create_test_structure(&test_dir)?;
215
216 let result = scan_directory(&test_dir, None)?;
217
218 assert!(result.total_size() >= 71);
221 assert_eq!(result.file_count(), 5);
222 assert_eq!(result.children.len(), 2); Ok(())
225 }
226
227 #[test]
228 fn test_count_files() -> io::Result<()> {
229 let temp_dir = TempDir::new()?;
232 let test_dir = temp_dir.path().join("test");
233 fs::create_dir(&test_dir)?;
234
235 create_test_structure(&test_dir)?;
236
237 let count = count_files(&test_dir)?;
238 assert_eq!(count, 5);
239
240 Ok(())
241 }
242
243 #[test]
244 fn test_scan_with_cache() -> io::Result<()> {
245 let temp_dir = TempDir::new()?;
250 let test_dir = temp_dir.path().join("test");
251 fs::create_dir(&test_dir)?;
252
253 create_test_structure(&test_dir)?;
254
255 let stats1 = scan_directory(&test_dir, None)?;
257 let scan_time1 = stats1.last_scan();
258
259 let stats2 = scan_directory(&test_dir, Some(&stats1))?;
261 let scan_time2 = stats2.last_scan();
262
263 assert_eq!(scan_time1, scan_time2);
265
266 Ok(())
267 }
268
269 #[test]
270 fn test_detects_new_nested_subdirectory() -> io::Result<()> {
271 use std::thread::sleep;
276 use std::time::Duration;
277
278 let temp_dir = TempDir::new()?;
279 let test_dir = temp_dir.path().join("test");
280 fs::create_dir(&test_dir)?;
281
282 fs::create_dir(test_dir.join("a"))?;
284 fs::write(test_dir.join("a/file1.txt"), "content")?;
285
286 let stats1 = scan_directory(&test_dir, None)?;
288 assert_eq!(stats1.file_count(), 1);
289
290 sleep(Duration::from_millis(10));
292
293 fs::create_dir(test_dir.join("a/b"))?;
295 fs::write(test_dir.join("a/b/file2.txt"), "new content")?;
296
297 let stats2 = scan_directory(&test_dir, Some(&stats1))?;
299
300 assert_eq!(stats2.file_count(), 2);
302 assert!(
303 stats2.last_scan() > stats1.last_scan(),
304 "Should have rescanned since new subdirectory was added"
305 );
306
307 Ok(())
308 }
309
310 #[test]
311 fn test_detects_deleted_subdirectory() -> io::Result<()> {
312 use std::thread::sleep;
317 use std::time::Duration;
318
319 let temp_dir = TempDir::new()?;
320 let test_dir = temp_dir.path().join("test");
321 fs::create_dir(&test_dir)?;
322
323 fs::create_dir(test_dir.join("a"))?;
325 fs::create_dir(test_dir.join("b"))?;
326 fs::write(test_dir.join("a/file1.txt"), "content")?;
327 fs::write(test_dir.join("b/file2.txt"), "content")?;
328
329 let stats1 = scan_directory(&test_dir, None)?;
331 assert_eq!(stats1.file_count(), 2);
332
333 sleep(Duration::from_millis(10));
335
336 fs::remove_file(test_dir.join("b/file2.txt"))?;
338 fs::remove_dir(test_dir.join("b"))?;
339
340 let stats2 = scan_directory(&test_dir, Some(&stats1))?;
342
343 assert_eq!(stats2.file_count(), 1);
345 assert!(
346 stats2.last_scan() > stats1.last_scan(),
347 "Should have rescanned since subdirectory was deleted"
348 );
349
350 Ok(())
351 }
352
353 #[test]
354 fn test_prunes_deeply_nested_deleted_directory() -> io::Result<()> {
355 use std::thread::sleep;
360 use std::time::Duration;
361
362 let temp_dir = TempDir::new()?;
363 let test_dir = temp_dir.path().join("test");
364 fs::create_dir(&test_dir)?;
365
366 fs::create_dir_all(test_dir.join("a/b/c/d"))?;
368 fs::write(test_dir.join("a/file1.txt"), "content1")?;
369 fs::write(test_dir.join("a/b/file2.txt"), "content2")?;
370 fs::write(test_dir.join("a/b/c/file3.txt"), "content3")?;
371 fs::write(test_dir.join("a/b/c/d/file4.txt"), "content4")?;
372
373 let stats1 = scan_directory(&test_dir, None)?;
375 assert_eq!(stats1.file_count(), 4);
376
377 sleep(Duration::from_millis(10));
379
380 fs::remove_file(test_dir.join("a/b/c/d/file4.txt"))?;
382 fs::remove_dir(test_dir.join("a/b/c/d"))?;
383 fs::remove_file(test_dir.join("a/b/c/file3.txt"))?;
384 fs::remove_dir(test_dir.join("a/b/c"))?;
385
386 let stats2 = scan_directory(&test_dir, Some(&stats1))?;
388
389 assert_eq!(stats2.file_count(), 2);
391
392 let a_stats = stats2.children.get(&test_dir.join("a")).unwrap();
394 let b_stats = a_stats.children.get(&test_dir.join("a/b")).unwrap();
395 assert!(
396 !b_stats.children.contains_key(&test_dir.join("a/b/c")),
397 "Deleted directory c should be pruned from cache"
398 );
399
400 Ok(())
401 }
402}