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) -> 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#[derive(Copy, Clone, Debug)]
124pub enum Source {
125 Found(Tz),
127
128 Inferred(Tz),
130}
131
132into_caveat_all!(Source, Tz);
133
134impl Source {
135 pub fn into_timezone(self) -> Tz {
137 match self {
138 Source::Found(tz) | Source::Inferred(tz) => tz,
139 }
140 }
141}
142
143pub 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 if cdr.version() == Version::V221 && v211_location.is_some() {
178 warnings.with_elem(Warning::V221CdrHasLocationField, cdr_root);
179 }
180
181 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 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 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 let Some(country_elem) = location_fields.get(COUNTRY_FIELD) else {
226 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
238fn 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#[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 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#[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 #[derive(serde::Deserialize)]
370 pub(crate) struct FindOrInferExpect {
371 #[serde(default)]
373 timezone: Expectation<String>,
374
375 #[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 let cdr::ParseReport {
449 cdr,
450 unexpected_fields,
451 } = cdr::parse_with_version(JSON, Version::V221).unwrap();
452
453 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 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 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 #[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 #[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}