diff --git a/src/cache/cache_line.rs b/src/cache/cache_line.rs index 33422cc..69f20e9 100644 --- a/src/cache/cache_line.rs +++ b/src/cache/cache_line.rs @@ -1,10 +1,15 @@ +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; -#[derive(Serialize, Deserialize, Debug)] +const CET: FixedOffset = unsafe { FixedOffset::east_opt(3600).unwrap_unchecked() }; + +#[derive(Serialize, Deserialize, Debug, PartialEq)] pub struct CacheLine { #[serde(with = "ts_seconds")] date: DateTime, @@ -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,82 @@ impl CacheLine { } } } + +impl PartialEq> for CacheLine { + fn eq(&self, other: &Vec) -> bool { + &self.exchange_rate_results == other + } +} + +#[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/holiday.rs b/src/holiday.rs new file mode 100644 index 0000000..3c9a63c --- /dev/null +++ b/src/holiday.rs @@ -0,0 +1,152 @@ +use chrono::{Days, NaiveDate}; + +/// Calculates the hollidays recognized by the EU +/// ECB recognizes the following hollidays https://www.ecb.europa.eu/ecb/contacts/working-hours/html/index.en.html +#[derive(Debug, Clone)] +pub struct Hollidays { + hollidays: [NaiveDate; 15], +} + +impl Hollidays { + pub fn is_holliday(&self, date: &NaiveDate) -> bool { + self.hollidays.contains(date) + } + + pub fn new(year: i32) -> Self { + assert!((1583..=4099).contains(&year)); + + let easter_sunday = Self::calc_easter_sunday(year); + let easter_monday = easter_sunday + Days::new(1); + let good_friday = easter_sunday - Days::new(2); + let ascension_day = easter_sunday + Days::new(39); + let whit_monday = easter_sunday + Days::new(50); + let corpus_christi = easter_sunday + Days::new(60); + let year_years_day = unsafe { NaiveDate::from_ymd_opt(year, 1, 1).unwrap_unchecked() }; + let labour_day = unsafe { NaiveDate::from_ymd_opt(year, 5, 1).unwrap_unchecked() }; + let robert_schuman_declaration = + unsafe { NaiveDate::from_ymd_opt(year, 5, 9).unwrap_unchecked() }; + let german_unity_day = unsafe { NaiveDate::from_ymd_opt(year, 10, 3).unwrap_unchecked() }; + let all_saints_day = unsafe { NaiveDate::from_ymd_opt(year, 11, 1).unwrap_unchecked() }; + let christmas_eve = unsafe { NaiveDate::from_ymd_opt(year, 12, 24).unwrap_unchecked() }; + let christmas_day = unsafe { NaiveDate::from_ymd_opt(year, 12, 25).unwrap_unchecked() }; + let christmas_holiday = unsafe { NaiveDate::from_ymd_opt(year, 12, 26).unwrap_unchecked() }; + let new_years_eve = unsafe { NaiveDate::from_ymd_opt(year, 12, 31).unwrap_unchecked() }; + + let hollidays = [ + easter_sunday, + easter_monday, + good_friday, + ascension_day, + whit_monday, + corpus_christi, + year_years_day, + labour_day, + robert_schuman_declaration, + german_unity_day, + all_saints_day, + christmas_eve, + christmas_day, + christmas_holiday, + new_years_eve, + ]; + Self { hollidays } + } + + /// Returns Easter Sunday for a given year (Gregorian calendar). + /// This uses a variation of the Butcher's algorithm. + /// Valid for years 1583..=4099 in the Gregorian calendar. + fn calc_easter_sunday(year: i32) -> NaiveDate { + // For reference: https://en.wikipedia.org/wiki/Computus#Butcher's_algorithm + let a = year % 19; + let b = year / 100; + let c = year % 100; + let d = b / 4; + let e = b % 4; + let f = (b + 8) / 25; + let g = (b - f + 1) / 3; + let h = (19 * a + b - d - g + 15) % 30; + let i = c / 4; + let k = c % 4; + let l = (32 + 2 * e + 2 * i - h - k) % 7; + let m = (a + 11 * h + 22 * l) / 451; + let month = (h + l - 7 * m + 114) / 31; + let day = (h + l - 7 * m + 114) % 31 + 1; + + NaiveDate::from_ymd_opt(year, month as u32, day as u32) + .expect("Invalid date calculation for Easter Sunday") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + + #[test] + fn test_holidays_2025() { + let year = 2025; + let holliday = Hollidays::new(year); + + let easter_sunday_2025 = NaiveDate::from_ymd_opt(2025, 4, 20).unwrap(); + assert!( + holliday.is_holliday(&easter_sunday_2025), + "Easter Sunday 2025" + ); + + let new_years_2025 = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(); + assert!(holliday.is_holliday(&new_years_2025), "New Year's Day 2025"); + + let labour_day_2025 = NaiveDate::from_ymd_opt(2025, 5, 1).unwrap(); + assert!(holliday.is_holliday(&labour_day_2025), "Labour Day 2025"); + + let random_workday_2025 = NaiveDate::from_ymd_opt(2025, 2, 10).unwrap(); + assert!( + !holliday.is_holliday(&random_workday_2025), + "Random weekday 2025" + ); + } + + #[test] + fn test_holidays_2026() { + let year = 2026; + let holliday = Hollidays::new(year); + + let easter_sunday_2026 = NaiveDate::from_ymd_opt(2026, 4, 5).unwrap(); + assert!( + holliday.is_holliday(&easter_sunday_2026), + "Easter Sunday 2026" + ); + + let german_unity_day_2026 = NaiveDate::from_ymd_opt(2026, 10, 3).unwrap(); + assert!( + holliday.is_holliday(&german_unity_day_2026), + "Day of German Unity 2026" + ); + + let random_workday_2026 = NaiveDate::from_ymd_opt(2026, 2, 10).unwrap(); + assert!( + !holliday.is_holliday(&random_workday_2026), + "Random weekday 2026" + ); + } + + #[test] + #[should_panic] + fn test_year_too_low() { + disable_panic_stack_trace(); + let _ = Hollidays::new(1000); + } + + #[test] + #[should_panic] + fn test_year_too_high() { + disable_panic_stack_trace(); + let _ = Hollidays::new(9999); + } + + fn disable_panic_stack_trace() { + std::panic::set_hook(Box::new(|x| { + let _ = x; + })); + } +} diff --git a/src/lib.rs b/src/lib.rs index 81f1f91..8c53513 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,12 @@ +pub mod cache; pub mod cli; +mod holiday; pub mod models; pub mod os; pub mod parsing; pub mod table; -pub mod cache; + +pub use holiday::Hollidays; const APP_NAME: &'static str = "ECB-rates"; diff --git a/src/main.rs b/src/main.rs index 88febef..4e128bf 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 { @@ -65,12 +65,24 @@ async fn main() -> ExitCode { return ExitCode::FAILURE; } }; + if !cache_ok { - if let Some(cache_safe) = cache.as_mut() { - let cache_line = CacheLine::new(parsed.clone()); - cache_safe.set_cache_line(cli.resolution, cache_line); - if let Err(e) = cache_safe.save() { - eprintln!("Failed to save to cache with: {:?}", e); + let not_equal_cache = cache.as_ref().map_or_else( + || true, + |cache_local| { + cache_local + .get_cache_line(cli.resolution) + .map_or_else(|| true, |cache_line| cache_line == &parsed) + }, + ); + + if not_equal_cache { + if let Some(cache_safe) = cache.as_mut() { + let cache_line = CacheLine::new(parsed.clone()); + cache_safe.set_cache_line(cli.resolution, cache_line); + if let Err(e) = cache_safe.save() { + eprintln!("Failed to save to cache with: {:?}", e); + } } } } diff --git a/src/models.rs b/src/models.rs index 3caacf1..2b0dedc 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] pub struct ExchangeRateResult { pub time: String, pub rates: HashMap,