1#![doc = include_str!("../README.md")]
4
5use chrono::{NaiveDate, ParseResult};
6use serde::{Deserialize, Serialize};
7
8#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
10pub struct CurrencyDoc {
11 #[serde(alias = "CcyTbl")]
13 table: CurrencyTable,
14
15 #[serde(alias = "@Pblshd")]
17 published: String,
18}
19
20impl CurrencyDoc {
21 #[must_use]
23 pub fn table(&self) -> &CurrencyTable {
24 &self.table
25 }
26
27 pub fn published(&self) -> ParseResult<NaiveDate> {
34 NaiveDate::parse_from_str(&self.published, "%Y-%m-%d")
35 }
36}
37
38#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
40pub struct CurrencyTable {
41 #[serde(alias = "CcyNtry")]
43 entries: Vec<CurrencyEntry>,
44}
45
46impl CurrencyTable {
47 #[must_use]
49 pub fn entries(&self) -> &[CurrencyEntry] {
50 &self.entries
51 }
52}
53
54#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
56pub struct CurrencyEntry {
57 #[serde(alias = "CtryNm")]
59 country: String,
60
61 #[serde(alias = "CcyNm")]
63 name: Option<CurrencyName>,
64
65 #[serde(alias = "Ccy")]
67 currency: Option<String>,
68
69 #[serde(alias = "CcyNbr")]
71 number: Option<u16>,
72
73 #[serde(alias = "CcyMnrUnts")]
75 minor_unit: Option<String>,
76}
77
78impl CurrencyEntry {
79 #[must_use]
81 pub fn country(&self) -> &str {
82 self.country.trim()
83 }
84
85 #[must_use]
87 pub fn name(&self) -> Option<&CurrencyName> {
88 self.name.as_ref()
89 }
90
91 #[must_use]
95 pub fn currency(&self) -> Option<&str> {
96 self.currency.as_deref().map(str::trim)
97 }
98
99 #[must_use]
101 pub fn number(&self) -> Option<u16> {
102 self.number
103 }
104
105 #[must_use]
107 pub fn minor_unit(&self) -> Option<u8> {
108 self.minor_unit.as_deref().and_then(|mu| match mu.trim() {
109 "N.A." | "" => None,
110 other => other.parse::<u8>().ok(),
111 })
112 }
113}
114
115#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
117pub struct CurrencyName {
118 #[serde(alias = "@IsFund")]
120 is_fund: Option<bool>,
121
122 #[serde(alias = "$value")]
124 name: String,
125}
126
127impl CurrencyName {
128 #[must_use]
130 pub fn is_fund(&self) -> bool {
131 self.is_fund.unwrap_or_default()
132 }
133
134 #[must_use]
136 pub fn name(&self) -> &str {
137 self.name.trim()
138 }
139}
140
141#[cfg(test)]
142mod test {
143 use super::*;
144 use quick_xml::de;
145 use std::{fs::File, io::BufReader, path::PathBuf};
146
147 const BASE_PATH: &str = env!("CARGO_MANIFEST_DIR");
148 const SRC_DIR: &str = "src";
149
150 #[yare::parameterized(
151 xml20260101 = { "2026-01-01.xml", 280 }
152 )]
153 fn counts(filename: &str, count: usize) {
154 let mut path = PathBuf::from(BASE_PATH);
155 path.push(SRC_DIR);
156 path.push(filename);
157
158 let file = File::open(path).expect("file");
159 let reader = BufReader::new(file);
160
161 let contents = de::from_reader::<_, CurrencyDoc>(reader).expect("XML reader");
162 let entries = contents.table().entries();
163
164 assert_eq!(count, entries.len());
165
166 for entry in entries {
167 if let Some(code) = entry.currency()
168 && code == "USN"
169 {
170 assert!(entry.name().unwrap().is_fund());
171 }
172 }
173 }
174}