1use chrono::{DateTime, Duration, ParseError, Utc};
8
9#[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 #[must_use]
19 pub fn from_now(ttl: Duration) -> Self {
20 Self::from_base(Utc::now(), ttl)
21 }
22
23 #[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 #[must_use]
34 pub fn is_expired_at(&self, now: DateTime<Utc>) -> bool {
35 is_expired(self.expires_at, now)
36 }
37
38 #[must_use]
40 pub fn is_valid_at(&self, now: DateTime<Utc>) -> bool {
41 is_valid(self.expires_at, now)
42 }
43
44 #[must_use]
46 pub fn cached_at_rfc3339(&self) -> String {
47 self.cached_at.to_rfc3339()
48 }
49
50 #[must_use]
52 pub fn expires_at_rfc3339(&self) -> String {
53 self.expires_at.to_rfc3339()
54 }
55}
56
57#[must_use]
59pub fn now_rfc3339() -> String {
60 Utc::now().to_rfc3339()
61}
62
63#[must_use]
65pub fn is_expired(expires_at: DateTime<Utc>, now: DateTime<Utc>) -> bool {
66 expires_at <= now
67}
68
69#[must_use]
71pub fn is_valid(expires_at: DateTime<Utc>, now: DateTime<Utc>) -> bool {
72 expires_at > now
73}
74
75pub 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}