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