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