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