use super::*;
use std::fs;
fn test_policy(max_age: Duration, node_max_age: Duration) -> IndexPolicy {
IndexPolicy {
max_age,
node_max_age,
max_depth: DEFAULT_MAX_DEPTH,
walk_budget: DEFAULT_WALK_BUDGET,
forbidden_roots: Vec::new(),
}
}
fn always_refresh() -> IndexPolicy {
test_policy(Duration::ZERO, DEFAULT_NODE_MAX_AGE)
}
fn write_file(path: &Path, bytes: usize) {
fs::create_dir_all(path.parent().expect("path has a parent")).expect("create parents");
fs::write(path, vec![b'x'; bytes]).expect("write file");
}
fn known_tree(root: &Path) {
write_file(&root.join("f1"), 10);
write_file(&root.join("a/f2"), 20);
write_file(&root.join("a/deep/f3"), 30);
write_file(&root.join("b/f4"), 40);
}
#[test]
fn bytes_sum_the_files_in_a_known_tree() {
let tmp = tempfile::tempdir().expect("tempdir");
known_tree(tmp.path());
let mut index = DirSizeIndex::with_policy(always_refresh());
let size = index.measure(tmp.path()).expect("measure");
assert_eq!(size.bytes, 100, "10 + 20 + 30 + 40");
assert!(!size.truncated);
assert!(size.unreadable.is_empty());
assert!(!size.from_cache);
assert_eq!(index.stats().directories_read, 4, "root, a, a/deep, b");
}
#[test]
fn a_second_read_inside_the_cadence_performs_no_walk() {
let tmp = tempfile::tempdir().expect("tempdir");
known_tree(tmp.path());
let mut index = DirSizeIndex::with_policy(test_policy(DEFAULT_MAX_AGE, DEFAULT_NODE_MAX_AGE));
let first = index.measure(tmp.path()).expect("first measure");
let after_first = index.stats();
let second = index.measure(tmp.path()).expect("second measure");
let after_second = index.stats();
assert_eq!(
after_second.directories_read, after_first.directories_read,
"a cached read must not open a single directory"
);
assert_eq!(
after_second.directories_revalidated, after_first.directories_revalidated,
"a cached read must not even stat a directory"
);
assert_eq!(after_second.refreshes, after_first.refreshes);
assert_eq!(after_second.cache_hits, 1);
assert_eq!(second.bytes, first.bytes);
assert!(second.from_cache);
assert_eq!(
second.measured_at, first.measured_at,
"a cached figure reports when it was MEASURED, not when it was read"
);
}
#[test]
fn a_changed_subtree_is_the_only_one_re_read() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
write_file(&root.join("a/f"), 10);
write_file(&root.join("b/f"), 20);
write_file(&root.join("b/c/f"), 30);
let mut index = DirSizeIndex::with_policy(always_refresh());
assert_eq!(index.measure(root).expect("first measure").bytes, 60);
let before = index.stats();
assert_eq!(before.directories_read, 4, "root, a, b, b/c");
std::thread::sleep(Duration::from_millis(1100));
write_file(&root.join("a/new"), 5);
let after_change = index.measure(root).expect("second measure");
let after = index.stats();
assert_eq!(after_change.bytes, 65);
assert_eq!(
after.directories_read - before.directories_read,
1,
"only `a` changed, so only `a` is listed again"
);
assert_eq!(
after.directories_revalidated - before.directories_revalidated,
3,
"root, b and b/c are reused on an unchanged mtime"
);
}
#[test]
fn the_node_backstop_re_reads_a_file_that_grew_in_place() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
write_file(&root.join("log.txt"), 10);
let mut index = DirSizeIndex::with_policy(test_policy(Duration::ZERO, Duration::ZERO));
assert_eq!(index.measure(root).expect("first measure").bytes, 10);
let before = index.stats();
write_file(&root.join("log.txt"), 30);
assert_eq!(index.measure(root).expect("second measure").bytes, 30);
assert_eq!(
index.stats().directories_read - before.directories_read,
1,
"an expired node is listed again whatever its mtime says"
);
}
#[test]
fn a_deleted_subtree_drops_out_of_the_total() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
write_file(&root.join("a/f"), 10);
write_file(&root.join("b/f"), 20);
let mut index = DirSizeIndex::with_policy(test_policy(Duration::ZERO, Duration::ZERO));
assert_eq!(index.measure(root).expect("first measure").bytes, 30);
assert!(index.nodes.contains_key(&root.join("b")));
fs::remove_dir_all(root.join("b")).expect("remove b");
assert_eq!(index.measure(root).expect("second measure").bytes, 10);
assert!(
!index.nodes.contains_key(&root.join("b")),
"the sweep must drop nodes the refresh no longer reaches"
);
}
#[test]
fn invalidate_forces_the_next_read_to_walk() {
let tmp = tempfile::tempdir().expect("tempdir");
known_tree(tmp.path());
let mut index = DirSizeIndex::with_policy(test_policy(DEFAULT_MAX_AGE, DEFAULT_NODE_MAX_AGE));
index.measure(tmp.path()).expect("first measure");
index.measure(tmp.path()).expect("cached measure");
let before = index.stats();
assert_eq!(before.cache_hits, 1);
index.invalidate(tmp.path());
let after_invalidate = index.measure(tmp.path()).expect("measure after invalidate");
assert!(!after_invalidate.from_cache);
assert_eq!(index.stats().cache_hits, before.cache_hits);
assert_eq!(
index.stats().directories_read - before.directories_read,
4,
"invalidate drops the nodes too, so the next read is a cold walk"
);
}
#[cfg(unix)]
#[test]
fn a_symlink_is_neither_followed_nor_counted() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
write_file(&root.join("real/f"), 100);
std::os::unix::fs::symlink(root.join("real"), root.join("link")).expect("symlink dir");
std::os::unix::fs::symlink(root.join("real/f"), root.join("link_f")).expect("symlink file");
let mut index = DirSizeIndex::with_policy(always_refresh());
let size = index.measure(root).expect("measure");
assert_eq!(
size.bytes, 100,
"the target is counted once, the links never"
);
assert_eq!(index.stats().directories_read, 2, "root and real only");
}
#[cfg(unix)]
#[test]
fn an_unreadable_subtree_is_recorded_not_fatal() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
write_file(&root.join("ok/f"), 100);
write_file(&root.join("denied/f"), 50);
let denied = root.join("denied");
fs::set_permissions(&denied, fs::Permissions::from_mode(0o000)).expect("chmod 000");
if fs::read_dir(&denied).is_ok() {
fs::set_permissions(&denied, fs::Permissions::from_mode(0o755)).expect("restore");
return;
}
let mut index = DirSizeIndex::with_policy(test_policy(DEFAULT_MAX_AGE, DEFAULT_NODE_MAX_AGE));
let size = index.measure(root).expect("measure must not fail");
let cached = index.measure(root).expect("cached measure");
fs::set_permissions(&denied, fs::Permissions::from_mode(0o755)).expect("restore");
assert_eq!(size.bytes, 100, "the readable half is still measured");
assert_eq!(size.unreadable, vec![denied.clone()]);
assert!(
!index.nodes.contains_key(&denied),
"a refused directory is never cached, so the next refresh retries it"
);
assert!(cached.from_cache);
assert_eq!(
cached.unreadable, size.unreadable,
"the refused subtree must survive the cache hit"
);
}
#[test]
fn the_depth_cap_truncates_and_says_so() {
let tmp = tempfile::tempdir().expect("tempdir");
known_tree(tmp.path());
let mut policy = always_refresh();
policy.max_depth = 1;
let mut index = DirSizeIndex::with_policy(policy);
let size = index.measure(tmp.path()).expect("measure");
assert!(size.truncated, "`a/deep` is below the cap");
assert_eq!(size.bytes, 70, "f1 + f2 + f4; f3 sits under the cap");
}
#[test]
fn default_policy_forbids_the_filesystem_root() {
assert!(
IndexPolicy::default()
.forbidden_roots
.contains(&PathBuf::from("/"))
);
let mut index = DirSizeIndex::new();
let err = index.measure(Path::new("/")).expect_err("`/` is refused");
assert!(matches!(err, SizeIndexError::ForbiddenRoot { .. }));
assert_eq!(index.stats().refreshes, 0);
}
#[test]
fn a_forbidden_root_is_refused_before_any_walk() {
let tmp = tempfile::tempdir().expect("tempdir");
let home = tmp.path().join("home");
write_file(&home.join("project/f"), 10);
let mut policy = always_refresh();
policy.forbidden_roots = vec![home.clone()];
let mut index = DirSizeIndex::with_policy(policy);
assert!(matches!(
index.measure(&home),
Err(SizeIndexError::ForbiddenRoot { .. })
));
assert!(
matches!(
index.measure(tmp.path()),
Err(SizeIndexError::ForbiddenRoot { .. })
),
"an ancestor of a forbidden root is forbidden too"
);
assert_eq!(
index.stats().refreshes,
0,
"both refusals happen before any walk"
);
let inside = index.measure(&home.join("project")).expect("below is fine");
assert_eq!(inside.bytes, 10);
}
#[test]
fn a_dot_dot_path_into_a_forbidden_root_is_refused() {
let tmp = tempfile::tempdir().expect("tempdir");
let home = tmp.path().join("home");
write_file(&home.join("project/f"), 10);
let mut policy = always_refresh();
policy.forbidden_roots = vec![home.clone()];
let mut index = DirSizeIndex::with_policy(policy);
let detour = home.join("project").join("..");
assert!(
matches!(
index.measure(&detour),
Err(SizeIndexError::ForbiddenRoot { .. })
),
"`home/project/..` IS `home`"
);
let above = home.join("project").join("..").join("..");
assert!(
matches!(
index.measure(&above),
Err(SizeIndexError::ForbiddenRoot { .. })
),
"`home/project/../..` is the forbidden root's parent"
);
assert_eq!(
index.stats().refreshes,
0,
"neither spelling may reach a walk"
);
}
#[test]
fn lexical_normalize_folds_dot_and_dot_dot() {
assert_eq!(
lexical_normalize(Path::new("/a/b/../../c")),
PathBuf::from("/c")
);
assert_eq!(
lexical_normalize(Path::new("/a/../..")),
PathBuf::from("/"),
"popping past the root stops at the root"
);
assert_eq!(
lexical_normalize(Path::new("/a/./b")),
PathBuf::from("/a/b"),
"a `.` segment is dropped"
);
assert_eq!(
lexical_normalize(Path::new("/a/b/")),
PathBuf::from("/a/b"),
"a trailing slash changes nothing"
);
assert_eq!(
lexical_normalize(Path::new("/a/b")),
PathBuf::from("/a/b"),
"a path with nothing to fold is returned unchanged"
);
}
#[test]
fn a_cached_read_still_reports_truncation() {
let tmp = tempfile::tempdir().expect("tempdir");
known_tree(tmp.path());
let mut policy = test_policy(DEFAULT_MAX_AGE, DEFAULT_NODE_MAX_AGE);
policy.max_depth = 1;
let mut index = DirSizeIndex::with_policy(policy);
let first = index.measure(tmp.path()).expect("first measure");
assert!(first.truncated);
assert!(!first.from_cache);
let cached = index.measure(tmp.path()).expect("cached measure");
assert!(cached.from_cache, "the second read must come from cache");
assert!(cached.truncated, "truncation must survive the cache hit");
assert_eq!(cached.bytes, first.bytes);
assert_eq!(cached.unreadable, first.unreadable);
assert_eq!(index.stats().cache_hits, 1);
}
#[test]
fn the_node_backstop_bound_is_exact() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
write_file(&root.join("f"), 10);
let mtime = fs::symlink_metadata(root)
.expect("stat root")
.modified()
.expect("mtime");
let planted = |read_at: Instant| DirNode {
mtime: Some(mtime),
own_bytes: 999,
children: Vec::new(),
read_at,
};
let mut inside =
DirSizeIndex::with_policy(test_policy(Duration::ZERO, Duration::from_secs(600)));
inside
.nodes
.insert(root.to_path_buf(), planted(Instant::now()));
let reused = inside.measure(root).expect("measure inside the bound");
assert_eq!(reused.bytes, 999, "a node inside the bound is reused as-is");
assert_eq!(inside.stats().directories_read, 0);
assert_eq!(inside.stats().directories_revalidated, 1);
let mut outside =
DirSizeIndex::with_policy(test_policy(Duration::ZERO, Duration::from_millis(50)));
let expired = Instant::now()
.checked_sub(Duration::from_millis(60))
.expect("the process started more than 60ms ago");
outside.nodes.insert(root.to_path_buf(), planted(expired));
let reread = outside.measure(root).expect("measure outside the bound");
assert_eq!(reread.bytes, 10, "an expired node is re-read from disk");
assert_eq!(outside.stats().directories_read, 1);
assert_eq!(outside.stats().directories_revalidated, 0);
}
#[test]
fn a_file_path_is_not_a_directory() {
let tmp = tempfile::tempdir().expect("tempdir");
let file = tmp.path().join("f");
write_file(&file, 10);
let mut index = DirSizeIndex::with_policy(always_refresh());
assert!(matches!(
index.measure(&file),
Err(SizeIndexError::NotADirectory { .. })
));
assert!(matches!(
index.measure(&tmp.path().join("missing")),
Err(SizeIndexError::NotADirectory { .. })
));
}
#[test]
fn a_caller_budget_bounds_the_walk_below_the_policy_ceiling() {
let tmp = tempfile::tempdir().expect("tempdir");
known_tree(tmp.path());
let mut index = DirSizeIndex::with_policy(always_refresh());
assert_eq!(index.policy().walk_budget, DEFAULT_WALK_BUDGET);
let started = Instant::now();
let size = index
.measure_within(tmp.path(), Some(Duration::ZERO))
.expect("measure_within");
assert!(
size.truncated,
"a walk the budget stopped must say so, not report a short total as a \
complete one: {size:?}"
);
assert!(
started.elapsed() < Duration::from_secs(1),
"the caller's budget, not the 30-second ceiling, bounded the walk: {:?}",
started.elapsed()
);
}
#[test]
fn the_policy_ceiling_still_caps_a_generous_caller_budget() {
let tmp = tempfile::tempdir().expect("tempdir");
known_tree(tmp.path());
let mut policy = always_refresh();
policy.walk_budget = Duration::ZERO;
let mut index = DirSizeIndex::with_policy(policy);
let size = index
.measure_within(tmp.path(), Some(Duration::from_secs(600)))
.expect("measure_within");
assert!(
size.truncated,
"a caller asking for ten minutes still gets the policy's zero: {size:?}"
);
}
#[test]
fn a_budget_exhausted_partial_is_never_cached_as_the_root_total() {
let tmp = tempfile::tempdir().expect("tempdir");
known_tree(tmp.path());
let mut index = DirSizeIndex::with_policy(test_policy(DEFAULT_MAX_AGE, DEFAULT_NODE_MAX_AGE));
let partial = index
.measure_within(tmp.path(), Some(Duration::ZERO))
.expect("budgeted measure");
assert!(partial.truncated, "{partial:?}");
let full = index.measure(tmp.path()).expect("unbudgeted measure");
assert!(
!full.from_cache,
"the partial must not have been cached, or this read would serve it: {full:?}"
);
assert_eq!(
full.bytes, 100,
"the full walk still reaches the whole tree"
);
assert!(!full.truncated, "{full:?}");
assert_eq!(index.stats().cache_hits, 0);
}
#[test]
fn no_caller_budget_leaves_the_policy_ceiling_standing() {
let tmp = tempfile::tempdir().expect("tempdir");
known_tree(tmp.path());
let mut index = DirSizeIndex::with_policy(always_refresh());
let size = index
.measure_within(tmp.path(), None)
.expect("measure_within");
assert_eq!(size.bytes, 100);
assert!(!size.truncated, "{size:?}");
}