diff --git a/src/cache/cache_line.rs b/src/cache/cache_line.rs index 33422cc..c8e50de 100644 --- a/src/cache/cache_line.rs +++ b/src/cache/cache_line.rs @@ -1,8 +1,13 @@ +use std::rc::Rc; + use chrono::serde::ts_seconds; -use chrono::{DateTime, Local, Utc}; +use chrono::{DateTime, Datelike, FixedOffset, Local, NaiveDate, TimeDelta, Utc, Weekday}; use serde::{Deserialize, Serialize}; use crate::models::ExchangeRateResult; +use crate::Hollidays; + +const CET: FixedOffset = unsafe { FixedOffset::east_opt(3600).unwrap_unchecked() }; #[derive(Serialize, Deserialize, Debug)] pub struct CacheLine { @@ -14,10 +19,63 @@ pub struct CacheLine { } impl CacheLine { - pub fn validate(&self) -> bool { - let today = Local::now().naive_local().date(); - let saved = self.date.naive_local().date(); - saved == today + pub fn is_valid(&self) -> bool { + self.is_valid_at(Local::now().with_timezone(&CET)) + } + + pub fn is_valid_at(&self, now_cet: DateTime) -> bool { + let saved_cet = self.date.with_timezone(&CET); + + // Shortcut: if the saved time is somehow *in the future* vs. 'now', treat as invalid. + if saved_cet > now_cet { + return false; + } + + // This can be optimized, but it won't make a difference for the application + let hollidays_opt = if now_cet.year() == saved_cet.year() { + Some(Rc::new(Hollidays::new(now_cet.year()))) + } else { + None + }; + + let mut day_iter = saved_cet.date_naive(); + let end_day = now_cet.date_naive(); + + // Helper: checks if a day is open (ECB publishes). + // weekend (Sat/Sun) or holiday is "closed". + let is_open_day = |date: NaiveDate| { + let wd = date.weekday(); + let is_weekend = wd == Weekday::Sat || wd == Weekday::Sun; + + let hollidays = hollidays_opt + .clone() + .unwrap_or_else(|| Rc::new(Hollidays::new(date.year()))); + + let is_holiday = hollidays.is_holliday(&date); + + !(is_weekend || is_holiday) + }; + + while day_iter <= end_day { + if is_open_day(day_iter) { + // Potential publish time is day_iter at 16:00 CET + let publish_time_cet = unsafe { + day_iter + .and_hms_opt(16, 0, 0) + .unwrap_unchecked() + .and_local_timezone(CET) + .unwrap() + }; + + if publish_time_cet > saved_cet && publish_time_cet <= now_cet { + return false; + } + } + day_iter += TimeDelta::days(1); + } + + // If we never found an open day’s 16:00 that invalidates the cache, we're good. + true } pub fn new(exchange_rate_results: Vec) -> Self { @@ -28,3 +86,75 @@ impl CacheLine { } } } +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + fn cl(date_utc: DateTime) -> CacheLine { + CacheLine { + date: date_utc, + exchange_rate_results: vec![], + } + } + + #[test] + fn test_cache_in_future() { + let now_cet = Utc + .with_ymd_and_hms(2025, 1, 1, 9, 0, 0) + .unwrap() + .with_timezone(&CET); + let future_utc = Utc.with_ymd_and_hms(2025, 1, 2, 9, 0, 0).unwrap(); + assert!(!cl(future_utc).is_valid_at(now_cet)); + } + + #[test] + fn test_same_open_day_before_16() { + let now_cet = Utc + .with_ymd_and_hms(2025, 1, 8, 12, 0, 0) + .unwrap() + .with_timezone(&CET); + let cache_utc = Utc.with_ymd_and_hms(2025, 1, 8, 10, 0, 0).unwrap(); + assert!(cl(cache_utc).is_valid_at(now_cet)); + } + + #[test] + fn test_same_day_after_16() { + let now_cet = Utc + .with_ymd_and_hms(2025, 1, 8, 17, 0, 0) + .unwrap() + .with_timezone(&CET); + let cache_utc = Utc.with_ymd_and_hms(2025, 1, 8, 14, 0, 0).unwrap(); + assert!(!cl(cache_utc).is_valid_at(now_cet)); + } + + #[test] + fn test_saved_after_16_same_day() { + let now_cet = Utc + .with_ymd_and_hms(2025, 1, 8, 18, 0, 0) + .unwrap() + .with_timezone(&CET); + let cache_utc = Utc.with_ymd_and_hms(2025, 1, 8, 17, 0, 0).unwrap(); + assert!(cl(cache_utc).is_valid_at(now_cet)); + } + + #[test] + fn test_multi_day_old_cache_should_invalidate_if_open_day_passed() { + let now_cet = Utc + .with_ymd_and_hms(2025, 1, 10, 18, 0, 0) + .unwrap() + .with_timezone(&CET); + let cache_utc = Utc.with_ymd_and_hms(2025, 1, 5, 10, 0, 0).unwrap(); + assert!(!cl(cache_utc).is_valid_at(now_cet)); + } + + #[test] + fn test_multi_day_holiday_scenario() { + let now_cet = Utc + .with_ymd_and_hms(2025, 12, 26, 19, 0, 0) + .unwrap() + .with_timezone(&CET); + let cache_utc = Utc.with_ymd_and_hms(2025, 12, 24, 10, 0, 0).unwrap(); + assert!(cl(cache_utc).is_valid_at(now_cet)); + } +} diff --git a/src/main.rs b/src/main.rs index 88febef..9e016c1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,7 +45,7 @@ async fn main() -> ExitCode { || false, |c| { c.get_cache_line(cli.resolution) - .map_or_else(|| false, |cl| cl.validate()) + .map_or_else(|| false, |cl| cl.is_valid()) }, ); let mut parsed = if cache_ok {