age.rs raw

use std::fmt;
use std::str::FromStr;
use std::time::{Duration, SystemTime};

use serde::{Deserialize, Deserializer};

/// How long data must have gone unused before `clean` will delete it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct MinAge(Duration);

impl MinAge {
    pub const fn new(threshold: Duration) -> Self {
        Self(threshold)
    }

    pub const fn as_duration(self) -> Duration {
        self.0
    }

    /// Whether data last used at `last_used` has gone untouched long enough to delete.
    ///
    /// Data with a `last_used` in the future is never eligible: that means the
    /// clock moved backwards, and guessing at a real age from a broken reading
    /// is how you delete something that is actually in use.
    pub fn is_eligible(self, last_used: SystemTime, now: SystemTime) -> bool {
        match now.duration_since(last_used) {
            Ok(age) => age >= self.0,
            Err(_) => false,
        }
    }
}

impl FromStr for MinAge {
    type Err = humantime::DurationError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        humantime::parse_duration(s).map(MinAge)
    }
}

impl fmt::Display for MinAge {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", humantime::format_duration(self.0))
    }
}

impl<'de> Deserialize<'de> for MinAge {
    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        let raw = String::deserialize(deserializer)?;
        raw.parse().map_err(serde::de::Error::custom)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const DAY: Duration = Duration::from_secs(60 * 60 * 24);

    #[test]
    fn parses_humantime_units() {
        assert_eq!("3d".parse::<MinAge>().unwrap(), MinAge::new(3 * DAY));
        assert_eq!(
            "90s".parse::<MinAge>().unwrap(),
            MinAge::new(Duration::from_secs(90))
        );
        assert_eq!(
            "1h 30m".parse::<MinAge>().unwrap(),
            MinAge::new(Duration::from_secs(5400))
        );
    }

    #[test]
    fn rejects_nonsense() {
        assert!("soon".parse::<MinAge>().is_err());
        assert!("".parse::<MinAge>().is_err());
    }

    #[test]
    fn eligibility_is_inclusive_at_the_threshold() {
        let now = SystemTime::UNIX_EPOCH + 10 * DAY;
        let three_days = MinAge::new(3 * DAY);

        assert!(three_days.is_eligible(now - 3 * DAY, now));
        assert!(three_days.is_eligible(now - 4 * DAY, now));
        assert!(!three_days.is_eligible(now - 2 * DAY, now));
    }

    #[test]
    fn zero_threshold_takes_everything_present_or_past() {
        let now = SystemTime::UNIX_EPOCH + 10 * DAY;

        assert!(MinAge::new(Duration::ZERO).is_eligible(now, now));
        assert!(MinAge::new(Duration::ZERO).is_eligible(now - DAY, now));
    }

    #[test]
    fn future_timestamps_are_never_eligible() {
        let now = SystemTime::UNIX_EPOCH + 10 * DAY;

        assert!(!MinAge::new(Duration::ZERO).is_eligible(now + DAY, now));
        assert!(!MinAge::new(3 * DAY).is_eligible(now + 100 * DAY, now));
    }
}