use std::collections::{BTreeMap, BTreeSet}; use std::time::SystemTime; use crate::age::MinAge; use crate::config::Config; use crate::reclaim::Category; /// What the user asked a command to look at. #[derive(Debug, Clone)] pub struct Selection { pub categories: BTreeSet, /// A threshold from the command line, overriding every configured one. pub min_age: Option, } impl Default for Selection { fn default() -> Self { Self { categories: Category::ALL.into_iter().collect(), min_age: None, } } } /// Why a scan is happening, which decides how much work is worth doing. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Intent { /// Report on everything, exactly — a person is reading the numbers, so /// data being kept gets measured like everything else. Survey, /// Find what can go. Data being kept is counted but not measured: nothing /// reads the number, and measuring a tree that is not going to be touched /// is the single most expensive thing a clean could do. Clean, } /// The rules for one run, resolved once so nothing downstream re-derives them. /// /// Thresholds are worked out here rather than consulted per item, and `now` is /// fixed at the start, so every item in a run is judged against the same /// instant and a `--dry-run` describes the clean that follows it. #[derive(Debug, Clone)] pub struct Policy { now: SystemTime, thresholds: BTreeMap, categories: BTreeSet, intent: Intent, } impl Policy { pub fn new(config: &Config, selection: &Selection, intent: Intent) -> Self { let thresholds = Category::ALL .into_iter() .map(|category| { let min_age = selection .min_age .unwrap_or_else(|| config.min_age(category)); (category, min_age) }) .collect(); Self { now: SystemTime::now(), thresholds, categories: selection.categories.clone(), intent, } } pub fn now(&self) -> SystemTime { self.now } /// Why this run is happening. /// /// Providers match on this to decide how much work to do for data they are /// not going to delete. What that means differs between them — a walk can /// stop early, a database lookup has nothing to skip — so the choice is /// theirs rather than something expressed as one blanket predicate here. pub fn intent(&self) -> Intent { self.intent } /// Whether this run cares about a category at all. pub fn wants(&self, category: Category) -> bool { self.categories.contains(&category) } /// Every category this run is looking at. pub fn categories(&self) -> impl Iterator + '_ { self.categories.iter().copied() } pub fn min_age(&self, category: Category) -> MinAge { self.thresholds .get(&category) .copied() .unwrap_or(MinAge::new(std::time::Duration::ZERO)) } /// The instant at or before which data of this category may be deleted. /// /// Anything modified after this is too new to touch. Expressing the rule as /// a point in time rather than a duration is what lets a walk stop at the /// first file past it, without having to know how old the tree is overall. /// /// A threshold too large for the clock to subtract falls back to the epoch, /// which makes everything too new to delete. That is the safe direction to /// fail in: the alternative is wrapping into the future and finding the /// whole disk stale. pub fn cutoff(&self, category: Category) -> SystemTime { self.now .checked_sub(self.min_age(category).as_duration()) .unwrap_or(SystemTime::UNIX_EPOCH) } /// Whether data last used at `last_used` is old enough to delete. pub fn is_eligible(&self, category: Category, last_used: SystemTime) -> bool { self.min_age(category).is_eligible(last_used, self.now) } } #[cfg(test)] mod tests { use super::*; use std::time::Duration; const DAY: Duration = Duration::from_secs(24 * 60 * 60); fn config(toml: &str) -> Config { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("grunk.toml"); std::fs::write(&path, toml).unwrap(); Config::load_from(&path).unwrap() } #[test] fn thresholds_come_from_the_config() { let policy = Policy::new( &config("min_age = \"3d\"\n\n[min_age_overrides]\ntarget = \"60d\"\n"), &Selection::default(), Intent::Clean, ); assert_eq!(policy.min_age(Category::Target), MinAge::new(60 * DAY)); assert_eq!( policy.min_age(Category::RegistryCrate), MinAge::new(3 * DAY) ); } #[test] fn the_command_line_overrides_every_configured_threshold() { let policy = Policy::new( &config("min_age = \"3d\"\n\n[min_age_overrides]\ntarget = \"60d\"\n"), &Selection { min_age: Some(MinAge::new(Duration::ZERO)), ..Selection::default() }, Intent::Clean, ); // Even the per-category override gives way. assert_eq!( policy.min_age(Category::Target), MinAge::new(Duration::ZERO) ); assert_eq!( policy.min_age(Category::RegistryCrate), MinAge::new(Duration::ZERO) ); } #[test] fn the_cutoff_agrees_with_the_eligibility_test() { // A walk stops at the first file past the cutoff, while database-backed // entries are judged by `is_eligible`. The two must not disagree, or a // target and a crate of identical age would get different verdicts. let policy = Policy::new( &config("min_age = \"3d\"\n"), &Selection::default(), Intent::Clean, ); let cutoff = policy.cutoff(Category::Target); assert!(policy.is_eligible(Category::Target, cutoff)); assert!(policy.is_eligible(Category::Target, cutoff - DAY)); assert!(!policy.is_eligible(Category::Target, cutoff + Duration::from_secs(1))); } #[test] fn an_absurd_threshold_keeps_everything_rather_than_wrapping_the_clock() { // 100,000 days lands centuries before the epoch. `SystemTime` handles // that without complaint, so the guard in `cutoff` rarely fires — what // matters is that the arithmetic never wraps round into the future and // starts calling live data stale. let policy = Policy::new( &config("min_age = \"100000d\"\n"), &Selection::default(), Intent::Clean, ); assert!(policy.cutoff(Category::Target) < policy.now()); assert!(!policy.is_eligible(Category::Target, policy.now())); assert!(!policy.is_eligible(Category::Target, SystemTime::UNIX_EPOCH)); } #[test] fn the_intent_reaches_the_providers() { let selection = Selection::default(); let cfg = config("min_age = \"3d\"\n"); assert_eq!( Policy::new(&cfg, &selection, Intent::Survey).intent(), Intent::Survey ); assert_eq!( Policy::new(&cfg, &selection, Intent::Clean).intent(), Intent::Clean ); } #[test] fn selection_limits_the_categories() { let policy = Policy::new( &config("min_age = \"3d\"\n"), &Selection { categories: BTreeSet::from([Category::Target]), min_age: None, }, Intent::Clean, ); assert!(policy.wants(Category::Target)); assert!(!policy.wants(Category::RegistryCrate)); assert_eq!( policy.categories().collect::>(), vec![Category::Target] ); } }