Skip to main content

shiplog_cache/
expiry.rs

1//! Canonical cache-expiry helpers for shiplog cache implementations.
2//!
3//! This crate has a single responsibility:
4//! - represent cache timestamp windows (`cached_at`, `expires_at`)
5//! - provide canonical validity semantics (`expires_at > now`)
6
7use chrono::{DateTime, Duration, ParseError, Utc};
8
9/// A cache timestamp window.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub struct CacheExpiryWindow {
12    pub cached_at: DateTime<Utc>,
13    pub expires_at: DateTime<Utc>,
14}
15
16impl CacheExpiryWindow {
17    /// Build a window from the current UTC time and a TTL.
18    #[must_use]
19    pub fn from_now(ttl: Duration) -> Self {
20        Self::from_base(Utc::now(), ttl)
21    }
22
23    /// Build a window from an explicit base timestamp and a TTL.
24    #[must_use]
25    pub fn from_base(cached_at: DateTime<Utc>, ttl: Duration) -> Self {
26        Self {
27            cached_at,
28            expires_at: cached_at + ttl,
29        }
30    }
31
32    /// Whether this window is expired at `now`.
33    #[must_use]
34    pub fn is_expired_at(&self, now: DateTime<Utc>) -> bool {
35        is_expired(self.expires_at, now)
36    }
37
38    /// Whether this window is valid at `now`.
39    #[must_use]
40    pub fn is_valid_at(&self, now: DateTime<Utc>) -> bool {
41        is_valid(self.expires_at, now)
42    }
43
44    /// Cached-at timestamp encoded as RFC3339.
45    #[must_use]
46    pub fn cached_at_rfc3339(&self) -> String {
47        self.cached_at.to_rfc3339()
48    }
49
50    /// Expires-at timestamp encoded as RFC3339.
51    #[must_use]
52    pub fn expires_at_rfc3339(&self) -> String {
53        self.expires_at.to_rfc3339()
54    }
55}
56
57/// Current UTC timestamp encoded as RFC3339.
58#[must_use]
59pub fn now_rfc3339() -> String {
60    Utc::now().to_rfc3339()
61}
62
63/// Canonical expiry predicate: an entry is expired when `expires_at <= now`.
64#[must_use]
65pub fn is_expired(expires_at: DateTime<Utc>, now: DateTime<Utc>) -> bool {
66    expires_at <= now
67}
68
69/// Canonical validity predicate: an entry is valid when `expires_at > now`.
70#[must_use]
71pub fn is_valid(expires_at: DateTime<Utc>, now: DateTime<Utc>) -> bool {
72    expires_at > now
73}
74
75/// Parse an RFC3339 timestamp into UTC.
76pub fn parse_rfc3339_utc(raw: &str) -> Result<DateTime<Utc>, ParseError> {
77    DateTime::parse_from_rfc3339(raw).map(|dt| dt.with_timezone(&Utc))
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    fn dt(secs: i64) -> DateTime<Utc> {
85        DateTime::<Utc>::from_timestamp(secs, 0).expect("valid timestamp")
86    }
87
88    #[test]
89    fn window_from_base_keeps_exact_ttl_delta() {
90        let cached = dt(1_700_000_000);
91        let window = CacheExpiryWindow::from_base(cached, Duration::seconds(90));
92        assert_eq!(window.expires_at - window.cached_at, Duration::seconds(90));
93    }
94
95    #[test]
96    fn validity_and_expiry_follow_strict_gt_contract() {
97        let cached = dt(1_700_000_000);
98        let window = CacheExpiryWindow::from_base(cached, Duration::seconds(30));
99        let at_expiry = cached + Duration::seconds(30);
100
101        assert!(window.is_valid_at(cached));
102        assert!(!window.is_valid_at(at_expiry));
103        assert!(window.is_expired_at(at_expiry));
104    }
105
106    #[test]
107    fn rfc3339_round_trip_preserves_timestamp() {
108        let cached = dt(1_700_000_000);
109        let window = CacheExpiryWindow::from_base(cached, Duration::seconds(60));
110
111        let parsed_cached = parse_rfc3339_utc(&window.cached_at_rfc3339()).unwrap();
112        let parsed_expires = parse_rfc3339_utc(&window.expires_at_rfc3339()).unwrap();
113
114        assert_eq!(parsed_cached, window.cached_at);
115        assert_eq!(parsed_expires, window.expires_at);
116    }
117}