ecb_rates/caching/
cache_line.rs

1use std::rc::Rc;
2
3use chrono::serde::ts_seconds;
4use chrono::{DateTime, Datelike, FixedOffset, Local, NaiveDate, TimeDelta, Utc, Weekday};
5use serde::{Deserialize, Serialize};
6
7use crate::Hollidays;
8use crate::models::ExchangeRateResult;
9
10const CET: FixedOffset = unsafe { FixedOffset::east_opt(3600).unwrap_unchecked() };
11
12#[derive(Serialize, Deserialize, Debug, PartialEq)]
13pub struct CacheLine {
14    #[serde(with = "ts_seconds")]
15    date: DateTime<Utc>,
16
17    #[serde(rename = "camelCase")]
18    pub exchange_rate_results: Vec<ExchangeRateResult>,
19}
20
21impl CacheLine {
22    pub fn is_valid(&self) -> bool {
23        self.is_valid_at(Local::now().with_timezone(&CET))
24    }
25
26    pub fn is_valid_at(&self, now_cet: DateTime<FixedOffset>) -> bool {
27        let saved_cet = self.date.with_timezone(&CET);
28
29        // Shortcut: if the saved time is somehow *in the future* vs. 'now', treat as invalid.
30        if saved_cet > now_cet {
31            return false;
32        }
33
34        // This can be optimized, but it won't make a difference for the application
35        let hollidays_opt = if now_cet.year() == saved_cet.year() {
36            Some(Rc::new(Hollidays::new(now_cet.year())))
37        } else {
38            None
39        };
40
41        let mut day_iter = saved_cet.date_naive();
42        let end_day = now_cet.date_naive();
43
44        // Helper: checks if a day is open (ECB publishes).
45        // weekend (Sat/Sun) or holiday is "closed".
46        let is_open_day = |date: NaiveDate| {
47            let wd = date.weekday();
48            let is_weekend = wd == Weekday::Sat || wd == Weekday::Sun;
49
50            let hollidays = hollidays_opt
51                .clone()
52                .unwrap_or_else(|| Rc::new(Hollidays::new(date.year())));
53
54            let is_holiday = hollidays.is_holliday(&date);
55
56            !(is_weekend || is_holiday)
57        };
58
59        while day_iter <= end_day {
60            if is_open_day(day_iter) {
61                // Potential publish time is day_iter at 16:00 CET
62                let publish_time_cet = unsafe {
63                    day_iter
64                        .and_hms_opt(16, 0, 0)
65                        .unwrap_unchecked()
66                        .and_local_timezone(CET)
67                        .unwrap()
68                };
69
70                if publish_time_cet > saved_cet && publish_time_cet <= now_cet {
71                    return false;
72                }
73            }
74            day_iter += TimeDelta::days(1);
75        }
76
77        // If we never found an open day’s 16:00 that invalidates the cache, we're good.
78        true
79    }
80
81    pub fn new(exchange_rate_results: Vec<ExchangeRateResult>) -> Self {
82        let date = Local::now().to_utc();
83        Self {
84            exchange_rate_results,
85            date,
86        }
87    }
88}
89
90impl PartialEq<Vec<ExchangeRateResult>> for CacheLine {
91    fn eq(&self, other: &Vec<ExchangeRateResult>) -> bool {
92        &self.exchange_rate_results == other
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use chrono::TimeZone;
100
101    fn cl(date_utc: DateTime<Utc>) -> CacheLine {
102        CacheLine {
103            date: date_utc,
104            exchange_rate_results: vec![],
105        }
106    }
107
108    #[test]
109    fn test_cache_in_future() {
110        let now_cet = Utc
111            .with_ymd_and_hms(2025, 1, 1, 9, 0, 0)
112            .unwrap()
113            .with_timezone(&CET);
114        let future_utc = Utc.with_ymd_and_hms(2025, 1, 2, 9, 0, 0).unwrap();
115        assert!(!cl(future_utc).is_valid_at(now_cet));
116    }
117
118    #[test]
119    fn test_same_open_day_before_16() {
120        let now_cet = Utc
121            .with_ymd_and_hms(2025, 1, 8, 12, 0, 0)
122            .unwrap()
123            .with_timezone(&CET);
124        let cache_utc = Utc.with_ymd_and_hms(2025, 1, 8, 10, 0, 0).unwrap();
125        assert!(cl(cache_utc).is_valid_at(now_cet));
126    }
127
128    #[test]
129    fn test_same_day_after_16() {
130        let now_cet = Utc
131            .with_ymd_and_hms(2025, 1, 8, 17, 0, 0)
132            .unwrap()
133            .with_timezone(&CET);
134        let cache_utc = Utc.with_ymd_and_hms(2025, 1, 8, 14, 0, 0).unwrap();
135        assert!(!cl(cache_utc).is_valid_at(now_cet));
136    }
137
138    #[test]
139    fn test_saved_after_16_same_day() {
140        let now_cet = Utc
141            .with_ymd_and_hms(2025, 1, 8, 18, 0, 0)
142            .unwrap()
143            .with_timezone(&CET);
144        let cache_utc = Utc.with_ymd_and_hms(2025, 1, 8, 17, 0, 0).unwrap();
145        assert!(cl(cache_utc).is_valid_at(now_cet));
146    }
147
148    #[test]
149    fn test_multi_day_old_cache_should_invalidate_if_open_day_passed() {
150        let now_cet = Utc
151            .with_ymd_and_hms(2025, 1, 10, 18, 0, 0)
152            .unwrap()
153            .with_timezone(&CET);
154        let cache_utc = Utc.with_ymd_and_hms(2025, 1, 5, 10, 0, 0).unwrap();
155        assert!(!cl(cache_utc).is_valid_at(now_cet));
156    }
157
158    #[test]
159    fn test_multi_day_holiday_scenario() {
160        let now_cet = Utc
161            .with_ymd_and_hms(2025, 12, 26, 19, 0, 0)
162            .unwrap()
163            .with_timezone(&CET);
164        let cache_utc = Utc.with_ymd_and_hms(2025, 12, 24, 10, 0, 0).unwrap();
165        assert!(cl(cache_utc).is_valid_at(now_cet));
166    }
167}