ocpi_tariffs/
timezone.rs

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