use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
fs, io,
path::{Path, PathBuf},
time::SystemTime,
};
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
use crate::error::DiskUseError;
fn get_block_size(meta: &fs::Metadata) -> u64 {
#[cfg(unix)]
{
meta.blocks() * 512
}
#[cfg(not(unix))]
{
meta.len()
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct DirStat {
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>, }
impl DirStat {
pub fn total_size(&self) -> u64 {
self.total_size
}
pub fn file_count(&self) -> u64 {
self.file_count
}
pub fn last_scan(&self) -> SystemTime {
self.last_scan
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn children(&self) -> &HashMap<PathBuf, DirStat> {
&self.children
}
}
fn dir_changed_since_last_scan(path: &Path, cached: &DirStat) -> bool {
match fs::metadata(path).and_then(|m| m.modified()) {
Ok(mtime) => {
if mtime > cached.last_scan {
return true;
}
}
Err(_) => return true, }
cached
.children
.par_iter()
.any(|(child_path, child_stat)| dir_changed_since_last_scan(child_path, child_stat))
}
pub fn scan_directory(path: &Path, cache: Option<&DirStat>) -> io::Result<DirStat> {
if let Some(cached) = cache {
if !dir_changed_since_last_scan(path, cached) {
return Ok(cached.clone());
}
}
let mut total_size = 0;
let mut file_count = 0;
let mut children = HashMap::new();
let entries: Vec<_> = match fs::read_dir(path) {
Ok(entries) => entries
.filter_map(|e| match e {
Ok(entry) => Some(entry),
Err(err) => {
eprintln!(
"Warning: Failed to read directory entry in '{}': {}",
path.display(),
err
);
None
}
})
.collect(),
Err(err) => {
return Err(io::Error::from(DiskUseError::ScanError {
path: path.to_path_buf(),
source: err,
}));
}
};
let mut subdirs = Vec::new();
for entry in entries {
let entry_path = entry.path();
match entry.metadata() {
Ok(meta) => {
if meta.is_file() {
total_size += get_block_size(&meta);
file_count += 1;
} else if meta.is_dir() {
subdirs.push(entry_path);
}
}
Err(err) => {
eprintln!(
"Warning: Failed to read metadata for '{}': {}",
entry_path.display(),
err
);
}
}
}
if subdirs.len() > 1 {
let results: Vec<_> = subdirs
.par_iter()
.filter_map(|entry_path| {
let child_cache = cache.and_then(|c| c.children.get(entry_path));
match scan_directory(entry_path, child_cache) {
Ok(stat) => Some(stat),
Err(err) => {
eprintln!("Warning: Failed to scan subdirectory: {}", err);
None
}
}
})
.collect();
for child_stat in results {
total_size += child_stat.total_size;
file_count += child_stat.file_count;
children.insert(child_stat.path.clone(), child_stat);
}
} else {
for entry_path in subdirs {
let child_cache = cache.and_then(|c| c.children.get(&entry_path));
match scan_directory(&entry_path, child_cache) {
Ok(child_stat) => {
total_size += child_stat.total_size;
file_count += child_stat.file_count;
children.insert(entry_path, child_stat);
}
Err(err) => {
eprintln!("Warning: Failed to scan subdirectory: {}", err);
}
}
}
}
Ok(DirStat {
path: path.to_path_buf(),
total_size,
file_count,
last_scan: SystemTime::now(),
children,
})
}
pub fn count_files(path: &Path) -> io::Result<u64> {
let mut count = 0;
let entries = fs::read_dir(path).map_err(|err| {
io::Error::from(DiskUseError::ScanError {
path: path.to_path_buf(),
source: err,
})
})?;
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(err) => {
eprintln!(
"Warning: Failed to read directory entry in '{}': {}",
path.display(),
err
);
continue;
}
};
let meta = match entry.metadata() {
Ok(m) => m,
Err(err) => {
eprintln!(
"Warning: Failed to read metadata for '{}': {}",
entry.path().display(),
err
);
continue;
}
};
if meta.is_file() {
count += 1;
} else if meta.is_dir() {
match count_files(&entry.path()) {
Ok(subcount) => count += subcount,
Err(err) => {
eprintln!("Warning: Failed to count files in subdirectory: {}", err);
}
}
}
}
Ok(count)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_structure(base: &Path) -> io::Result<()> {
fs::create_dir_all(base.join("subdir1"))?;
fs::create_dir_all(base.join("subdir2/nested"))?;
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(())
}
#[test]
fn test_scan_directory() -> io::Result<()> {
let temp_dir = TempDir::new()?;
let test_dir = temp_dir.path().join("test");
fs::create_dir(&test_dir)?;
create_test_structure(&test_dir)?;
let result = scan_directory(&test_dir, None)?;
assert!(result.total_size() >= 71);
assert_eq!(result.file_count(), 5);
assert_eq!(result.children.len(), 2);
Ok(())
}
#[test]
fn test_count_files() -> io::Result<()> {
let temp_dir = TempDir::new()?;
let test_dir = temp_dir.path().join("test");
fs::create_dir(&test_dir)?;
create_test_structure(&test_dir)?;
let count = count_files(&test_dir)?;
assert_eq!(count, 5);
Ok(())
}
#[test]
fn test_scan_with_cache() -> io::Result<()> {
let temp_dir = TempDir::new()?;
let test_dir = temp_dir.path().join("test");
fs::create_dir(&test_dir)?;
create_test_structure(&test_dir)?;
let stats1 = scan_directory(&test_dir, None)?;
let scan_time1 = stats1.last_scan();
let stats2 = scan_directory(&test_dir, Some(&stats1))?;
let scan_time2 = stats2.last_scan();
assert_eq!(scan_time1, scan_time2);
Ok(())
}
#[test]
fn test_detects_new_nested_subdirectory() -> io::Result<()> {
use std::thread::sleep;
use std::time::Duration;
let temp_dir = TempDir::new()?;
let test_dir = temp_dir.path().join("test");
fs::create_dir(&test_dir)?;
fs::create_dir(test_dir.join("a"))?;
fs::write(test_dir.join("a/file1.txt"), "content")?;
let stats1 = scan_directory(&test_dir, None)?;
assert_eq!(stats1.file_count(), 1);
sleep(Duration::from_millis(10));
fs::create_dir(test_dir.join("a/b"))?;
fs::write(test_dir.join("a/b/file2.txt"), "new content")?;
let stats2 = scan_directory(&test_dir, Some(&stats1))?;
assert_eq!(stats2.file_count(), 2);
assert!(
stats2.last_scan() > stats1.last_scan(),
"Should have rescanned since new subdirectory was added"
);
Ok(())
}
#[test]
fn test_detects_deleted_subdirectory() -> io::Result<()> {
use std::thread::sleep;
use std::time::Duration;
let temp_dir = TempDir::new()?;
let test_dir = temp_dir.path().join("test");
fs::create_dir(&test_dir)?;
fs::create_dir(test_dir.join("a"))?;
fs::create_dir(test_dir.join("b"))?;
fs::write(test_dir.join("a/file1.txt"), "content")?;
fs::write(test_dir.join("b/file2.txt"), "content")?;
let stats1 = scan_directory(&test_dir, None)?;
assert_eq!(stats1.file_count(), 2);
sleep(Duration::from_millis(10));
fs::remove_file(test_dir.join("b/file2.txt"))?;
fs::remove_dir(test_dir.join("b"))?;
let stats2 = scan_directory(&test_dir, Some(&stats1))?;
assert_eq!(stats2.file_count(), 1);
assert!(
stats2.last_scan() > stats1.last_scan(),
"Should have rescanned since subdirectory was deleted"
);
Ok(())
}
#[test]
fn test_prunes_deeply_nested_deleted_directory() -> io::Result<()> {
use std::thread::sleep;
use std::time::Duration;
let temp_dir = TempDir::new()?;
let test_dir = temp_dir.path().join("test");
fs::create_dir(&test_dir)?;
fs::create_dir_all(test_dir.join("a/b/c/d"))?;
fs::write(test_dir.join("a/file1.txt"), "content1")?;
fs::write(test_dir.join("a/b/file2.txt"), "content2")?;
fs::write(test_dir.join("a/b/c/file3.txt"), "content3")?;
fs::write(test_dir.join("a/b/c/d/file4.txt"), "content4")?;
let stats1 = scan_directory(&test_dir, None)?;
assert_eq!(stats1.file_count(), 4);
sleep(Duration::from_millis(10));
fs::remove_file(test_dir.join("a/b/c/d/file4.txt"))?;
fs::remove_dir(test_dir.join("a/b/c/d"))?;
fs::remove_file(test_dir.join("a/b/c/file3.txt"))?;
fs::remove_dir(test_dir.join("a/b/c"))?;
let stats2 = scan_directory(&test_dir, Some(&stats1))?;
assert_eq!(stats2.file_count(), 2);
let a_stats = stats2.children.get(&test_dir.join("a")).unwrap();
let b_stats = a_stats.children.get(&test_dir.join("a/b")).unwrap();
assert!(
!b_stats.children.contains_key(&test_dir.join("a/b/c")),
"Deleted directory c should be pruned from cache"
);
Ok(())
}
#[test]
fn test_scan_nonexistent_path() {
let result = scan_directory(Path::new("/nonexistent/path/that/does/not/exist"), None);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string().contains("Failed to scan directory"),
"Error message should be descriptive"
);
}
#[test]
fn test_count_files_with_inaccessible_subdirectory() -> io::Result<()> {
let temp_dir = TempDir::new()?;
let test_dir = temp_dir.path().join("test");
fs::create_dir(&test_dir)?;
fs::write(test_dir.join("file1.txt"), "content")?;
fs::create_dir(test_dir.join("subdir"))?;
fs::write(test_dir.join("subdir/file2.txt"), "content")?;
let count = count_files(&test_dir)?;
assert_eq!(count, 2);
Ok(())
}
}