ocpi_tariffs/
currency.rs

1//! An ISO 4217 currency code.
2use std::{borrow::Cow, fmt};
3
4use num_derive::{FromPrimitive, ToPrimitive};
5use num_traits::{FromPrimitive as _, ToPrimitive as _};
6
7use crate::{
8    into_caveat, json,
9    warning::{self, GatherWarnings as _},
10    IntoCaveat, Verdict,
11};
12
13/// The warnings that can happen when parsing or linting a currency code.
14#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
15pub enum WarningKind {
16    /// The currency field does not require char escape codes.
17    ContainsEscapeCodes,
18
19    /// The field at the path could not be decoded.
20    Decode(json::decode::WarningKind),
21
22    /// The `country` is not a valid ISO 3166-1 country code because it's not uppercase.
23    PreferUpperCase,
24
25    /// The `currency` is not a valid ISO 4217 currency code.
26    InvalidCode,
27
28    /// The JSON value given is not a string.
29    InvalidType,
30
31    /// The `currency` is not a valid ISO 4217 currency code: it should be 3 chars.
32    InvalidLength,
33
34    /// The `currency` is not a valid ISO 4217 currency code because it's a test code.
35    InvalidCodeXTS,
36
37    /// The `currency` is not a valid ISO 4217 currency code because it's a code for `no-currency`.
38    InvalidCodeXXX,
39}
40
41impl fmt::Display for WarningKind {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        match self {
44            Self::ContainsEscapeCodes => write!(
45                f,
46                "The currency-code contains escape-codes but it does not need them.",
47            ),
48            Self::Decode(warning) => fmt::Display::fmt(warning, f),
49            Self::PreferUpperCase => write!(
50                f,
51                "The currency-code follows the ISO 4217 standard whch states: the chars should be uppercase.",
52            ),
53            Self::InvalidCode => {
54                write!(f, "The currency-code is not a valid ISO 4217 code.")
55            }
56            Self::InvalidType => write!(f, "The currency-code should be a string."),
57            Self::InvalidLength => write!(f, "The currency-code follows the ISO 4217 standard whch states: the code should be three chars."),
58            Self::InvalidCodeXTS => write!(
59                f,
60                "The currency-code is `XTS`. This is a code for testing only",
61            ),
62            Self::InvalidCodeXXX => write!(
63                f,
64                "The currency-code is `XXX`. This means there is no currency",
65            ),
66        }
67    }
68}
69
70impl warning::Kind for WarningKind {
71    fn id(&self) -> Cow<'static, str> {
72        match self {
73            Self::ContainsEscapeCodes => "contains_escape_codes".into(),
74            Self::Decode(kind) => kind.id(),
75            Self::PreferUpperCase => "prefer_upper_case".into(),
76            Self::InvalidCode => "invalid_code".into(),
77            Self::InvalidType => "invalid_type".into(),
78            Self::InvalidLength => "invalid_length".into(),
79            Self::InvalidCodeXTS => "invalid_code_xts".into(),
80            Self::InvalidCodeXXX => "invalid_code_xxx".into(),
81        }
82    }
83}
84
85impl From<json::decode::WarningKind> for WarningKind {
86    fn from(warn_kind: json::decode::WarningKind) -> Self {
87        Self::Decode(warn_kind)
88    }
89}
90
91impl json::FromJson<'_, '_> for Code {
92    type WarningKind = WarningKind;
93
94    #[expect(
95        clippy::unwrap_used,
96        reason = "The CURRENCIES_ALPHA3_ARRAY is in sync with the Code enum."
97    )]
98    fn from_json(elem: &'_ json::Element<'_>) -> Verdict<Self, Self::WarningKind> {
99        let mut warnings = warning::Set::new();
100        let value = elem.as_value();
101
102        let Some(s) = value.as_raw_str() else {
103            warnings.with_elem(WarningKind::InvalidType, elem);
104            return Err(warnings);
105        };
106
107        let pending_str = s.has_escapes(elem).gather_warnings_into(&mut warnings);
108
109        let s = match pending_str {
110            json::decode::PendingStr::NoEscapes(s) => s,
111            json::decode::PendingStr::HasEscapes(_) => {
112                warnings.with_elem(WarningKind::ContainsEscapeCodes, elem);
113                return Err(warnings);
114            }
115        };
116
117        let bytes = s.as_bytes();
118
119        // ISO 4217 is expected to be 3 chars enclosed in quotes.
120        let [a, b, c] = bytes else {
121            warnings.with_elem(WarningKind::InvalidLength, elem);
122            return Err(warnings);
123        };
124
125        let triplet: [u8; 3] = [
126            a.to_ascii_uppercase(),
127            b.to_ascii_uppercase(),
128            c.to_ascii_uppercase(),
129        ];
130
131        if triplet != bytes {
132            warnings.with_elem(WarningKind::PreferUpperCase, elem);
133        }
134
135        let Some(index) = CURRENCIES_ALPHA3_ARRAY
136            .iter()
137            .position(|code| code.as_bytes() == triplet)
138        else {
139            warnings.with_elem(WarningKind::InvalidCode, elem);
140            return Err(warnings);
141        };
142
143        let code = Code::from_usize(index).unwrap();
144
145        if matches!(code, Code::Xts) {
146            warnings.with_elem(WarningKind::InvalidCodeXTS, elem);
147        } else if matches!(code, Code::Xxx) {
148            warnings.with_elem(WarningKind::InvalidCodeXXX, elem);
149        }
150
151        Ok(code.into_caveat(warnings))
152    }
153}
154
155impl Code {
156    /// Return a str version of the [Code]
157    ///
158    /// # Panics
159    ///
160    /// Panics if the Code enum is out of sync with the `CURRENCIES_ALPHA3_ARRAY` array
161    #[expect(
162        clippy::indexing_slicing,
163        reason = "The CURRENCIES_ALPHA3_ARRAY is not in sync with the Code enum"
164    )]
165    pub fn into_str(self) -> &'static str {
166        let index = self
167            .to_usize()
168            .expect("The CURRENCIES_ALPHA3_ARRAY is in sync with the Code enum");
169        CURRENCIES_ALPHA3_ARRAY[index]
170    }
171}
172
173into_caveat!(Code);
174
175impl fmt::Display for Code {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        f.write_str(self.into_str())
178    }
179}
180
181/// An ISO 4217 currency code.
182#[derive(Clone, Copy, Debug, Eq, FromPrimitive, Ord, PartialEq, PartialOrd, ToPrimitive)]
183pub enum Code {
184    Aed,
185    Afn,
186    All,
187    Amd,
188    Ang,
189    Aoa,
190    Ars,
191    Aud,
192    Awg,
193    Azn,
194    Bam,
195    Bbd,
196    Bdt,
197    Bgn,
198    Bhd,
199    Bif,
200    Bmd,
201    Bnd,
202    Bob,
203    Bov,
204    Brl,
205    Bsd,
206    Btn,
207    Bwp,
208    Byn,
209    Bzd,
210    Cad,
211    Cdf,
212    Che,
213    Chf,
214    Chw,
215    Clf,
216    Clp,
217    Cny,
218    Cop,
219    Cou,
220    Crc,
221    Cuc,
222    Cup,
223    Cve,
224    Czk,
225    Djf,
226    Dkk,
227    Dop,
228    Dzd,
229    Egp,
230    Ern,
231    Etb,
232    Eur,
233    Fjd,
234    Fkp,
235    Gbp,
236    Gel,
237    Ghs,
238    Gip,
239    Gmd,
240    Gnf,
241    Gtq,
242    Gyd,
243    Hkd,
244    Hnl,
245    Hrk,
246    Htg,
247    Huf,
248    Idr,
249    Ils,
250    Inr,
251    Iqd,
252    Irr,
253    Isk,
254    Jmd,
255    Jod,
256    Jpy,
257    Kes,
258    Kgs,
259    Khr,
260    Kmf,
261    Kpw,
262    Krw,
263    Kwd,
264    Kyd,
265    Kzt,
266    Lak,
267    Lbp,
268    Lkr,
269    Lrd,
270    Lsl,
271    Lyd,
272    Mad,
273    Mdl,
274    Mga,
275    Mkd,
276    Mmk,
277    Mnt,
278    Mop,
279    Mru,
280    Mur,
281    Mvr,
282    Mwk,
283    Mxn,
284    Mxv,
285    Myr,
286    Mzn,
287    Nad,
288    Ngn,
289    Nio,
290    Nok,
291    Npr,
292    Nzd,
293    Omr,
294    Pab,
295    Pen,
296    Pgk,
297    Php,
298    Pkr,
299    Pln,
300    Pyg,
301    Qar,
302    Ron,
303    Rsd,
304    Rub,
305    Rwf,
306    Sar,
307    Sbd,
308    Scr,
309    Sdg,
310    Sek,
311    Sgd,
312    Shp,
313    Sle,
314    Sll,
315    Sos,
316    Srd,
317    Ssp,
318    Stn,
319    Svc,
320    Syp,
321    Szl,
322    Thb,
323    Tjs,
324    Tmt,
325    Tnd,
326    Top,
327    Try,
328    Ttd,
329    Twd,
330    Tzs,
331    Uah,
332    Ugx,
333    Usd,
334    Usn,
335    Uyi,
336    Uyu,
337    Uyw,
338    Uzs,
339    Ved,
340    Ves,
341    Vnd,
342    Vuv,
343    Wst,
344    Xaf,
345    Xag,
346    Xau,
347    Xba,
348    Xbb,
349    Xbc,
350    Xbd,
351    Xcd,
352    Xdr,
353    Xof,
354    Xpd,
355    Xpf,
356    Xpt,
357    Xsu,
358    Xts,
359    Xua,
360    Xxx,
361    Yer,
362    Zar,
363    Zmw,
364    Zwl,
365}
366
367/// `&str` versions of an ISO 4217 currency code
368pub(crate) const CURRENCIES_ALPHA3_ARRAY: [&str; 181] = [
369    "AED", "AFN", "ALL", "AMD", "ANG", "AOA", "ARS", "AUD", "AWG", "AZN", "BAM", "BBD", "BDT",
370    "BGN", "BHD", "BIF", "BMD", "BND", "BOB", "BOV", "BRL", "BSD", "BTN", "BWP", "BYN", "BZD",
371    "CAD", "CDF", "CHE", "CHF", "CHW", "CLF", "CLP", "CNY", "COP", "COU", "CRC", "CUC", "CUP",
372    "CVE", "CZK", "DJF", "DKK", "DOP", "DZD", "EGP", "ERN", "ETB", "EUR", "FJD", "FKP", "GBP",
373    "GEL", "GHS", "GIP", "GMD", "GNF", "GTQ", "GYD", "HKD", "HNL", "HRK", "HTG", "HUF", "IDR",
374    "ILS", "INR", "IQD", "IRR", "ISK", "JMD", "JOD", "JPY", "KES", "KGS", "KHR", "KMF", "KPW",
375    "KRW", "KWD", "KYD", "KZT", "LAK", "LBP", "LKR", "LRD", "LSL", "LYD", "MAD", "MDL", "MGA",
376    "MKD", "MMK", "MNT", "MOP", "MRU", "MUR", "MVR", "MWK", "MXN", "MXV", "MYR", "MZN", "NAD",
377    "NGN", "NIO", "NOK", "NPR", "NZD", "OMR", "PAB", "PEN", "PGK", "PHP", "PKR", "PLN", "PYG",
378    "QAR", "RON", "RSD", "RUB", "RWF", "SAR", "SBD", "SCR", "SDG", "SEK", "SGD", "SHP", "SLE",
379    "SLL", "SOS", "SRD", "SSP", "STN", "SVC", "SYP", "SZL", "THB", "TJS", "TMT", "TND", "TOP",
380    "TRY", "TTD", "TWD", "TZS", "UAH", "UGX", "USD", "USN", "UYI", "UYU", "UYW", "UZS", "VED",
381    "VES", "VND", "VUV", "WST", "XAF", "XAG", "XAU", "XBA", "XBB", "XBC", "XBD", "XCD", "XDR",
382    "XOF", "XPD", "XPF", "XPT", "XSU", "XTS", "XUA", "XXX", "YER", "ZAR", "ZMW", "ZWL",
383];
384
385#[cfg(test)]
386mod test {
387    #![allow(
388        clippy::unwrap_in_result,
389        reason = "unwraps are allowed anywhere in tests"
390    )]
391
392    use assert_matches::assert_matches;
393
394    use crate::{
395        json::{self, FromJson as _},
396        Verdict,
397    };
398
399    use super::{Code, WarningKind};
400
401    #[test]
402    fn should_create_currency_without_issue() {
403        const JSON: &str = r#"{ "currency": "EUR" }"#;
404
405        let (code, warnings) = parse_code_from_json(JSON).unwrap().into_parts();
406
407        assert_eq!(Code::Eur, code);
408        assert_matches!(*warnings, []);
409    }
410
411    #[test]
412    fn should_raise_currency_content_issue() {
413        const JSON: &str = r#"{ "currency": "VVV" }"#;
414
415        let warnings = parse_code_from_json(JSON).unwrap_err().into_kind_vec();
416
417        assert_matches!(*warnings, [WarningKind::InvalidCode]);
418    }
419
420    #[test]
421    fn should_raise_currency_case_issue() {
422        const JSON: &str = r#"{ "currency": "eur" }"#;
423
424        let (code, warnings) = parse_code_from_json(JSON).unwrap().into_parts();
425        let warnings = warnings.into_kind_vec();
426
427        assert_eq!(code, Code::Eur);
428        assert_matches!(*warnings, [WarningKind::PreferUpperCase]);
429    }
430
431    #[test]
432    fn should_raise_currency_xts_issue() {
433        const JSON: &str = r#"{ "currency": "xts" }"#;
434
435        let (code, warnings) = parse_code_from_json(JSON).unwrap().into_parts();
436        let warnings = warnings.into_kind_vec();
437
438        assert_eq!(code, Code::Xts);
439        assert_matches!(
440            *warnings,
441            [WarningKind::PreferUpperCase, WarningKind::InvalidCodeXTS]
442        );
443    }
444
445    #[test]
446    fn should_raise_currency_xxx_issue() {
447        const JSON: &str = r#"{ "currency": "xxx" }"#;
448
449        let (code, warnings) = parse_code_from_json(JSON).unwrap().into_parts();
450        let warnings = warnings.into_kind_vec();
451
452        assert_eq!(code, Code::Xxx);
453        assert_matches!(
454            *warnings,
455            [WarningKind::PreferUpperCase, WarningKind::InvalidCodeXXX]
456        );
457    }
458
459    #[track_caller]
460    fn parse_code_from_json(json: &str) -> Verdict<Code, WarningKind> {
461        let json = json::parse(json).unwrap();
462        let currency_elem = json.find_field("currency").unwrap();
463        Code::from_json(currency_elem.element())
464    }
465}