use std::path::Path;
use crate::domain::stats::DiskStats;
use crate::util::archive::is_excluded;
const GIT_DIR: &str = ".git";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Bucket {
Content,
Git,
Excluded,
}
pub fn measure(root: &Path, exclude_dirs: &[String]) -> DiskStats {
let mut stats = DiskStats::default();
let Ok(metadata) = std::fs::symlink_metadata(root) else {
return stats;
};
if !metadata.is_dir() {
stats.total_bytes = metadata.len();
stats.files = 1;
return stats;
}
walk(root, exclude_dirs, Bucket::Content, &mut stats);
stats
}
fn walk(
dir: &Path,
exclude_dirs: &[String],
bucket: Bucket,
stats: &mut DiskStats,
) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
let name = entry.file_name();
let child =
child_bucket(&name.to_string_lossy(), bucket, exclude_dirs);
walk(&entry.path(), exclude_dirs, child, stats);
} else if let Ok(metadata) = entry.metadata() {
add_file(metadata.len(), bucket, stats);
}
}
}
fn child_bucket(name: &str, parent: Bucket, exclude_dirs: &[String]) -> Bucket {
if parent != Bucket::Content {
return parent;
}
if name == GIT_DIR {
return Bucket::Git;
}
if is_excluded(name, exclude_dirs) {
return Bucket::Excluded;
}
Bucket::Content
}
fn add_file(size: u64, bucket: Bucket, stats: &mut DiskStats) {
stats.total_bytes = stats.total_bytes.saturating_add(size);
stats.files = stats.files.saturating_add(1);
match bucket {
Bucket::Content => {}
Bucket::Git => stats.git_bytes = stats.git_bytes.saturating_add(size),
Bucket::Excluded => {
stats.excluded_bytes = stats.excluded_bytes.saturating_add(size);
}
}
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
fn tree(tag: &str) -> std::path::PathBuf {
let root = std::env::temp_dir()
.join(format!("hop-diskusage-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("src")).unwrap();
fs::create_dir_all(root.join(".git")).unwrap();
fs::create_dir_all(root.join("target.nosync")).unwrap();
fs::write(root.join("src/lib.rs"), vec![b'x'; 10]).unwrap();
fs::write(root.join(".git/HEAD"), vec![b'x'; 20]).unwrap();
fs::write(root.join("target.nosync/out"), vec![b'x'; 40]).unwrap();
root
}
#[test]
fn measure_splits_git_and_excluded_bytes_out_of_the_total() {
let root = tree("split");
let stats = measure(&root, &["target".to_string()]);
assert_eq!(stats.total_bytes, 70, "every byte is in the total");
assert_eq!(stats.git_bytes, 20);
assert_eq!(stats.excluded_bytes, 40);
assert_eq!(stats.files, 3);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn measure_without_exclusions_counts_everything_as_content() {
let root = tree("plain");
let stats = measure(&root, &[]);
assert_eq!(stats.total_bytes, 70);
assert_eq!(stats.excluded_bytes, 0);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn measure_reports_a_single_file_and_tolerates_a_missing_path() {
let root = tree("file");
let stats = measure(&root.join("src/lib.rs"), &[]);
assert_eq!((stats.total_bytes, stats.files), (10, 1));
assert_eq!(
measure(Path::new("/no/such/path"), &[]),
DiskStats::default()
);
let _ = fs::remove_dir_all(&root);
}
}