policy.rs
raw
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<Category>,
/// A threshold from the command line, overriding every configured one.
pub min_age: Option<MinAge>,
}
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<Category, MinAge>,
categories: BTreeSet<Category>,
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<Item = Category> + '_ {
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<_>>(),
vec![Category::Target]
);
}
}