1use 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#[derive(Debug)]
16pub enum Warning {
17 CantInferTimezoneFromCountry(&'static str),
19
20 ContainsEscapeCodes,
22
23 Country(country::Warning),
25
26 Decode(json::decode::Warning),
28
29 Deserialize(ParseError),
31
32 InvalidLocationType,
34
35 InvalidTimezone,
39
40 InvalidTimezoneType,
42
43 LocationCountryShouldBeAlpha3,
47
48 NoLocationCountry,
50
51 NoLocation,
53
54 Parser(json::Error),
56
57 ShouldBeAnObject,
59
60 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#[derive(Copy, Clone, Debug)]
118pub enum Source {
119 Found(Tz),
121
122 Inferred(Tz),
124}
125
126into_caveat_all!(Source, Tz);
127
128impl Source {
129 pub fn into_timezone(self) -> Tz {
131 match self {
132 Source::Found(tz) | Source::Inferred(tz) => tz,
133 }
134 }
135}
136
137pub 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 if cdr.version() == Version::V221 && v211_location.is_some() {
172 warnings.with_elem(Warning::V221CdrHasLocationField, cdr_root);
173 }
174
175 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 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 let Some(country_elem) = location_fields.get(COUNTRY_FIELD) else {
218 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
230fn 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#[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 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#[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 #[derive(serde::Deserialize)]
362 pub(crate) struct FindOrInferExpect {
363 #[serde(default)]
365 timezone: Expectation<String>,
366
367 #[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 let cdr::ParseReport {
441 cdr,
442 unexpected_fields,
443 } = cdr::parse_with_version(JSON, Version::V221).unwrap();
444
445 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 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 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 #[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 #[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}