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