use log::{info, trace, warn};
use std::{env, io};
use std::{
fs,
path::{Path, PathBuf},
time::{Duration, SystemTime},
};
pub const DEFAULT_MAX_AGE_ENV: &'static str = "UVM_GC_MAX_AGE";
pub const DEFAULT_MAX_AGE: Duration = Duration::from_secs(2_630_016);
pub const DEFAULT_MAX_AGE_HUMAN: &'static str = "1month";
fn parse_max_age_from_string(max_age_human_value: &str) -> Duration {
trace!("Use max age value: {}", max_age_human_value);
match humantime::parse_duration(max_age_human_value) {
Ok(duration) => duration,
Err(_) => {
warn!("Invalid GC max age value: {}", max_age_human_value);
warn!("Using default GCmax age: {}", DEFAULT_MAX_AGE_HUMAN);
DEFAULT_MAX_AGE
}
}
}
pub fn default_max_age() -> Duration {
let max_age_human_value = env::var(DEFAULT_MAX_AGE_ENV).unwrap_or_else(|_| {
trace!(
"No max age value found in environment variable: {}. Using default max age: {}",
DEFAULT_MAX_AGE_ENV, DEFAULT_MAX_AGE_HUMAN
);
DEFAULT_MAX_AGE_HUMAN.to_owned()
});
parse_max_age_from_string(&max_age_human_value)
}
pub struct GarbageCollector {
dry_run: bool,
max_age: Duration,
base_dir: PathBuf,
}
impl GarbageCollector {
pub fn new<P: AsRef<Path>>(base_dir: P) -> Self {
Self {
dry_run: true,
max_age: default_max_age(),
base_dir: base_dir.as_ref().to_path_buf(),
}
}
pub fn with_dry_run(mut self, dry_run: bool) -> Self {
self.dry_run = dry_run;
self
}
pub fn with_max_age(mut self, max_age: Duration) -> Self {
self.max_age = max_age;
self
}
pub fn collect(&self) -> io::Result<()> {
info!(
"Cleaning up files older than {} in {}",
humantime::format_duration(self.max_age),
self.base_dir.display()
);
for (entry, accessed_elapsed) in walkdir::WalkDir::new(&self.base_dir)
.into_iter()
.filter_map(Result::ok)
.filter(|entry| entry.path().is_file())
.filter_map(|entry| fs::metadata(entry.path()).ok().map(|m| (entry, m)))
.filter_map(|(entry, metadata)| {
let accessed = metadata.accessed().unwrap_or_else(|_| SystemTime::now());
let accessed_elapsed = accessed.elapsed().unwrap_or_default();
if accessed_elapsed > self.max_age {
Some((entry, accessed_elapsed))
} else {
None
}
})
{
info!(
"Deleting file: {} ({} old)",
entry.path().display(),
humantime::format_duration(accessed_elapsed)
);
if !self.dry_run {
fs::remove_file(entry.path())?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use tempfile::tempdir;
#[test]
fn test_garbage_collector_respects_large_max_age() {
let temp_dir = tempdir().expect("Failed to create temp directory");
let temp_path = temp_dir.path();
let file1 = temp_path.join("recent_file.txt");
File::create(&file1).expect("Failed to create test file");
let gc = GarbageCollector::new(&temp_path)
.with_dry_run(false)
.with_max_age(Duration::from_secs(365 * 24 * 60 * 60));
let result = gc.collect();
assert!(result.is_ok());
assert!(file1.exists());
}
#[test]
fn test_garbage_collector_respects_zero_max_age() {
let temp_dir = tempdir().expect("Failed to create temp directory");
let temp_path = temp_dir.path();
let file1 = temp_path.join("any_file.txt");
File::create(&file1).expect("Failed to create test file");
let gc = GarbageCollector::new(&temp_path)
.with_dry_run(false)
.with_max_age(Duration::from_secs(0));
let result = gc.collect();
assert!(result.is_ok());
assert!(!file1.exists());
}
#[test]
fn test_garbage_collector_dry_run_preserves_files() {
let temp_dir = tempdir().expect("Failed to create temp directory");
let temp_path = temp_dir.path();
let file1 = temp_path.join("test1.txt");
let file2 = temp_path.join("test2.txt");
File::create(&file1).expect("Failed to create test file");
File::create(&file2).expect("Failed to create test file");
let gc = GarbageCollector::new(&temp_path)
.with_dry_run(true)
.with_max_age(Duration::from_secs(0));
let result = gc.collect();
assert!(result.is_ok());
assert!(file1.exists());
assert!(file2.exists());
}
#[test]
fn test_garbage_collector_execute_deletes_files() {
let temp_dir = tempdir().expect("Failed to create temp directory");
let temp_path = temp_dir.path();
let file1 = temp_path.join("test1.txt");
let file2 = temp_path.join("test2.txt");
File::create(&file1).expect("Failed to create test file");
File::create(&file2).expect("Failed to create test file");
let gc = GarbageCollector::new(&temp_path)
.with_dry_run(false)
.with_max_age(Duration::from_secs(0));
let result = gc.collect();
assert!(result.is_ok());
assert!(!file1.exists());
assert!(!file2.exists());
}
#[test]
fn default_human_matches_const() {
let parsed = humantime::parse_duration(DEFAULT_MAX_AGE_HUMAN).unwrap();
assert_eq!(parsed, DEFAULT_MAX_AGE);
}
#[test]
fn test_parse_max_age_with_valid_durations() {
let test_cases = vec![
("30days", Duration::from_secs(30 * 24 * 60 * 60)),
("2weeks", Duration::from_secs(2 * 7 * 24 * 60 * 60)),
("1hour", Duration::from_secs(60 * 60)),
("45min", Duration::from_secs(45 * 60)),
("3600s", Duration::from_secs(3600)),
("1month", Duration::from_secs(2_630_016)), ];
for (input, expected) in test_cases {
let result = parse_max_age_from_string(input);
assert_eq!(result, expected, "Failed for input: {}", input);
}
}
#[test]
fn test_parse_max_age_with_invalid_durations() {
let invalid_values = vec![
"completely_invalid",
"not_a_duration",
"",
"xyz123",
"1.5.3seconds",
];
for invalid_value in invalid_values {
let result = parse_max_age_from_string(invalid_value);
assert_eq!(result, DEFAULT_MAX_AGE, "Failed for invalid input: {}", invalid_value);
}
}
#[test]
fn test_parse_max_age_with_whitespace() {
let whitespace_values = vec![
(" ", DEFAULT_MAX_AGE), ("\t", DEFAULT_MAX_AGE), ("\n", DEFAULT_MAX_AGE), (" 1hour ", Duration::from_secs(3600)), ];
for (input, expected) in whitespace_values {
let result = parse_max_age_from_string(input);
assert_eq!(result, expected, "Failed for input: {:?}", input);
}
}
#[test]
fn test_parse_max_age_edge_cases() {
let edge_cases = vec![
("0s", Duration::from_secs(0)),
("1ns", Duration::from_nanos(1)),
("999999999ns", Duration::from_nanos(999999999)),
];
for (input, expected) in edge_cases {
let result = parse_max_age_from_string(input);
assert_eq!(result, expected, "Failed for edge case: {}", input);
}
}
#[test]
fn test_default_max_age_uses_default_when_no_env() {
let result = default_max_age();
assert!(result.as_secs() > 0);
}
}