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]
        );
    }
}