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
65fn dir_changed_since_last_scan(path: &Path, cached: &DirStat) -> bool {
74 match fs::metadata(path).and_then(|m| m.modified()) {
76 Ok(mtime) => {
77 if mtime > cached.last_scan {
78 return true;
79 }
80 }
81 Err(_) => return true, }
83
84 cached
89 .children
90 .par_iter()
91 .any(|(child_path, child_stat)| dir_changed_since_last_scan(child_path, child_stat))
92}
93
94pub fn scan_directory(path: &Path, cache: Option<&DirStat>) -> io::Result<DirStat> {
103 if let Some(cached) = cache {
105 if !dir_changed_since_last_scan(path, cached) {
108 return Ok(cached.clone());
109 }
110 }
111
112 let mut total_size = 0;
113 let mut file_count = 0;
114 let mut children = HashMap::new();
115
116 let entries: Vec<_> = match fs::read_dir(path) {
118 Ok(entries) => entries
119 .filter_map(|e| match e {
120 Ok(entry) => Some(entry),
121 Err(err) => {
122 eprintln!(
124 "Warning: Failed to read directory entry in '{}': {}",
125 path.display(),
126 err
127 );
128 None
129 }
130 })
131 .collect(),
132 Err(err) => {
133 return Err(io::Error::from(DiskUseError::ScanError {
134 path: path.to_path_buf(),
135 source: err,
136 }));
137 }
138 };
139
140 let mut subdirs = Vec::new();
142
143 for entry in entries {
144 let entry_path = entry.path();
145 match entry.metadata() {
146 Ok(meta) => {
147 if meta.is_file() {
148 total_size += get_block_size(&meta);
149 file_count += 1;
150 } else if meta.is_dir() {
151 subdirs.push(entry_path);
152 }
153 }
154 Err(err) => {
155 eprintln!(
157 "Warning: Failed to read metadata for '{}': {}",
158 entry_path.display(),
159 err
160 );
161 }
162 }
163 }
164
165 if subdirs.len() > 1 {
167 let results: Vec<_> = subdirs
168 .par_iter()
169 .filter_map(|entry_path| {
170 let child_cache = cache.and_then(|c| c.children.get(entry_path));
171 match scan_directory(entry_path, child_cache) {
172 Ok(stat) => Some(stat),
173 Err(err) => {
174 eprintln!("Warning: Failed to scan subdirectory: {}", err);
176 None
177 }
178 }
179 })
180 .collect();
181
182 for child_stat in results {
183 total_size += child_stat.total_size;
184 file_count += child_stat.file_count;
185 children.insert(child_stat.path.clone(), child_stat);
186 }
187 } else {
188 for entry_path in subdirs {
190 let child_cache = cache.and_then(|c| c.children.get(&entry_path));
191 match scan_directory(&entry_path, child_cache) {
192 Ok(child_stat) => {
193 total_size += child_stat.total_size;
194 file_count += child_stat.file_count;
195 children.insert(entry_path, child_stat);
196 }
197 Err(err) => {
198 eprintln!("Warning: Failed to scan subdirectory: {}", err);
200 }
201 }
202 }
203 }
204
205 Ok(DirStat {
206 path: path.to_path_buf(),
207 total_size,
208 file_count,
209 last_scan: SystemTime::now(),
210 children,
211 })
212}
213
214pub fn count_files(path: &Path) -> io::Result<u64> {
216 let mut count = 0;
217
218 let entries = fs::read_dir(path).map_err(|err| {
219 io::Error::from(DiskUseError::ScanError {
220 path: path.to_path_buf(),
221 source: err,
222 })
223 })?;
224
225 for entry in entries {
226 let entry = match entry {
227 Ok(e) => e,
228 Err(err) => {
229 eprintln!(
230 "Warning: Failed to read directory entry in '{}': {}",
231 path.display(),
232 err
233 );
234 continue;
235 }
236 };
237
238 let meta = match entry.metadata() {
239 Ok(m) => m,
240 Err(err) => {
241 eprintln!(
242 "Warning: Failed to read metadata for '{}': {}",
243 entry.path().display(),
244 err
245 );
246 continue;
247 }
248 };
249
250 if meta.is_file() {
251 count += 1;
252 } else if meta.is_dir() {
253 match count_files(&entry.path()) {
254 Ok(subcount) => count += subcount,
255 Err(err) => {
256 eprintln!("Warning: Failed to count files in subdirectory: {}", err);
257 }
258 }
259 }
260 }
261
262 Ok(count)
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use tempfile::TempDir;
269
270 fn create_test_structure(base: &Path) -> io::Result<()> {
271 fs::create_dir_all(base.join("subdir1"))?;
272 fs::create_dir_all(base.join("subdir2/nested"))?;
273
274 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(())
281 }
282
283 #[test]
284 fn test_scan_directory() -> io::Result<()> {
285 let temp_dir = TempDir::new()?;
290 let test_dir = temp_dir.path().join("test");
291 fs::create_dir(&test_dir)?;
292
293 create_test_structure(&test_dir)?;
294
295 let result = scan_directory(&test_dir, None)?;
296
297 assert!(result.total_size() >= 71);
300 assert_eq!(result.file_count(), 5);
301 assert_eq!(result.children.len(), 2); Ok(())
304 }
305
306 #[test]
307 fn test_count_files() -> io::Result<()> {
308 let temp_dir = TempDir::new()?;
311 let test_dir = temp_dir.path().join("test");
312 fs::create_dir(&test_dir)?;
313
314 create_test_structure(&test_dir)?;
315
316 let count = count_files(&test_dir)?;
317 assert_eq!(count, 5);
318
319 Ok(())
320 }
321
322 #[test]
323 fn test_scan_with_cache() -> io::Result<()> {
324 let temp_dir = TempDir::new()?;
329 let test_dir = temp_dir.path().join("test");
330 fs::create_dir(&test_dir)?;
331
332 create_test_structure(&test_dir)?;
333
334 let stats1 = scan_directory(&test_dir, None)?;
336 let scan_time1 = stats1.last_scan();
337
338 let stats2 = scan_directory(&test_dir, Some(&stats1))?;
340 let scan_time2 = stats2.last_scan();
341
342 assert_eq!(scan_time1, scan_time2);
344
345 Ok(())
346 }
347
348 #[test]
349 fn test_detects_new_nested_subdirectory() -> io::Result<()> {
350 use std::thread::sleep;
355 use std::time::Duration;
356
357 let temp_dir = TempDir::new()?;
358 let test_dir = temp_dir.path().join("test");
359 fs::create_dir(&test_dir)?;
360
361 fs::create_dir(test_dir.join("a"))?;
363 fs::write(test_dir.join("a/file1.txt"), "content")?;
364
365 let stats1 = scan_directory(&test_dir, None)?;
367 assert_eq!(stats1.file_count(), 1);
368
369 sleep(Duration::from_millis(10));
371
372 fs::create_dir(test_dir.join("a/b"))?;
374 fs::write(test_dir.join("a/b/file2.txt"), "new content")?;
375
376 let stats2 = scan_directory(&test_dir, Some(&stats1))?;
378
379 assert_eq!(stats2.file_count(), 2);
381 assert!(
382 stats2.last_scan() > stats1.last_scan(),
383 "Should have rescanned since new subdirectory was added"
384 );
385
386 Ok(())
387 }
388
389 #[test]
390 fn test_detects_deleted_subdirectory() -> io::Result<()> {
391 use std::thread::sleep;
396 use std::time::Duration;
397
398 let temp_dir = TempDir::new()?;
399 let test_dir = temp_dir.path().join("test");
400 fs::create_dir(&test_dir)?;
401
402 fs::create_dir(test_dir.join("a"))?;
404 fs::create_dir(test_dir.join("b"))?;
405 fs::write(test_dir.join("a/file1.txt"), "content")?;
406 fs::write(test_dir.join("b/file2.txt"), "content")?;
407
408 let stats1 = scan_directory(&test_dir, None)?;
410 assert_eq!(stats1.file_count(), 2);
411
412 sleep(Duration::from_millis(10));
414
415 fs::remove_file(test_dir.join("b/file2.txt"))?;
417 fs::remove_dir(test_dir.join("b"))?;
418
419 let stats2 = scan_directory(&test_dir, Some(&stats1))?;
421
422 assert_eq!(stats2.file_count(), 1);
424 assert!(
425 stats2.last_scan() > stats1.last_scan(),
426 "Should have rescanned since subdirectory was deleted"
427 );
428
429 Ok(())
430 }
431
432 #[test]
433 fn test_prunes_deeply_nested_deleted_directory() -> io::Result<()> {
434 use std::thread::sleep;
439 use std::time::Duration;
440
441 let temp_dir = TempDir::new()?;
442 let test_dir = temp_dir.path().join("test");
443 fs::create_dir(&test_dir)?;
444
445 fs::create_dir_all(test_dir.join("a/b/c/d"))?;
447 fs::write(test_dir.join("a/file1.txt"), "content1")?;
448 fs::write(test_dir.join("a/b/file2.txt"), "content2")?;
449 fs::write(test_dir.join("a/b/c/file3.txt"), "content3")?;
450 fs::write(test_dir.join("a/b/c/d/file4.txt"), "content4")?;
451
452 let stats1 = scan_directory(&test_dir, None)?;
454 assert_eq!(stats1.file_count(), 4);
455
456 sleep(Duration::from_millis(10));
458
459 fs::remove_file(test_dir.join("a/b/c/d/file4.txt"))?;
461 fs::remove_dir(test_dir.join("a/b/c/d"))?;
462 fs::remove_file(test_dir.join("a/b/c/file3.txt"))?;
463 fs::remove_dir(test_dir.join("a/b/c"))?;
464
465 let stats2 = scan_directory(&test_dir, Some(&stats1))?;
467
468 assert_eq!(stats2.file_count(), 2);
470
471 let a_stats = stats2.children.get(&test_dir.join("a")).unwrap();
473 let b_stats = a_stats.children.get(&test_dir.join("a/b")).unwrap();
474 assert!(
475 !b_stats.children.contains_key(&test_dir.join("a/b/c")),
476 "Deleted directory c should be pruned from cache"
477 );
478
479 Ok(())
480 }
481
482 #[test]
483 fn test_scan_nonexistent_path() {
484 let result = scan_directory(Path::new("/nonexistent/path/that/does/not/exist"), None);
486 assert!(result.is_err());
487 let err = result.unwrap_err();
488 assert!(
489 err.to_string().contains("Failed to scan directory"),
490 "Error message should be descriptive"
491 );
492 }
493
494 #[test]
495 fn test_count_files_with_inaccessible_subdirectory() -> io::Result<()> {
496 let temp_dir = TempDir::new()?;
498 let test_dir = temp_dir.path().join("test");
499 fs::create_dir(&test_dir)?;
500
501 fs::write(test_dir.join("file1.txt"), "content")?;
503 fs::create_dir(test_dir.join("subdir"))?;
504 fs::write(test_dir.join("subdir/file2.txt"), "content")?;
505
506 let count = count_files(&test_dir)?;
508 assert_eq!(count, 2);
509
510 Ok(())
511 }
512}