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
15use crate::error::DiskUseError;
16
17fn get_block_size(meta: &fs::Metadata) -> u64 {
23 #[cfg(unix)]
24 {
25 meta.blocks() * 512
26 }
27 #[cfg(not(unix))]
28 {
29 meta.len()
30 }
31}
32
33#[derive(Serialize, Deserialize, Debug, Clone)]
35pub struct DirStat {
36 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>, }
42
43impl DirStat {
44 pub fn total_size(&self) -> u64 {
46 self.total_size
47 }
48
49 pub fn file_count(&self) -> u64 {
51 self.file_count
52 }
53
54 pub fn last_scan(&self) -> SystemTime {
56 self.last_scan
57 }
58
59 pub fn path(&self) -> &Path {
61 &self.path
62 }
63
64 pub fn children(&self) -> &HashMap<PathBuf, DirStat> {
66 &self.children
67 }
68}
69
70fn 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 cached
94 .children
95 .par_iter()
96 .any(|(child_path, child_stat)| dir_changed_since_last_scan(child_path, child_stat))
97}
98
99pub fn scan_directory(path: &Path, cache: Option<&DirStat>) -> io::Result<DirStat> {
108 if let Some(cached) = cache {
110 if !dir_changed_since_last_scan(path, cached) {
113 return Ok(cached.clone());
114 }
115 }
116
117 let mut total_size = 0;
118 let mut file_count = 0;
119 let mut children = HashMap::new();
120
121 let entries: Vec<_> = match fs::read_dir(path) {
123 Ok(entries) => entries
124 .filter_map(|e| match e {
125 Ok(entry) => Some(entry),
126 Err(err) => {
127 eprintln!(
129 "Warning: Failed to read directory entry in '{}': {}",
130 path.display(),
131 err
132 );
133 None
134 }
135 })
136 .collect(),
137 Err(err) => {
138 return Err(io::Error::from(DiskUseError::ScanError {
139 path: path.to_path_buf(),
140 source: err,
141 }));
142 }
143 };
144
145 let mut subdirs = Vec::new();
147
148 for entry in entries {
149 let entry_path = entry.path();
150 match entry.metadata() {
151 Ok(meta) => {
152 if meta.is_file() {
153 total_size += get_block_size(&meta);
154 file_count += 1;
155 } else if meta.is_dir() {
156 subdirs.push(entry_path);
157 }
158 }
159 Err(err) => {
160 eprintln!(
162 "Warning: Failed to read metadata for '{}': {}",
163 entry_path.display(),
164 err
165 );
166 }
167 }
168 }
169
170 if subdirs.len() > 1 {
172 let results: Vec<_> = subdirs
173 .par_iter()
174 .filter_map(|entry_path| {
175 let child_cache = cache.and_then(|c| c.children.get(entry_path));
176 match scan_directory(entry_path, child_cache) {
177 Ok(stat) => Some(stat),
178 Err(err) => {
179 eprintln!("Warning: Failed to scan subdirectory: {}", err);
181 None
182 }
183 }
184 })
185 .collect();
186
187 for child_stat in results {
188 total_size += child_stat.total_size;
189 file_count += child_stat.file_count;
190 children.insert(child_stat.path.clone(), child_stat);
191 }
192 } else {
193 for entry_path in subdirs {
195 let child_cache = cache.and_then(|c| c.children.get(&entry_path));
196 match scan_directory(&entry_path, child_cache) {
197 Ok(child_stat) => {
198 total_size += child_stat.total_size;
199 file_count += child_stat.file_count;
200 children.insert(entry_path, child_stat);
201 }
202 Err(err) => {
203 eprintln!("Warning: Failed to scan subdirectory: {}", err);
205 }
206 }
207 }
208 }
209
210 Ok(DirStat {
211 path: path.to_path_buf(),
212 total_size,
213 file_count,
214 last_scan: SystemTime::now(),
215 children,
216 })
217}
218
219pub fn count_files(path: &Path) -> io::Result<u64> {
221 let mut count = 0;
222
223 let entries = fs::read_dir(path).map_err(|err| {
224 io::Error::from(DiskUseError::ScanError {
225 path: path.to_path_buf(),
226 source: err,
227 })
228 })?;
229
230 for entry in entries {
231 let entry = match entry {
232 Ok(e) => e,
233 Err(err) => {
234 eprintln!(
235 "Warning: Failed to read directory entry in '{}': {}",
236 path.display(),
237 err
238 );
239 continue;
240 }
241 };
242
243 let meta = match entry.metadata() {
244 Ok(m) => m,
245 Err(err) => {
246 eprintln!(
247 "Warning: Failed to read metadata for '{}': {}",
248 entry.path().display(),
249 err
250 );
251 continue;
252 }
253 };
254
255 if meta.is_file() {
256 count += 1;
257 } else if meta.is_dir() {
258 match count_files(&entry.path()) {
259 Ok(subcount) => count += subcount,
260 Err(err) => {
261 eprintln!("Warning: Failed to count files in subdirectory: {}", err);
262 }
263 }
264 }
265 }
266
267 Ok(count)
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273 use tempfile::TempDir;
274
275 fn create_test_structure(base: &Path) -> io::Result<()> {
276 fs::create_dir_all(base.join("subdir1"))?;
277 fs::create_dir_all(base.join("subdir2/nested"))?;
278
279 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(())
286 }
287
288 #[test]
289 fn test_scan_directory() -> io::Result<()> {
290 let temp_dir = TempDir::new()?;
295 let test_dir = temp_dir.path().join("test");
296 fs::create_dir(&test_dir)?;
297
298 create_test_structure(&test_dir)?;
299
300 let result = scan_directory(&test_dir, None)?;
301
302 assert!(result.total_size() >= 71);
305 assert_eq!(result.file_count(), 5);
306 assert_eq!(result.children.len(), 2); Ok(())
309 }
310
311 #[test]
312 fn test_count_files() -> io::Result<()> {
313 let temp_dir = TempDir::new()?;
316 let test_dir = temp_dir.path().join("test");
317 fs::create_dir(&test_dir)?;
318
319 create_test_structure(&test_dir)?;
320
321 let count = count_files(&test_dir)?;
322 assert_eq!(count, 5);
323
324 Ok(())
325 }
326
327 #[test]
328 fn test_scan_with_cache() -> io::Result<()> {
329 let temp_dir = TempDir::new()?;
334 let test_dir = temp_dir.path().join("test");
335 fs::create_dir(&test_dir)?;
336
337 create_test_structure(&test_dir)?;
338
339 let stats1 = scan_directory(&test_dir, None)?;
341 let scan_time1 = stats1.last_scan();
342
343 let stats2 = scan_directory(&test_dir, Some(&stats1))?;
345 let scan_time2 = stats2.last_scan();
346
347 assert_eq!(scan_time1, scan_time2);
349
350 Ok(())
351 }
352
353 #[test]
354 fn test_detects_new_nested_subdirectory() -> 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(test_dir.join("a"))?;
368 fs::write(test_dir.join("a/file1.txt"), "content")?;
369
370 let stats1 = scan_directory(&test_dir, None)?;
372 assert_eq!(stats1.file_count(), 1);
373
374 sleep(Duration::from_millis(10));
376
377 fs::create_dir(test_dir.join("a/b"))?;
379 fs::write(test_dir.join("a/b/file2.txt"), "new content")?;
380
381 let stats2 = scan_directory(&test_dir, Some(&stats1))?;
383
384 assert_eq!(stats2.file_count(), 2);
386 assert!(
387 stats2.last_scan() > stats1.last_scan(),
388 "Should have rescanned since new subdirectory was added"
389 );
390
391 Ok(())
392 }
393
394 #[test]
395 fn test_detects_deleted_subdirectory() -> io::Result<()> {
396 use std::thread::sleep;
401 use std::time::Duration;
402
403 let temp_dir = TempDir::new()?;
404 let test_dir = temp_dir.path().join("test");
405 fs::create_dir(&test_dir)?;
406
407 fs::create_dir(test_dir.join("a"))?;
409 fs::create_dir(test_dir.join("b"))?;
410 fs::write(test_dir.join("a/file1.txt"), "content")?;
411 fs::write(test_dir.join("b/file2.txt"), "content")?;
412
413 let stats1 = scan_directory(&test_dir, None)?;
415 assert_eq!(stats1.file_count(), 2);
416
417 sleep(Duration::from_millis(10));
419
420 fs::remove_file(test_dir.join("b/file2.txt"))?;
422 fs::remove_dir(test_dir.join("b"))?;
423
424 let stats2 = scan_directory(&test_dir, Some(&stats1))?;
426
427 assert_eq!(stats2.file_count(), 1);
429 assert!(
430 stats2.last_scan() > stats1.last_scan(),
431 "Should have rescanned since subdirectory was deleted"
432 );
433
434 Ok(())
435 }
436
437 #[test]
438 fn test_prunes_deeply_nested_deleted_directory() -> io::Result<()> {
439 use std::thread::sleep;
444 use std::time::Duration;
445
446 let temp_dir = TempDir::new()?;
447 let test_dir = temp_dir.path().join("test");
448 fs::create_dir(&test_dir)?;
449
450 fs::create_dir_all(test_dir.join("a/b/c/d"))?;
452 fs::write(test_dir.join("a/file1.txt"), "content1")?;
453 fs::write(test_dir.join("a/b/file2.txt"), "content2")?;
454 fs::write(test_dir.join("a/b/c/file3.txt"), "content3")?;
455 fs::write(test_dir.join("a/b/c/d/file4.txt"), "content4")?;
456
457 let stats1 = scan_directory(&test_dir, None)?;
459 assert_eq!(stats1.file_count(), 4);
460
461 sleep(Duration::from_millis(10));
463
464 fs::remove_file(test_dir.join("a/b/c/d/file4.txt"))?;
466 fs::remove_dir(test_dir.join("a/b/c/d"))?;
467 fs::remove_file(test_dir.join("a/b/c/file3.txt"))?;
468 fs::remove_dir(test_dir.join("a/b/c"))?;
469
470 let stats2 = scan_directory(&test_dir, Some(&stats1))?;
472
473 assert_eq!(stats2.file_count(), 2);
475
476 let a_stats = stats2.children.get(&test_dir.join("a")).unwrap();
478 let b_stats = a_stats.children.get(&test_dir.join("a/b")).unwrap();
479 assert!(
480 !b_stats.children.contains_key(&test_dir.join("a/b/c")),
481 "Deleted directory c should be pruned from cache"
482 );
483
484 Ok(())
485 }
486
487 #[test]
488 fn test_scan_nonexistent_path() {
489 let result = scan_directory(Path::new("/nonexistent/path/that/does/not/exist"), None);
491 assert!(result.is_err());
492 let err = result.unwrap_err();
493 assert!(
494 err.to_string().contains("Failed to scan directory"),
495 "Error message should be descriptive"
496 );
497 }
498
499 #[test]
500 fn test_count_files_with_inaccessible_subdirectory() -> io::Result<()> {
501 let temp_dir = TempDir::new()?;
503 let test_dir = temp_dir.path().join("test");
504 fs::create_dir(&test_dir)?;
505
506 fs::write(test_dir.join("file1.txt"), "content")?;
508 fs::create_dir(test_dir.join("subdir"))?;
509 fs::write(test_dir.join("subdir/file2.txt"), "content")?;
510
511 let count = count_files(&test_dir)?;
513 assert_eq!(count, 2);
514
515 Ok(())
516 }
517}