Skip to main content

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