mirror of
https://github.com/lov3b/ecb-rates.git
synced 2025-02-22 18:00:11 +01:00
Merge branch 'better-cache'
This commit is contained in:
commit
1eb7716956
149
src/cache/cache_line.rs
vendored
149
src/cache/cache_line.rs
vendored
@ -1,10 +1,15 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
use chrono::serde::ts_seconds;
|
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 serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::models::ExchangeRateResult;
|
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 {
|
pub struct CacheLine {
|
||||||
#[serde(with = "ts_seconds")]
|
#[serde(with = "ts_seconds")]
|
||||||
date: DateTime<Utc>,
|
date: DateTime<Utc>,
|
||||||
@ -14,10 +19,63 @@ pub struct CacheLine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CacheLine {
|
impl CacheLine {
|
||||||
pub fn validate(&self) -> bool {
|
pub fn is_valid(&self) -> bool {
|
||||||
let today = Local::now().naive_local().date();
|
self.is_valid_at(Local::now().with_timezone(&CET))
|
||||||
let saved = self.date.naive_local().date();
|
}
|
||||||
saved == today
|
|
||||||
|
pub fn is_valid_at(&self, now_cet: DateTime<FixedOffset>) -> 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<ExchangeRateResult>) -> Self {
|
pub fn new(exchange_rate_results: Vec<ExchangeRateResult>) -> Self {
|
||||||
@ -28,3 +86,82 @@ impl CacheLine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl PartialEq<Vec<ExchangeRateResult>> for CacheLine {
|
||||||
|
fn eq(&self, other: &Vec<ExchangeRateResult>) -> bool {
|
||||||
|
&self.exchange_rate_results == other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::TimeZone;
|
||||||
|
|
||||||
|
fn cl(date_utc: DateTime<Utc>) -> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
152
src/holiday.rs
Normal file
152
src/holiday.rs
Normal file
@ -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;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
@ -1,9 +1,12 @@
|
|||||||
|
pub mod cache;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
|
mod holiday;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod os;
|
pub mod os;
|
||||||
pub mod parsing;
|
pub mod parsing;
|
||||||
pub mod table;
|
pub mod table;
|
||||||
pub mod cache;
|
|
||||||
|
pub use holiday::Hollidays;
|
||||||
|
|
||||||
const APP_NAME: &'static str = "ECB-rates";
|
const APP_NAME: &'static str = "ECB-rates";
|
||||||
|
|
||||||
|
24
src/main.rs
24
src/main.rs
@ -45,7 +45,7 @@ async fn main() -> ExitCode {
|
|||||||
|| false,
|
|| false,
|
||||||
|c| {
|
|c| {
|
||||||
c.get_cache_line(cli.resolution)
|
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 {
|
let mut parsed = if cache_ok {
|
||||||
@ -65,12 +65,24 @@ async fn main() -> ExitCode {
|
|||||||
return ExitCode::FAILURE;
|
return ExitCode::FAILURE;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if !cache_ok {
|
if !cache_ok {
|
||||||
if let Some(cache_safe) = cache.as_mut() {
|
let not_equal_cache = cache.as_ref().map_or_else(
|
||||||
let cache_line = CacheLine::new(parsed.clone());
|
|| true,
|
||||||
cache_safe.set_cache_line(cli.resolution, cache_line);
|
|cache_local| {
|
||||||
if let Err(e) = cache_safe.save() {
|
cache_local
|
||||||
eprintln!("Failed to save to cache with: {:?}", e);
|
.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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||||
pub struct ExchangeRateResult {
|
pub struct ExchangeRateResult {
|
||||||
pub time: String,
|
pub time: String,
|
||||||
pub rates: HashMap<String, f64>,
|
pub rates: HashMap<String, f64>,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user