1use alloc::vec::Vec;
2
3use chrono::{Datelike, NaiveDate};
4use rust_decimal::Decimal;
5
6use crate::error::LookupError;
7use crate::rate::Rate;
8use crate::store::{self, Entry, Series, WeekIdx, Weeks};
9use crate::types::{Currency, Period, RateType, YearEnd, YearMonth};
10
11const CE_EPOCH_OFFSET: i32 = 719_163;
13
14fn date_to_day(date: NaiveDate) -> i32 {
15 date.num_days_from_ce() - CE_EPOCH_OFFSET
16}
17
18fn day_to_date(day: i32) -> Option<NaiveDate> {
19 NaiveDate::from_num_days_from_ce_opt(day.checked_add(CE_EPOCH_OFFSET)?)
20}
21
22fn week_period(week: &WeekIdx) -> Option<Period> {
24 Some(Period::Week {
25 start: day_to_date(week.start_day)?,
26 end: day_to_date(week.end_day)?,
27 })
28}
29
30fn gbp_identity(code: &str, period: Period) -> Option<Rate> {
32 (Currency::normalize(code) == Some(Currency::GBP.code()))
33 .then(|| Rate::new(Decimal::ONE, Currency::GBP, period))
34}
35
36#[derive(Clone)]
42pub struct Rates {
43 monthly: Series,
44 spot: Series,
45 average: Series,
46 weeks: Weeks,
47}
48
49impl core::fmt::Debug for Rates {
50 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
51 f.debug_struct("Rates")
52 .field("months", &self.monthly.keys().len())
53 .field("spot_periods", &self.spot.keys().len())
54 .field("average_periods", &self.average.keys().len())
55 .field("weeks", &self.weeks.index().len())
56 .finish()
57 }
58}
59
60#[cfg(feature = "bundled")]
61impl Default for Rates {
62 fn default() -> Rates {
63 Rates::new()
64 }
65}
66
67impl Rates {
68 #[cfg(feature = "bundled")]
81 pub fn new() -> Rates {
82 Rates {
83 monthly: Series::new(crate::bundled::MONTHLY),
84 spot: Series::new(crate::bundled::SPOT),
85 average: Series::new(crate::bundled::AVERAGE),
86 weeks: Weeks::new(crate::bundled::WEEKLY),
87 }
88 }
89
90 #[cfg(test)]
92 pub(crate) fn empty() -> Rates {
93 Rates {
94 monthly: Series::new(store::EMPTY_SERIES),
95 spot: Series::new(store::EMPTY_SERIES),
96 average: Series::new(store::EMPTY_SERIES),
97 weeks: Weeks::new(store::EMPTY_WEEKS),
98 }
99 }
100
101 #[cfg(feature = "http")]
102 pub(crate) fn set_period(&mut self, table: RateType, key: i32, entries: Vec<Entry>) {
103 match table {
104 RateType::Monthly => self.monthly.set(key, entries),
105 RateType::Spot => self.spot.set(key, entries),
106 RateType::Average => self.average.set(key, entries),
107 _ => {}
108 }
109 }
110
111 pub fn monthly_rate(
129 &self,
130 code: &str,
131 year_month: impl Into<YearMonth>,
132 ) -> Result<Rate, LookupError> {
133 self.monthly_rate_or_earlier(code, year_month, 0)
134 }
135
136 pub fn monthly_rate_or_earlier(
155 &self,
156 code: &str,
157 year_month: impl Into<YearMonth>,
158 max_months_back: u32,
159 ) -> Result<Rate, LookupError> {
160 let requested = year_month.into();
161 if let Some(rate) = gbp_identity(code, Period::YearMonth(requested)) {
163 return Ok(rate);
164 }
165 let mut candidate = requested;
166 for _ in 0..=max_months_back {
167 if self.monthly.table(candidate.key()).is_some() {
168 return self.monthly(candidate)?.rate(code);
169 }
170 candidate = candidate.prev();
171 }
172 Err(self.period_missing(RateType::Monthly, Period::YearMonth(requested)))
173 }
174
175 pub fn monthly(&self, year_month: impl Into<YearMonth>) -> Result<Table<'_>, LookupError> {
177 let year_month = year_month.into();
178 let period = Period::YearMonth(year_month);
179 match self.monthly.table(year_month.key()) {
180 Some(entries) => Ok(Table {
181 rate_type: RateType::Monthly,
182 period,
183 entries,
184 known: Known::Series(&self.monthly),
185 }),
186 None => Err(self.period_missing(RateType::Monthly, period)),
187 }
188 }
189
190 pub fn spot(&self, period: YearEnd) -> Result<Table<'_>, LookupError> {
202 self.year_end_table(&self.spot, RateType::Spot, period)
203 }
204
205 pub fn average(&self, period: YearEnd) -> Result<Table<'_>, LookupError> {
218 self.year_end_table(&self.average, RateType::Average, period)
219 }
220
221 pub fn weekly(&self, date: NaiveDate) -> Result<Table<'_>, LookupError> {
237 let day = date_to_day(date);
238 if let Some((week, entries)) = self.weeks.containing(day) {
239 if let Some(period) = week_period(&week) {
240 return Ok(Table {
241 rate_type: RateType::Weekly,
242 period,
243 entries,
244 known: Known::Weeks(&self.weeks),
245 });
246 }
247 }
248 Err(LookupError::PeriodNotAvailable {
249 table: RateType::Weekly,
250 period: Period::Week {
251 start: date,
252 end: date,
253 },
254 available: self.available(RateType::Weekly),
255 })
256 }
257
258 pub fn months(&self) -> impl DoubleEndedIterator<Item = YearMonth> + use<'_> {
260 self.monthly.keys().into_iter().map(YearMonth::from_key)
261 }
262
263 pub fn spot_periods(&self) -> impl DoubleEndedIterator<Item = YearEnd> + use<'_> {
265 self.spot.keys().into_iter().map(YearEnd::from_key)
266 }
267
268 pub fn average_periods(&self) -> impl DoubleEndedIterator<Item = YearEnd> + use<'_> {
270 self.average.keys().into_iter().map(YearEnd::from_key)
271 }
272
273 pub fn weeks(&self) -> impl DoubleEndedIterator<Item = Period> + use<'_> {
275 self.weeks.index().iter().filter_map(week_period)
276 }
277
278 pub fn currencies(&self, table: RateType) -> impl Iterator<Item = Currency> + use<'_> {
280 let codes = match table {
281 RateType::Monthly => self.monthly.codes(),
282 RateType::Spot => self.spot.codes(),
283 RateType::Average => self.average.codes(),
284 RateType::Weekly => self.weekly_codes(),
285 };
286 codes.into_iter().map(Currency::from_code)
287 }
288
289 fn weekly_codes(&self) -> Vec<[u8; 3]> {
290 let mut codes: Vec<[u8; 3]> = self.weeks.arena().iter().map(|e| e.code).collect();
291 codes.sort_unstable();
292 codes.dedup();
293 codes
294 }
295
296 fn year_end_table<'a>(
297 &'a self,
298 series: &'a Series,
299 rate_type: RateType,
300 period: YearEnd,
301 ) -> Result<Table<'a>, LookupError> {
302 match series.table(period.key()) {
303 Some(entries) => Ok(Table {
304 rate_type,
305 period: Period::YearEnd(period),
306 entries,
307 known: Known::Series(series),
308 }),
309 None => Err(self.period_missing(rate_type, Period::YearEnd(period))),
310 }
311 }
312
313 fn available(&self, table: RateType) -> Option<(Period, Period)> {
315 match table {
316 RateType::Monthly => self.monthly.first_last().map(|(f, l)| {
317 (
318 Period::YearMonth(YearMonth::from_key(f)),
319 Period::YearMonth(YearMonth::from_key(l)),
320 )
321 }),
322 RateType::Spot | RateType::Average => {
323 let series = if table == RateType::Spot {
324 &self.spot
325 } else {
326 &self.average
327 };
328 series.first_last().map(|(f, l)| {
329 (
330 Period::YearEnd(YearEnd::from_key(f)),
331 Period::YearEnd(YearEnd::from_key(l)),
332 )
333 })
334 }
335 RateType::Weekly => {
336 let idx = self.weeks.index();
337 Some((week_period(idx.first()?)?, week_period(idx.last()?)?))
338 }
339 }
340 }
341
342 fn period_missing(&self, table: RateType, period: Period) -> LookupError {
343 LookupError::PeriodNotAvailable {
344 table,
345 period,
346 available: self.available(table),
347 }
348 }
349}
350
351#[derive(Copy, Clone)]
352enum Known<'a> {
353 Series(&'a Series),
354 Weeks(&'a Weeks),
355}
356
357impl Known<'_> {
358 fn knows(&self, code: [u8; 3]) -> bool {
359 match self {
360 Known::Series(s) => s.knows(code),
361 Known::Weeks(w) => w.knows(code),
362 }
363 }
364}
365
366#[derive(Copy, Clone)]
368pub struct Table<'a> {
369 rate_type: RateType,
370 period: Period,
371 entries: &'a [Entry],
372 known: Known<'a>,
373}
374
375impl core::fmt::Debug for Table<'_> {
376 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
377 f.debug_struct("Table")
378 .field("rate_type", &self.rate_type)
379 .field("period", &self.period)
380 .field("len", &self.entries.len())
381 .finish()
382 }
383}
384
385impl<'a> Table<'a> {
386 pub fn period(&self) -> Period {
388 self.period
389 }
390
391 pub fn rate_type(&self) -> RateType {
393 self.rate_type
394 }
395
396 pub fn rate(&self, code: &str) -> Result<Rate, LookupError> {
418 if let Some(rate) = gbp_identity(code, self.period) {
419 return Ok(rate);
420 }
421 let Some(normalized) = Currency::normalize(code) else {
422 return Err(LookupError::UnknownCurrency {
423 code: code.trim().into(),
424 table: self.rate_type,
425 });
426 };
427 match store::lookup(self.entries, normalized) {
428 Some(entry) => Ok(Rate::new(
429 entry.decimal(),
430 Currency::from_code(normalized),
431 self.period,
432 )),
433 None if self.known.knows(normalized) => Err(LookupError::NotInPeriod {
434 currency: Currency::from_code(normalized),
435 table: self.rate_type,
436 period: self.period,
437 }),
438 None => Err(LookupError::UnknownCurrency {
439 code: code.trim().into(),
440 table: self.rate_type,
441 }),
442 }
443 }
444
445 pub fn get(&self, code: &str) -> Option<Rate> {
447 self.rate(code).ok()
448 }
449
450 pub fn iter(&self) -> impl ExactSizeIterator<Item = (Currency, Rate)> + use<'a> {
452 let period = self.period;
453 self.entries.iter().map(move |e| {
454 let currency = Currency::from_code(e.code);
455 (currency, Rate::new(e.decimal(), currency, period))
456 })
457 }
458
459 pub fn len(&self) -> usize {
461 self.entries.len()
462 }
463
464 pub fn is_empty(&self) -> bool {
466 self.entries.is_empty()
467 }
468}
469
470#[cfg(test)]
471mod empty_tests {
472 use super::*;
473
474 #[test]
475 fn empty_rates_report_no_data_loaded() {
476 let rates = Rates::empty();
477 let year_month = YearMonth::new(2025, 8);
478 let Some(year_month) = year_month else { return };
479 let result = rates.monthly_rate("USD", year_month);
480 assert!(
481 matches!(
482 result,
483 Err(LookupError::PeriodNotAvailable {
484 available: None,
485 ..
486 })
487 ),
488 "unexpected: {result:?}"
489 );
490 assert!(rates.months().next().is_none());
491 assert_eq!(rates.currencies(RateType::Spot).count(), 0);
492 assert!(rates.monthly_rate("GBP", year_month).is_ok());
494 }
495}
496
497#[cfg(all(test, feature = "bundled"))]
498#[allow(clippy::unwrap_used)]
499mod bundled_tests {
500 use super::*;
501
502 #[test]
503 fn statics_hold_codegen_invariants() {
504 for series in [
505 &crate::bundled::MONTHLY,
506 &crate::bundled::SPOT,
507 &crate::bundled::AVERAGE,
508 ] {
509 let mut start = 0usize;
510 for pair in series.index.windows(2) {
511 assert!(
512 pair[0].key < pair[1].key,
513 "index keys not strictly ascending"
514 );
515 }
516 for idx in series.index {
517 let table = &series.arena[start..idx.end as usize];
518 start = idx.end as usize;
519 assert!(!table.is_empty());
520 for entry in table {
521 assert!(entry.mantissa > 0);
522 assert!(entry.scale <= 9);
523 assert!(entry.code.iter().all(u8::is_ascii_uppercase));
524 }
525 for pair in table.windows(2) {
526 assert!(pair[0].code < pair[1].code, "codes not sorted/deduped");
527 }
528 }
529 assert_eq!(start, series.arena.len(), "index does not cover the arena");
530 }
531 for pair in crate::bundled::WEEKLY.index.windows(2) {
532 assert!(pair[0].end_day < pair[1].start_day, "overlapping weeks");
533 }
534 }
535
536 #[cfg(feature = "std")]
538 #[test]
539 fn codegen_matches_fresh_parse() {
540 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/data/monthly/2025-08.xml");
541 let bytes = std::fs::read(path).unwrap();
542 let ((year, month), raw) = crate::parse::parse_monthly_xml(&bytes).unwrap();
543 let parsed = crate::parse::dedup_majority(raw).unwrap();
544
545 let rates = Rates::new();
546 let table = rates.monthly(YearMonth::new(year, month).unwrap()).unwrap();
547 assert_eq!(table.len(), parsed.len());
548 for rate in &parsed {
549 let entry = crate::store::lookup(table.entries, rate.code).unwrap();
550 assert_eq!((entry.mantissa, entry.scale), (rate.mantissa, rate.scale));
551 }
552
553 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/data/average/2024-12.csv");
555 let bytes = std::fs::read(path).unwrap();
556 let parsed =
557 crate::parse::dedup_majority(crate::parse::parse_rates_csv(&bytes).unwrap()).unwrap();
558 let table = rates.average(YearEnd::december(2024)).unwrap();
559 assert_eq!(table.len(), parsed.len());
560 for rate in &parsed {
561 let entry = crate::store::lookup(table.entries, rate.code).unwrap();
562 assert_eq!((entry.mantissa, entry.scale), (rate.mantissa, rate.scale));
563 }
564 }
565}