use std::path::{Path, PathBuf};
use std::time::SystemTime;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TempEntry {
pub(crate) path: PathBuf,
pub(crate) bytes: u64,
pub(crate) is_dir: bool,
pub(crate) age_hours: u64,
pub(crate) newest_content_age_hours: Option<u64>,
}
impl TempEntry {
pub(crate) fn label(&self) -> &'static str {
if self.is_dir {
"tmp-dir"
} else {
"tmp-file"
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct OversizedLog {
pub(crate) path: PathBuf,
pub(crate) bytes: u64,
pub(crate) rotated: bool,
pub(crate) age_hours: u64,
}
impl OversizedLog {
pub(crate) fn reason(&self) -> String {
if self.rotated {
format!("rotated log, {} old", human_age(self.age_hours))
} else {
format!(
"active log over {} MiB",
OVERSIZED_LOG_BYTES / (1024 * 1024)
)
}
}
}
pub(crate) const OVERSIZED_LOG_BYTES: u64 = 256 * 1024 * 1024;
fn human_age(hours: u64) -> String {
if hours >= 48 {
format!("{} days", hours / 24)
} else {
format!("{hours}h")
}
}
pub(crate) fn age_hours(modified: SystemTime, now: SystemTime) -> Option<u64> {
now.duration_since(modified)
.ok()
.map(|delta| delta.as_secs() / 3600)
}
pub(crate) fn is_rotated_log_name(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
if !lower.contains(".log") {
return false;
}
if lower.rsplit_once(".log").is_some_and(|(_, rest)| {
let rest = rest.trim_end_matches(".gz").trim_end_matches(".xz");
!rest.is_empty() && rest.trim_start_matches('.').chars().all(|c| c.is_ascii_digit())
}) {
return true;
}
lower
.split(|c: char| !c.is_ascii_alphanumeric())
.any(|part| part.len() == 8 && part.chars().all(|c| c.is_ascii_digit()))
}
pub(crate) const TEMP_ROOTS: [&str; 2] = ["/tmp", "/var/tmp"];
pub(crate) const LOG_ROOTS: [&str; 2] = ["/var/log", "/home/dracon/.dracon/logs"];
pub(crate) fn find_temp_cruft(
roots: &[&str],
min_age_hours: u64,
now: SystemTime,
) -> Vec<TempEntry> {
let mut found = Vec::new();
for root in roots {
let Ok(entries) = std::fs::read_dir(root) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(metadata) = std::fs::symlink_metadata(&path) else {
continue;
};
if metadata.file_type().is_symlink() {
continue;
}
let Ok(modified) = metadata.modified() else {
continue;
};
let Some(hours) = age_hours(modified, now) else {
continue;
};
if hours < min_age_hours {
continue;
}
found.push(TempEntry {
path,
bytes: if metadata.is_dir() { 0 } else { metadata.len() },
is_dir: metadata.is_dir(),
age_hours: hours,
newest_content_age_hours: if metadata.is_dir() {
None
} else {
Some(hours)
},
});
}
}
let dir_paths: Vec<PathBuf> = found
.iter()
.filter(|entry| entry.is_dir)
.map(|entry| entry.path.clone())
.collect();
let measured = directory_bytes_and_age_parallel(&dir_paths, now);
let mut measured = measured.into_iter();
for entry in found.iter_mut().filter(|entry| entry.is_dir) {
let (bytes, age) = measured.next().unwrap_or((0, None));
entry.bytes = bytes;
entry.newest_content_age_hours = age;
}
found.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.path.cmp(&b.path)));
found
}
pub(crate) fn find_oversized_logs(
roots: &[&str],
min_age_hours: u64,
now: SystemTime,
) -> Vec<OversizedLog> {
let mut found = Vec::new();
for root in roots {
let Ok(entries) = std::fs::read_dir(root) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(metadata) = std::fs::symlink_metadata(&path) else {
continue;
};
if metadata.file_type().is_symlink() || !metadata.is_file() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
let rotated = is_rotated_log_name(&name);
let bytes = metadata.len();
if !rotated && bytes < OVERSIZED_LOG_BYTES {
continue;
}
let age = metadata
.modified()
.ok()
.and_then(|modified| age_hours(modified, now))
.unwrap_or(0);
if rotated && age < min_age_hours {
continue;
}
found.push(OversizedLog {
path,
bytes,
rotated,
age_hours: age,
});
}
}
found.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.path.cmp(&b.path)));
found
}
pub(crate) fn directory_bytes_and_age_parallel(
roots: &[PathBuf],
now: SystemTime,
) -> Vec<(u64, Option<u64>)> {
if roots.is_empty() {
return Vec::new();
}
let workers = std::thread::available_parallelism()
.map(|n| n.get().min(8))
.unwrap_or(4);
let mut out = vec![(0u64, None); roots.len()];
let chunk_size = roots.len().div_ceil(workers).max(1);
std::thread::scope(|scope| {
let mut rest = out.as_mut_slice();
let mut remaining = roots;
while !rest.is_empty() {
let take = chunk_size.min(rest.len());
let (mine, tail) = rest.split_at_mut(take);
let (roots_mine, roots_tail) = remaining.split_at(take);
scope.spawn(move || {
for (slot, root) in mine.iter_mut().zip(roots_mine.iter()) {
*slot = directory_bytes_and_age(root, now);
}
});
rest = tail;
remaining = roots_tail;
}
});
out
}
pub(crate) fn directory_bytes_and_age(root: &Path, now: SystemTime) -> (u64, Option<u64>) {
const LIMIT: usize = 200_000;
let mut total = 0u64;
let mut seen = 0usize;
let mut newest: Option<SystemTime> = None;
let mut unreadable = false;
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
unreadable = true;
continue;
};
for entry in entries.flatten() {
seen += 1;
if seen > LIMIT {
return (total, None);
}
let Ok(metadata) = std::fs::symlink_metadata(entry.path()) else {
unreadable = true;
continue;
};
if metadata.file_type().is_symlink() {
continue;
}
match metadata.modified() {
Ok(modified) => {
if newest.is_none_or(|current| modified > current) {
newest = Some(modified);
}
}
Err(_) => unreadable = true,
}
if metadata.is_dir() {
stack.push(entry.path());
} else {
total += metadata.len();
}
}
}
if unreadable {
return (total, None);
}
let newest = match newest {
Some(modified) => Some(modified),
None if seen == 0 => std::fs::symlink_metadata(root)
.ok()
.and_then(|metadata| metadata.modified().ok()),
None => None,
};
let age = newest.and_then(|modified| age_hours(modified, now));
(total, age)
}
pub(crate) fn directory_bytes(root: &Path) -> u64 {
const LIMIT: usize = 200_000;
let mut total = 0u64;
let mut seen = 0usize;
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
seen += 1;
if seen > LIMIT {
return total;
}
let Ok(metadata) = std::fs::symlink_metadata(entry.path()) else {
continue;
};
if metadata.file_type().is_symlink() {
continue;
}
if metadata.is_dir() {
stack.push(entry.path());
} else {
total += metadata.len();
}
}
}
total
}
#[cfg(test)]
mod tmp_log_tests {
use super::*;
use std::time::Duration;
fn now() -> SystemTime {
SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000)
}
fn write(path: &Path, bytes: usize, age_hours: u64) {
std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
std::fs::write(path, vec![b'x'; bytes]).expect("write");
let when = now() - Duration::from_secs(age_hours * 3600 + 60);
let file = std::fs::File::options()
.write(true)
.open(path)
.expect("open");
file.set_modified(when).expect("set mtime");
}
#[test]
fn only_entries_past_the_age_floor_are_cruft() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
write(&root.join("old.txt"), 10, 48);
write(&root.join("fresh.txt"), 10, 1);
let found = find_temp_cruft(&[root.to_str().expect("utf8")], 24, now());
let names: Vec<String> = found
.iter()
.map(|e| e.path.file_name().expect("name").to_string_lossy().into_owned())
.collect();
assert_eq!(names, vec!["old.txt".to_string()]);
assert_eq!(found[0].bytes, 10);
assert!(!found[0].is_dir);
}
#[test]
fn symlinks_are_never_reported_because_removing_them_reclaims_nothing() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
write(&root.join("real.txt"), 5, 48);
std::os::unix::fs::symlink(root.join("real.txt"), root.join("link")).expect("symlink");
let found = find_temp_cruft(&[root.to_str().expect("utf8")], 1, now());
let names: Vec<String> = found
.iter()
.map(|e| e.path.file_name().expect("name").to_string_lossy().into_owned())
.collect();
assert_eq!(names, vec!["real.txt".to_string()]);
}
#[test]
fn a_directory_reports_the_size_of_its_whole_tree() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
write(&root.join("build/one.bin"), 100, 48);
write(&root.join("build/nested/two.bin"), 200, 48);
let build = root.join("build");
let aged = now() - Duration::from_secs(48 * 3600 + 60);
std::fs::File::options()
.read(true)
.open(&build)
.expect("open dir")
.set_modified(aged)
.expect("set dir mtime");
let found = find_temp_cruft(&[root.to_str().expect("utf8")], 24, now());
assert_eq!(found.len(), 1);
assert!(found[0].is_dir);
assert_eq!(found[0].bytes, 300, "directory size is the whole tree");
}
#[test]
fn rotated_log_families_are_recognised() {
assert!(is_rotated_log_name("app.log.1"));
assert!(is_rotated_log_name("app.log.2.gz"));
assert!(is_rotated_log_name("app-20261026.log"));
assert!(!is_rotated_log_name("app.log"), "the live log is not rotated");
assert!(!is_rotated_log_name("notes.txt"));
}
#[test]
fn a_fresh_rotation_is_not_reported_but_an_old_one_is() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
write(&root.join("app.log.1"), 10, 1);
write(&root.join("app.log.2"), 10, 72);
let found = find_oversized_logs(&[root.to_str().expect("utf8")], 24, now());
let names: Vec<String> = found
.iter()
.map(|e| e.path.file_name().expect("name").to_string_lossy().into_owned())
.collect();
assert_eq!(
names,
vec!["app.log.2".to_string()],
"the live rotation must survive: it became the current log one rename ago"
);
assert!(found[0].rotated);
}
#[test]
fn an_oversized_live_log_is_reported_even_when_not_rotated() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
let path = root.join("huge.log");
std::fs::create_dir_all(root).expect("mkdir");
let file = std::fs::File::create(&path).expect("create");
file.set_len(OVERSIZED_LOG_BYTES + 1).expect("sparse set_len");
drop(file);
let found = find_oversized_logs(&[root.to_str().expect("utf8")], 24, now());
assert_eq!(found.len(), 1);
assert!(!found[0].rotated);
assert!(found[0].reason().contains("active log over"));
}
#[test]
fn a_small_recent_log_is_ignored() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
write(&root.join("small.log"), 100, 0);
assert!(find_oversized_logs(&[root.to_str().expect("utf8")], 24, now()).is_empty());
}
}