Skip to main content

iso4217_parser/
lib.rs

1//! ISO 4217 XML Parser
2
3#![doc = include_str!("../README.md")]
4
5use chrono::{NaiveDate, ParseResult};
6use serde::{Deserialize, Serialize};
7
8/// The currency document
9#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
10pub struct CurrencyDoc {
11    /// The table of currency entries.
12    #[serde(alias = "CcyTbl")]
13    table: CurrencyTable,
14
15    /// The date this document was published.
16    #[serde(alias = "@Pblshd")]
17    published: String,
18}
19
20impl CurrencyDoc {
21    /// The table contained within this document.
22    #[must_use]
23    pub fn table(&self) -> &CurrencyTable {
24        &self.table
25    }
26
27    /// The date this document was published.
28    ///
29    /// # Errors
30    ///
31    /// - [`ParseError`](chrono::format::ParseError) when the date string is not in the format
32    ///   `YYYY-MM-DD`.
33    pub fn published(&self) -> ParseResult<NaiveDate> {
34        NaiveDate::parse_from_str(&self.published, "%Y-%m-%d")
35    }
36}
37
38/// The currency table
39#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
40pub struct CurrencyTable {
41    /// The individual currency entries.
42    #[serde(alias = "CcyNtry")]
43    entries: Vec<CurrencyEntry>,
44}
45
46impl CurrencyTable {
47    /// Retrieve a slice of the entries in this table.
48    #[must_use]
49    pub fn entries(&self) -> &[CurrencyEntry] {
50        &self.entries
51    }
52}
53
54/// An Currency XML Entry
55#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
56pub struct CurrencyEntry {
57    /// The name of the country.
58    #[serde(alias = "CtryNm")]
59    country: String,
60
61    /// The name of the currency.
62    #[serde(alias = "CcyNm")]
63    name: Option<CurrencyName>,
64
65    /// The 3-character currency code.
66    #[serde(alias = "Ccy")]
67    currency: Option<String>,
68
69    /// The numeric currency code.
70    #[serde(alias = "CcyNbr")]
71    number: Option<u16>,
72
73    /// The minor unit decimal places.
74    #[serde(alias = "CcyMnrUnts")]
75    minor_unit: Option<String>,
76}
77
78impl CurrencyEntry {
79    /// The country name.
80    #[must_use]
81    pub fn country(&self) -> &str {
82        self.country.trim()
83    }
84
85    /// The currency name.
86    #[must_use]
87    pub fn name(&self) -> Option<&CurrencyName> {
88        self.name.as_ref()
89    }
90
91    /// The currency code.
92    ///
93    /// This may be optional if the given country doesn't have universal currency.
94    #[must_use]
95    pub fn currency(&self) -> Option<&str> {
96        self.currency.as_deref().map(str::trim)
97    }
98
99    /// Retrieve the currency code as a number.
100    #[must_use]
101    pub fn number(&self) -> Option<u16> {
102        self.number
103    }
104
105    /// Retrieve the minor unit decimal places, if applicable.
106    #[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/// A currency name
116#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
117pub struct CurrencyName {
118    /// Whether the currency is a fund or not.
119    #[serde(alias = "@IsFund")]
120    is_fund: Option<bool>,
121
122    /// The currency name.
123    #[serde(alias = "$value")]
124    name: String,
125}
126
127impl CurrencyName {
128    /// Whether the currency is a fund or not.
129    #[must_use]
130    pub fn is_fund(&self) -> bool {
131        self.is_fund.unwrap_or_default()
132    }
133
134    /// The (trimmed) currency name.
135    #[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}