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