mirror of
				https://github.com/lov3b/ecb-rates.git
				synced 2025-10-31 05:10:25 +01:00 
			
		
		
		
	Merge branch 'better-cache'
This commit is contained in:
		
							
								
								
									
										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>, | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user