ocpi_tariffs/
timezone.rs

1//! Parse an IANA Timezone from JSON or find a timezone in a CDR.
2use std::{borrow::Cow, fmt};
3
4use chrono_tz::Tz;
5use tracing::{debug, instrument};
6
7use crate::{
8    cdr, country, from_warning_set_to, into_caveat_all,
9    json::{self, FieldsAsExt as _, FromJson as _},
10    warning::{self, GatherWarnings as _, OptionExt as _},
11    Caveat, IntoCaveat, ParseError, Verdict, VerdictExt, Version, Versioned, Warning,
12};
13
14/// The warnings possible when parsing or linting an IANA timezone.
15#[derive(Debug)]
16pub enum WarningKind {
17    /// A timezone can't be inferred from the `location`'s `country`.
18    CantInferTimezoneFromCountry(&'static str),
19
20    /// Neither the timezone or country field require char escape codes.
21    ContainsEscapeCodes,
22
23    /// The CDR location is not a valid ISO 3166-1 alpha-3 code.
24    Country(country::WarningKind),
25
26    /// The field at the path could not be decoded.
27    Decode(json::decode::WarningKind),
28
29    /// An error occurred while deserializing the `CDR`.
30    Deserialize(ParseError),
31
32    /// The CDR location is not a String.
33    InvalidLocationType,
34
35    /// The CDR location did not contain a valid IANA time-zone.
36    ///
37    /// See: <https://www.iana.org/time-zones>.
38    InvalidTimezone,
39
40    /// The CDR timezone is not a String.
41    InvalidTimezoneType,
42
43    /// The `location.country` field should be an alpha-3 country code.
44    ///
45    /// The alpha-2 code can be converted into an alpha-3 but the caller should be warned.
46    LocationCountryShouldBeAlpha3,
47
48    /// The CDR's `location` has no `country` element and so the timezone can't be inferred.
49    NoLocationCountry,
50
51    /// The CDR has no `location` element and so the timezone can't be found or inferred.
52    NoLocation,
53
54    /// An `Error` occurred while parsing the JSON or deferred JSON String decode.
55    Parser(json::Error),
56
57    /// Both the CDR and tariff JSON should be an Object.
58    ShouldBeAnObject,
59
60    /// A v221 CDR is given but it contains a `location` field instead of a `cdr_location` as defined in the spec.
61    V221CdrHasLocationField,
62}
63
64from_warning_set_to!(country::WarningKind => WarningKind);
65
66impl fmt::Display for WarningKind {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match self {
69            WarningKind::CantInferTimezoneFromCountry(country_code) => write!(f, "Unable to infer timezone from the `location`'s `country`: `{country_code}`"),
70            WarningKind::ContainsEscapeCodes => f.write_str("The CDR location contains needless escape codes."),
71            WarningKind::Country(kind) => fmt::Display::fmt(kind, f),
72            WarningKind::Decode(warning) => fmt::Display::fmt(warning, f),
73            WarningKind::Deserialize(err) => fmt::Display::fmt(err, f),
74            WarningKind::InvalidLocationType => f.write_str("The CDR location is not a String."),
75            WarningKind::InvalidTimezone => f.write_str("The CDR location did not contain a valid IANA time-zone."),
76            WarningKind::InvalidTimezoneType => f.write_str("The CDR timezone is not a String."),
77            WarningKind::LocationCountryShouldBeAlpha3 => f.write_str("The `location.country` field should be an alpha-3 country code."),
78            WarningKind::NoLocationCountry => {
79                f.write_str("The CDR's `location` has no `country` element and so the timezone can't be inferred.")
80            },
81            WarningKind::NoLocation => {
82                f.write_str("The CDR has no `location` element and so the timezone can't be found or inferred.")                   
83            }
84            WarningKind::Parser(err) => fmt::Display::fmt(err, f),
85            WarningKind::ShouldBeAnObject => f.write_str("The CDR should be a JSON object"),
86            WarningKind::V221CdrHasLocationField => f.write_str("the v2.2.1 CDR contains a `location` field but the v2.2.1 spec defines a `cdr_location` field."),
87
88        }
89    }
90}
91
92impl warning::Kind for WarningKind {
93    fn id(&self) -> Cow<'static, str> {
94        match self {
95            WarningKind::CantInferTimezoneFromCountry(_) => {
96                "cant_infer_timezone_from_country".into()
97            }
98            WarningKind::ContainsEscapeCodes => "contains_escape_codes".into(),
99            WarningKind::Decode(warning) => format!("decode.{}", warning.id()).into(),
100            WarningKind::Deserialize(err) => format!("deserialize.{err}").into(),
101            WarningKind::Country(warning) => format!("country.{}", warning.id()).into(),
102            WarningKind::InvalidLocationType => "invalid_location_type".into(),
103            WarningKind::InvalidTimezone => "invalid_timezone".into(),
104            WarningKind::InvalidTimezoneType => "invalid_timezone_type".into(),
105            WarningKind::LocationCountryShouldBeAlpha3 => {
106                "location_country_should_be_alpha3".into()
107            }
108            WarningKind::NoLocationCountry => "no_location_country".into(),
109            WarningKind::NoLocation => "no_location".into(),
110            WarningKind::Parser(err) => format!("parser.{err}").into(),
111            WarningKind::ShouldBeAnObject => "should_be_an_object".into(),
112            WarningKind::V221CdrHasLocationField => "v221_cdr_has_location_field".into(),
113        }
114    }
115}
116
117/// The source of the timezone
118#[derive(Copy, Clone, Debug)]
119pub enum Source {
120    /// The timezone was found in the `location` element.
121    Found(Tz),
122
123    /// The timezone is inferred from the `location`'s `country`.
124    Inferred(Tz),
125}
126
127into_caveat_all!(Source, Tz);
128
129impl Source {
130    /// Return the timezone and disregard where it came from.
131    pub fn into_timezone(self) -> Tz {
132        match self {
133            Source::Found(tz) | Source::Inferred(tz) => tz,
134        }
135    }
136}
137
138/// Try to find or infer the timezone from the `CDR` JSON.
139///
140/// Return `Some` if the timezone can be found or inferred.
141/// Return `None` if the timezone is not found and can't be inferred.
142///
143/// Finding a timezone is an infallible operation. If invalid data is found a `None` is returned
144/// with an appropriate warning.
145///
146/// If the `CDR` contains a `time_zone` in the location object then that is simply returned.
147/// Only pre-v2.2.1 CDR's have a `time_zone` field in the `Location` object.
148///
149/// Inferring the timezone only works for `CDR`s from European countries.
150///
151pub fn find_or_infer(cdr: &cdr::Versioned<'_>) -> Caveat<Option<Source>, WarningKind> {
152    const LOCATION_FIELD_V211: &str = "location";
153    const LOCATION_FIELD_V221: &str = "cdr_location";
154    const TIMEZONE_FIELD: &str = "time_zone";
155    const COUNTRY_FIELD: &str = "country";
156
157    let mut warnings = warning::Set::new();
158
159    let cdr_root = cdr.as_element();
160    let Some(fields) = cdr_root.as_object_fields() else {
161        warnings.with_elem(WarningKind::ShouldBeAnObject, cdr_root);
162        return None.into_caveat(warnings);
163    };
164
165    let cdr_fields = fields.as_raw_map();
166
167    let v211_location = cdr_fields.get(LOCATION_FIELD_V211);
168
169    // OCPI v221 changed the `location` field to `cdr_location`.
170    //
171    // * See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc#131-cdr-object>
172    // * See: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_cdrs.md#3-object-description>
173    if cdr.version() == Version::V221 && v211_location.is_some() {
174        warnings.with_elem(WarningKind::V221CdrHasLocationField, cdr_root);
175    }
176
177    // Describes the location that the charge-session took place at.
178    //
179    // The v211 CDR has a `location` field, while the v221 CDR has a `cdr_location` field.
180    //
181    // * See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc#131-cdr-object>
182    // * See: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_cdrs.md#3-object-description>
183    let Some(location_elem) = v211_location.or_else(|| cdr_fields.get(LOCATION_FIELD_V221)) else {
184        warnings.with_elem(WarningKind::NoLocation, cdr_root);
185        return None.into_caveat(warnings);
186    };
187
188    let json::Value::Object(fields) = location_elem.as_value() else {
189        warnings.with_elem(WarningKind::InvalidLocationType, cdr_root);
190        return None.into_caveat(warnings);
191    };
192
193    let location_fields = fields.as_raw_map();
194
195    debug!("Searching for time-zone in CDR");
196
197    // The `location::time_zone` field is optional in v211 and not defined in the v221 spec.
198    //
199    // See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc#mod_cdrs_cdr_location_class>
200    // See: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_locations.md#31-location-object>
201    let tz = location_fields.get(TIMEZONE_FIELD).and_then(|elem| {
202        let tz = try_parse_location_timezone(elem).ok_caveat();
203        tz.gather_warnings_into(&mut warnings)
204    });
205
206    if let Some(tz) = tz {
207        return Some(Source::Found(tz)).into_caveat(warnings);
208    }
209
210    debug!("No time-zone found in CDR; trying to infer time-zone from country");
211
212    // ISO 3166-1 alpha-3 code for the country of this location.
213    let Some(country_elem) = location_fields.get(COUNTRY_FIELD) else {
214        // The `location::country` field is required.
215        //
216        // See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc#mod_cdrs_cdr_location_class>
217        // See: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_locations.md#31-location-object>
218        warnings.with_elem(WarningKind::NoLocationCountry, location_elem);
219        return None.into_caveat(warnings);
220    };
221
222    let Some(timezone) = infer_timezone_from_location_country(country_elem)
223        .ok_caveat()
224        .gather_warnings_into(&mut warnings)
225    else {
226        return None.into_caveat(warnings);
227    };
228
229    Some(Source::Inferred(timezone)).into_caveat(warnings)
230}
231
232impl From<json::decode::WarningKind> for WarningKind {
233    fn from(warn_kind: json::decode::WarningKind) -> Self {
234        Self::Decode(warn_kind)
235    }
236}
237
238impl From<country::WarningKind> for WarningKind {
239    fn from(warn_kind: country::WarningKind) -> Self {
240        Self::Country(warn_kind)
241    }
242}
243
244/// Try to parse the `location` element's timezone into a `Tz`.
245fn try_parse_location_timezone(tz_elem: &json::Element<'_>) -> Verdict<Tz, WarningKind> {
246    let tz = tz_elem.as_value();
247    debug!(tz = %tz, "Raw time-zone found in CDR");
248
249    let mut warnings = warning::Set::new();
250    let Some(tz) = tz.as_raw_str() else {
251        warnings.with_elem(WarningKind::InvalidTimezoneType, tz_elem);
252        return Err(warnings);
253    };
254
255    let tz = tz
256        .decode_escapes(tz_elem)
257        .gather_warnings_into(&mut warnings);
258
259    if matches!(tz, Cow::Owned(_)) {
260        warnings.with_elem(WarningKind::ContainsEscapeCodes, tz_elem);
261    }
262
263    debug!(%tz, "Escaped time-zone found in CDR");
264
265    let Ok(tz) = tz.parse::<Tz>() else {
266        warnings.with_elem(WarningKind::InvalidTimezone, tz_elem);
267        return Err(warnings);
268    };
269
270    Ok(tz.into_caveat(warnings))
271}
272
273/// Try to infer a timezone from the `location` elements `country` field.
274#[instrument(skip_all)]
275fn infer_timezone_from_location_country(
276    country_elem: &json::Element<'_>,
277) -> Verdict<Tz, WarningKind> {
278    let mut warnings = warning::Set::new();
279    let code_set = country::CodeSet::from_json(country_elem)?.gather_warnings_into(&mut warnings);
280
281    // The `location.country` field should be an alpha-3 country code.
282    //
283    // The alpha-2 code can be converted into an alpha-3 but the caller should be warned.
284    let country_code = match code_set {
285        country::CodeSet::Alpha2(code) => {
286            warnings.with_elem(WarningKind::LocationCountryShouldBeAlpha3, country_elem);
287            code
288        }
289        country::CodeSet::Alpha3(code) => code,
290    };
291    let tz = try_detect_timezone(country_code).exit_with_warning(warnings, || {
292        Warning::with_elem(
293            WarningKind::CantInferTimezoneFromCountry(country_code.into_str()),
294            country_elem,
295        )
296    })?;
297
298    Ok(tz)
299}
300
301/// Mapping of European countries to time-zones with geographical naming
302///
303/// This is only possible for countries with a single time-zone and only for countries as they
304/// currently exist (2024). It's a best effort approach to determine a time-zone from just an
305/// ALPHA-3 ISO 3166-1 country code.
306///
307/// In small edge cases (e.g. Gibraltar) this detection might generate the wrong time-zone.
308#[instrument]
309fn try_detect_timezone(country_code: country::Code) -> Option<Tz> {
310    let tz = match country_code {
311        country::Code::Ad => Tz::Europe__Andorra,
312        country::Code::Al => Tz::Europe__Tirane,
313        country::Code::At => Tz::Europe__Vienna,
314        country::Code::Ba => Tz::Europe__Sarajevo,
315        country::Code::Be => Tz::Europe__Brussels,
316        country::Code::Bg => Tz::Europe__Sofia,
317        country::Code::By => Tz::Europe__Minsk,
318        country::Code::Ch => Tz::Europe__Zurich,
319        country::Code::Cy => Tz::Europe__Nicosia,
320        country::Code::Cz => Tz::Europe__Prague,
321        country::Code::De => Tz::Europe__Berlin,
322        country::Code::Dk => Tz::Europe__Copenhagen,
323        country::Code::Ee => Tz::Europe__Tallinn,
324        country::Code::Es => Tz::Europe__Madrid,
325        country::Code::Fi => Tz::Europe__Helsinki,
326        country::Code::Fr => Tz::Europe__Paris,
327        country::Code::Gb => Tz::Europe__London,
328        country::Code::Gr => Tz::Europe__Athens,
329        country::Code::Hr => Tz::Europe__Zagreb,
330        country::Code::Hu => Tz::Europe__Budapest,
331        country::Code::Ie => Tz::Europe__Dublin,
332        country::Code::Is => Tz::Iceland,
333        country::Code::It => Tz::Europe__Rome,
334        country::Code::Li => Tz::Europe__Vaduz,
335        country::Code::Lt => Tz::Europe__Vilnius,
336        country::Code::Lu => Tz::Europe__Luxembourg,
337        country::Code::Lv => Tz::Europe__Riga,
338        country::Code::Mc => Tz::Europe__Monaco,
339        country::Code::Md => Tz::Europe__Chisinau,
340        country::Code::Me => Tz::Europe__Podgorica,
341        country::Code::Mk => Tz::Europe__Skopje,
342        country::Code::Mt => Tz::Europe__Malta,
343        country::Code::Nl => Tz::Europe__Amsterdam,
344        country::Code::No => Tz::Europe__Oslo,
345        country::Code::Pl => Tz::Europe__Warsaw,
346        country::Code::Pt => Tz::Europe__Lisbon,
347        country::Code::Ro => Tz::Europe__Bucharest,
348        country::Code::Rs => Tz::Europe__Belgrade,
349        country::Code::Ru => Tz::Europe__Moscow,
350        country::Code::Se => Tz::Europe__Stockholm,
351        country::Code::Si => Tz::Europe__Ljubljana,
352        country::Code::Sk => Tz::Europe__Bratislava,
353        country::Code::Sm => Tz::Europe__San_Marino,
354        country::Code::Tr => Tz::Turkey,
355        country::Code::Ua => Tz::Europe__Kiev,
356        _ => return None,
357    };
358
359    debug!(%tz, "time-zone detected");
360
361    Some(tz)
362}
363
364#[cfg(test)]
365pub mod test {
366    #![allow(clippy::missing_panics_doc, reason = "tests are allowed to panic")]
367    #![allow(clippy::panic, reason = "tests are allowed panic")]
368
369    use std::collections::BTreeMap;
370
371    use crate::{
372        cdr,
373        test::{ExpectFile, ExpectValue, Expectation},
374        warning::{self, test},
375    };
376
377    use super::{Source, WarningKind};
378
379    /// Expectations for the result of calling `timezone::find_or_infer`.
380    #[derive(serde::Deserialize)]
381    pub(crate) struct FindOrInferExpect {
382        /// The expected timezone
383        #[serde(default)]
384        timezone: Expectation<String>,
385
386        /// A list of expected warnings by `Warning::id()`.
387        #[serde(default)]
388        warnings: Expectation<BTreeMap<String, Vec<String>>>,
389    }
390
391    #[track_caller]
392    pub(crate) fn assert_find_or_infer_outcome(
393        cdr: &cdr::Versioned<'_>,
394        timezone: Source,
395        expect: ExpectFile<FindOrInferExpect>,
396        warnings: &warning::Set<WarningKind>,
397    ) {
398        let ExpectFile {
399            value: expect,
400            expect_file_name,
401        } = expect;
402
403        let root = cdr.as_element();
404
405        let Some(expect) = expect else {
406            assert!(
407                warnings.is_empty(),
408                "There is no expectation file at `{expect_file_name}` but the timezone has warnings;\n{:?}",
409                warnings.group_by_elem(root).into_stringified_map()
410            );
411            return;
412        };
413
414        if let Expectation::Present(ExpectValue::Some(expected)) = &expect.timezone {
415            assert_eq!(expected, &timezone.into_timezone().to_string());
416        }
417
418        test::assert_warnings(&expect_file_name, root, warnings, expect.warnings);
419    }
420}
421
422#[cfg(test)]
423mod test_find_or_infer {
424    use assert_matches::assert_matches;
425
426    use crate::{cdr, json, test, timezone::WarningKind, warning, Version};
427
428    use super::{find_or_infer, Source};
429
430    #[test]
431    fn should_find_timezone() {
432        const JSON: &str = r#"{
433    "country_code": "NL",
434    "cdr_location": {
435        "time_zone": "Europe/Amsterdam"
436    }
437}"#;
438
439        test::setup();
440        let (_cdr, timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON);
441
442        assert_matches!(timezone, Source::Found(chrono_tz::Tz::Europe__Amsterdam));
443        assert_matches!(*warnings, []);
444    }
445
446    #[test]
447    fn should_find_timezone_but_warn_about_use_of_location_for_v221_cdr() {
448        const JSON: &str = r#"{
449    "country_code": "NL",
450    "location": {
451        "time_zone": "Europe/Amsterdam"
452    }
453}"#;
454
455        test::setup();
456        // If you parse a CDR that has a `location` field as v221 you will get multiple warnings...
457        let cdr::ParseReport {
458            cdr,
459            unexpected_fields,
460        } = cdr::parse_with_version(JSON, Version::V221).unwrap();
461
462        // The parse function will complain about unexpected fields
463        assert_unexpected_fields(&unexpected_fields, &["$.location", "$.location.time_zone"]);
464
465        let (timezone_source, warnings) = find_or_infer(&cdr).into_parts();
466        let warnings = warnings.into_kind_vec();
467        let timezone_source = timezone_source.unwrap();
468
469        assert_matches!(
470            timezone_source,
471            Source::Found(chrono_tz::Tz::Europe__Amsterdam)
472        );
473        // And the `find_or_infer` fn will warn about a v221 CDR having a `location` field.
474        assert_matches!(*warnings, [WarningKind::V221CdrHasLocationField]);
475    }
476
477    #[test]
478    fn should_find_timezone_without_cdr_country() {
479        const JSON: &str = r#"{
480    "cdr_location": {
481        "time_zone": "Europe/Amsterdam"
482    }
483}"#;
484
485        test::setup();
486        let (_cdr, timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON);
487
488        assert_matches!(timezone, Source::Found(chrono_tz::Tz::Europe__Amsterdam));
489        assert_matches!(*warnings, []);
490    }
491
492    #[test]
493    fn should_infer_timezone_and_warn_about_invalid_type() {
494        const JSON: &str = r#"{
495    "country_code": "NL",
496    "cdr_location": {
497        "time_zone": null,
498        "country": "BEL"
499    }
500}"#;
501
502        test::setup();
503        let (_cdr, timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON);
504        let warnings = warnings.into_kind_vec();
505
506        assert_matches!(timezone, Source::Inferred(chrono_tz::Tz::Europe__Brussels));
507        assert_matches!(*warnings, [WarningKind::InvalidTimezoneType]);
508    }
509
510    #[test]
511    fn should_find_timezone_and_warn_about_invalid_type() {
512        const JSON: &str = r#"{
513    "country_code": "NL",
514    "cdr_location": {
515        "time_zone": "Europe/Hamsterdam",
516        "country": "BEL"
517    }
518}"#;
519
520        test::setup();
521        let (_cdr, timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON);
522        let warnings = warnings.into_kind_vec();
523
524        assert_matches!(timezone, Source::Inferred(chrono_tz::Tz::Europe__Brussels));
525        assert_matches!(*warnings, [WarningKind::InvalidTimezone]);
526    }
527
528    #[test]
529    fn should_find_timezone_and_warn_about_escape_codes_and_invalid_type() {
530        const JSON: &str = r#"{
531    "country_code": "NL",
532    "cdr_location": {
533        "time_zone": "Europe\/Hamsterdam",
534        "country": "BEL"
535    }
536}"#;
537
538        test::setup();
539        let (cdr, timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON);
540        let warnings = warnings.into_parts_vec();
541
542        assert_matches!(timezone, Source::Inferred(chrono_tz::Tz::Europe__Brussels));
543        let elem_id = assert_matches!(
544            &*warnings,
545            [
546                (WarningKind::ContainsEscapeCodes, elem_id),
547                (WarningKind::InvalidTimezone, _)
548            ] => elem_id
549        );
550
551        let cdr_elem = cdr.into_element();
552        let elem_map = json::test::ElementMap::for_elem(&cdr_elem);
553        let elem = elem_map.get(*elem_id);
554        assert_eq!(elem.path(), "$.cdr_location.time_zone");
555    }
556
557    #[test]
558    fn should_find_timezone_and_warn_about_escape_codes() {
559        const JSON: &str = r#"{
560    "country_code": "NL",
561    "cdr_location": {
562        "time_zone": "Europe\/Amsterdam",
563        "country": "BEL"
564    }
565}"#;
566
567        test::setup();
568        let (cdr, timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON);
569        let warnings = warnings.into_parts_vec();
570
571        assert_matches!(timezone, Source::Found(chrono_tz::Tz::Europe__Amsterdam));
572        let elem_id = assert_matches!(
573            &*warnings,
574            [( WarningKind::ContainsEscapeCodes, elem_id )] => elem_id
575        );
576        assert_elem_path(cdr.as_element(), *elem_id, "$.cdr_location.time_zone");
577    }
578
579    #[test]
580    fn should_infer_timezone_from_location_country() {
581        const JSON: &str = r#"{
582    "country_code": "NL",
583    "cdr_location": {
584        "country": "BEL"
585    }
586}"#;
587
588        test::setup();
589        let (_cdr, timezone, warnings) = parse_expect_v221(JSON);
590
591        assert_matches!(
592            timezone,
593            Some(Source::Inferred(chrono_tz::Tz::Europe__Brussels))
594        );
595        assert_matches!(*warnings, []);
596    }
597
598    #[test]
599    fn should_find_timezone_but_report_alpha2_location_country_code() {
600        const JSON: &str = r#"{
601    "country_code": "NL",
602    "cdr_location": {
603        "country": "BE"
604    }
605}"#;
606
607        test::setup();
608        let (cdr, timezone, warnings) = parse_expect_v221(JSON);
609        let warnings = warnings.into_parts_vec();
610
611        assert_matches!(
612            timezone,
613            Some(Source::Inferred(chrono_tz::Tz::Europe__Brussels))
614        );
615        let elem_id = assert_matches!(
616            &*warnings,
617            [(
618                WarningKind::LocationCountryShouldBeAlpha3,
619                elem_id
620            )] => elem_id
621        );
622
623        assert_elem_path(cdr.as_element(), *elem_id, "$.cdr_location.country");
624    }
625
626    #[test]
627    fn should_not_find_timezone_due_to_no_location() {
628        const JSON: &str = r#"{ "country_code": "BE" }"#;
629
630        test::setup();
631        let (cdr, source, warnings) = parse_expect_v221(JSON);
632        let warnings = warnings.into_parts_vec();
633        assert_matches!(source, None);
634
635        let elem_id = assert_matches!(&*warnings, [( WarningKind::NoLocation, elem_id)] => elem_id);
636
637        assert_elem_path(cdr.as_element(), *elem_id, "$");
638    }
639
640    #[test]
641    fn should_not_find_timezone_due_to_no_country() {
642        // The `$.country_code` field is not used at all when inferring the timezone.
643        const JSON: &str = r#"{
644    "country_code": "BELGIUM",
645    "cdr_location": {}
646}"#;
647
648        test::setup();
649        let (cdr, source, warnings) = parse_expect_v221(JSON);
650        let warnings = warnings.into_parts_vec();
651
652        assert_matches!(source, None);
653        let elem_id =
654            assert_matches!(&*warnings, [(WarningKind::NoLocationCountry, elem_id)] => elem_id);
655        assert_elem_path(cdr.as_element(), *elem_id, "$.cdr_location");
656    }
657
658    #[test]
659    fn should_not_find_timezone_due_to_country_having_many_timezones() {
660        const JSON: &str = r#"{
661    "country_code": "BE",
662    "cdr_location": {
663        "country": "CHN"
664    }
665}"#;
666
667        test::setup();
668        let (cdr, source, warnings) = parse_expect_v221(JSON);
669        let warnings = warnings.into_parts_vec();
670        assert_matches!(source, None);
671
672        let elem_id = assert_matches!(
673            &*warnings,
674            [(WarningKind::CantInferTimezoneFromCountry("CN"), elem_id)] => elem_id
675        );
676
677        assert_elem_path(cdr.as_element(), *elem_id, "$.cdr_location.country");
678    }
679
680    #[test]
681    fn should_fail_due_to_json_not_being_object() {
682        const JSON: &str = r#"["not_a_cdr"]"#;
683
684        test::setup();
685        let (cdr, source, warnings) = parse_expect_v221(JSON);
686        let warnings = warnings.into_parts_vec();
687        assert_matches!(source, None);
688
689        let elem_id = assert_matches!(
690            &*warnings,
691            [(WarningKind::ShouldBeAnObject, elem_id)] => elem_id
692        );
693        assert_elem_path(cdr.as_element(), *elem_id, "$");
694    }
695
696    /// Parse CDR and infer the timezone and assert that there are no unexpected fields.
697    #[track_caller]
698    fn parse_expect_v221(
699        json: &str,
700    ) -> (
701        cdr::Versioned<'_>,
702        Option<Source>,
703        warning::Set<WarningKind>,
704    ) {
705        let cdr::ParseReport {
706            cdr,
707            unexpected_fields,
708        } = cdr::parse_with_version(json, Version::V221).unwrap();
709        test::assert_no_unexpected_fields(&unexpected_fields);
710
711        let (timezone_source, warnings) = find_or_infer(&cdr).into_parts();
712        (cdr, timezone_source, warnings)
713    }
714
715    /// Parse CDR and infer the timezone and assert that the `$.cdr_location.time_zone` fields.
716    #[track_caller]
717    fn parse_expect_v221_and_time_zone_field(
718        json: &str,
719    ) -> (cdr::Versioned<'_>, Source, warning::Set<WarningKind>) {
720        let cdr::ParseReport {
721            cdr,
722            unexpected_fields,
723        } = cdr::parse_with_version(json, Version::V221).unwrap();
724        assert_unexpected_fields(&unexpected_fields, &["$.cdr_location.time_zone"]);
725
726        let (timezone_source, warnings) = find_or_infer(&cdr).into_parts();
727        (cdr, timezone_source.unwrap(), warnings)
728    }
729
730    /// Assert that the `Element` has a path.
731    #[track_caller]
732    fn assert_elem_path(elem: &json::Element<'_>, elem_id: json::ElemId, path: &str) {
733        let elem_map = json::test::ElementMap::for_elem(elem);
734        let elem = elem_map.get(elem_id);
735
736        assert_eq!(elem.path(), path);
737    }
738
739    #[track_caller]
740    fn assert_unexpected_fields(
741        unexpected_fields: &json::UnexpectedFields<'_>,
742        expected: &[&'static str],
743    ) {
744        if unexpected_fields.len() != expected.len() {
745            let unexpected_fields = unexpected_fields
746                .into_iter()
747                .map(|path| path.to_string())
748                .collect::<Vec<_>>();
749
750            panic!(
751                "The unexpected fields and expected fields lists have different lengths.\n\nUnexpected fields found:\n{}",
752                unexpected_fields.join(",\n")
753            );
754        }
755
756        let unmatched_paths = unexpected_fields
757            .into_iter()
758            .zip(expected.iter())
759            .filter(|(a, b)| a != *b)
760            .collect::<Vec<_>>();
761
762        if !unmatched_paths.is_empty() {
763            let unmatched_paths = unmatched_paths
764                .into_iter()
765                .map(|(a, b)| format!("{a} != {b}"))
766                .collect::<Vec<_>>();
767
768            panic!(
769                "The unexpected fields don't match the expected fields.\n\nUnexpected fields found:\n{}",
770                unmatched_paths.join(",\n")
771            );
772        }
773    }
774}