1use std::{borrow::Cow, fmt};
3
4use chrono_tz::Tz;
5use tracing::{debug, instrument};
6
7use crate::{
8 cdr, country, from_warning_set_to, into_caveat,
9 json::{self, FieldsAsExt as _, FromJson as _},
10 warning::{self, GatherWarnings as _, OptionExt as _},
11 Caveat, IntoCaveat, ParseError, Verdict, VerdictExt, Version, Versioned, Warning,
12};
13
14#[derive(Debug)]
16pub enum WarningKind {
17 CantInferTimezoneFromCountry(&'static str),
19
20 ContainsEscapeCodes,
22
23 Country(country::WarningKind),
25
26 Decode(json::decode::WarningKind),
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_set_to!(country::WarningKind => WarningKind);
65
66impl fmt::Display for WarningKind {
67 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68 match self {
69 WarningKind::CantInferTimezoneFromCountry(country_code) => write!(f, "Unable to infer timezone from the `location`'s `country`: `{country_code}`"),
70 WarningKind::ContainsEscapeCodes => f.write_str("The CDR location contains needless escape codes."),
71 WarningKind::Country(kind) => fmt::Display::fmt(kind, f),
72 WarningKind::Decode(warning) => fmt::Display::fmt(warning, f),
73 WarningKind::Deserialize(err) => fmt::Display::fmt(err, f),
74 WarningKind::InvalidLocationType => f.write_str("The CDR location is not a String."),
75 WarningKind::InvalidTimezone => f.write_str("The CDR location did not contain a valid IANA time-zone."),
76 WarningKind::InvalidTimezoneType => f.write_str("The CDR timezone is not a String."),
77 WarningKind::LocationCountryShouldBeAlpha3 => f.write_str("The `location.country` field should be an alpha-3 country code."),
78 WarningKind::NoLocationCountry => {
79 f.write_str("The CDR's `location` has no `country` element and so the timezone can't be inferred.")
80 },
81 WarningKind::NoLocation => {
82 f.write_str("The CDR has no `location` element and so the timezone can't be found or inferred.")
83 }
84 WarningKind::Parser(err) => fmt::Display::fmt(err, f),
85 WarningKind::ShouldBeAnObject => f.write_str("The CDR should be a JSON object"),
86 WarningKind::V221CdrHasLocationField => f.write_str("the v2.2.1 CDR contains a `location` field but the v2.2.1 spec defines a `cdr_location` field."),
87
88 }
89 }
90}
91
92impl warning::Kind for WarningKind {
93 fn id(&self) -> Cow<'static, str> {
94 match self {
95 WarningKind::CantInferTimezoneFromCountry(_) => {
96 "cant_infer_timezone_from_country".into()
97 }
98 WarningKind::ContainsEscapeCodes => "contains_escape_codes".into(),
99 WarningKind::Decode(warning) => format!("decode.{}", warning.id()).into(),
100 WarningKind::Deserialize(err) => format!("deserialize.{err}").into(),
101 WarningKind::Country(warning) => format!("country.{}", warning.id()).into(),
102 WarningKind::InvalidLocationType => "invalid_location_type".into(),
103 WarningKind::InvalidTimezone => "invalid_timezone".into(),
104 WarningKind::InvalidTimezoneType => "invalid_timezone_type".into(),
105 WarningKind::LocationCountryShouldBeAlpha3 => {
106 "location_country_should_be_alpha3".into()
107 }
108 WarningKind::NoLocationCountry => "no_location_country".into(),
109 WarningKind::NoLocation => "no_location".into(),
110 WarningKind::Parser(err) => format!("parser.{err}").into(),
111 WarningKind::ShouldBeAnObject => "should_be_an_object".into(),
112 WarningKind::V221CdrHasLocationField => "v221_cdr_has_location_field".into(),
113 }
114 }
115}
116
117#[derive(Copy, Clone, Debug)]
119pub enum Source {
120 Found(Tz),
122
123 Inferred(Tz),
125}
126
127into_caveat!(Source);
128into_caveat!(Tz);
129
130impl Source {
131 pub fn into_timezone(self) -> Tz {
133 match self {
134 Source::Found(tz) | Source::Inferred(tz) => tz,
135 }
136 }
137}
138
139pub fn find_or_infer(cdr: &cdr::Versioned<'_>) -> Caveat<Option<Source>, WarningKind> {
153 const LOCATION_FIELD_V211: &str = "location";
154 const LOCATION_FIELD_V221: &str = "cdr_location";
155 const TIMEZONE_FIELD: &str = "time_zone";
156 const COUNTRY_FIELD: &str = "country";
157
158 let mut warnings = warning::Set::new();
159
160 let cdr_root = cdr.as_element();
161 let Some(fields) = cdr_root.as_object_fields() else {
162 warnings.with_elem(WarningKind::ShouldBeAnObject, cdr_root);
163 return None.into_caveat(warnings);
164 };
165
166 let cdr_fields = fields.as_raw_map();
167
168 let v211_location = cdr_fields.get(LOCATION_FIELD_V211);
169
170 if cdr.version() == Version::V221 && v211_location.is_some() {
175 warnings.with_elem(WarningKind::V221CdrHasLocationField, cdr_root);
176 }
177
178 let Some(location_elem) = v211_location.or_else(|| cdr_fields.get(LOCATION_FIELD_V221)) else {
185 warnings.with_elem(WarningKind::NoLocation, cdr_root);
186 return None.into_caveat(warnings);
187 };
188
189 let json::Value::Object(fields) = location_elem.as_value() else {
190 warnings.with_elem(WarningKind::InvalidLocationType, cdr_root);
191 return None.into_caveat(warnings);
192 };
193
194 let location_fields = fields.as_raw_map();
195
196 debug!("Searching for time-zone in CDR");
197
198 let tz = location_fields.get(TIMEZONE_FIELD).and_then(|elem| {
203 let tz = try_parse_location_timezone(elem).ok_caveat();
204 tz.gather_warnings_into(&mut warnings)
205 });
206
207 if let Some(tz) = tz {
208 return Some(Source::Found(tz)).into_caveat(warnings);
209 }
210
211 debug!("No time-zone found in CDR; trying to infer time-zone from country");
212
213 let Some(country_elem) = location_fields.get(COUNTRY_FIELD) else {
215 warnings.with_elem(WarningKind::NoLocationCountry, location_elem);
220 return None.into_caveat(warnings);
221 };
222
223 let Some(timezone) = infer_timezone_from_location_country(country_elem)
224 .ok_caveat()
225 .gather_warnings_into(&mut warnings)
226 else {
227 return None.into_caveat(warnings);
228 };
229
230 Some(Source::Inferred(timezone)).into_caveat(warnings)
231}
232
233impl From<json::decode::WarningKind> for WarningKind {
234 fn from(warn_kind: json::decode::WarningKind) -> Self {
235 Self::Decode(warn_kind)
236 }
237}
238
239impl From<country::WarningKind> for WarningKind {
240 fn from(warn_kind: country::WarningKind) -> Self {
241 Self::Country(warn_kind)
242 }
243}
244
245fn try_parse_location_timezone(tz_elem: &json::Element<'_>) -> Verdict<Tz, WarningKind> {
247 let tz = tz_elem.as_value();
248 debug!(tz = %tz, "Raw time-zone found in CDR");
249
250 let mut warnings = warning::Set::new();
251 let Some(tz) = tz.as_raw_str() else {
253 warnings.with_elem(WarningKind::InvalidTimezoneType, tz_elem);
254 return Err(warnings);
255 };
256
257 let tz = tz
258 .decode_escapes(tz_elem)
259 .gather_warnings_into(&mut warnings);
260
261 if matches!(tz, Cow::Owned(_)) {
262 warnings.with_elem(WarningKind::ContainsEscapeCodes, tz_elem);
263 }
264
265 debug!(%tz, "Escaped time-zone found in CDR");
266
267 let Ok(tz) = tz.parse::<Tz>() else {
268 warnings.with_elem(WarningKind::InvalidTimezone, tz_elem);
269 return Err(warnings);
270 };
271
272 Ok(tz.into_caveat(warnings))
273}
274
275#[instrument(skip_all)]
277fn infer_timezone_from_location_country(
278 country_elem: &json::Element<'_>,
279) -> Verdict<Tz, WarningKind> {
280 let mut warnings = warning::Set::new();
281 let code_set = country::CodeSet::from_json(country_elem)?.gather_warnings_into(&mut warnings);
282
283 let country_code = match code_set {
287 country::CodeSet::Alpha2(code) => {
288 warnings.with_elem(WarningKind::LocationCountryShouldBeAlpha3, country_elem);
289 code
290 }
291 country::CodeSet::Alpha3(code) => code,
292 };
293 let tz = try_detect_timezone(country_code).exit_with_warning(warnings, || {
294 Warning::with_elem(
295 WarningKind::CantInferTimezoneFromCountry(country_code.into_str()),
296 country_elem,
297 )
298 })?;
299
300 Ok(tz)
301}
302
303#[instrument]
311fn try_detect_timezone(country_code: country::Code) -> Option<Tz> {
312 let tz = match country_code {
313 country::Code::Ad => Tz::Europe__Andorra,
314 country::Code::Al => Tz::Europe__Tirane,
315 country::Code::At => Tz::Europe__Vienna,
316 country::Code::Ba => Tz::Europe__Sarajevo,
317 country::Code::Be => Tz::Europe__Brussels,
318 country::Code::Bg => Tz::Europe__Sofia,
319 country::Code::By => Tz::Europe__Minsk,
320 country::Code::Ch => Tz::Europe__Zurich,
321 country::Code::Cy => Tz::Europe__Nicosia,
322 country::Code::Cz => Tz::Europe__Prague,
323 country::Code::De => Tz::Europe__Berlin,
324 country::Code::Dk => Tz::Europe__Copenhagen,
325 country::Code::Ee => Tz::Europe__Tallinn,
326 country::Code::Es => Tz::Europe__Madrid,
327 country::Code::Fi => Tz::Europe__Helsinki,
328 country::Code::Fr => Tz::Europe__Paris,
329 country::Code::Gb => Tz::Europe__London,
330 country::Code::Gr => Tz::Europe__Athens,
331 country::Code::Hr => Tz::Europe__Zagreb,
332 country::Code::Hu => Tz::Europe__Budapest,
333 country::Code::Ie => Tz::Europe__Dublin,
334 country::Code::Is => Tz::Iceland,
335 country::Code::It => Tz::Europe__Rome,
336 country::Code::Li => Tz::Europe__Vaduz,
337 country::Code::Lt => Tz::Europe__Vilnius,
338 country::Code::Lu => Tz::Europe__Luxembourg,
339 country::Code::Lv => Tz::Europe__Riga,
340 country::Code::Mc => Tz::Europe__Monaco,
341 country::Code::Md => Tz::Europe__Chisinau,
342 country::Code::Me => Tz::Europe__Podgorica,
343 country::Code::Mk => Tz::Europe__Skopje,
344 country::Code::Mt => Tz::Europe__Malta,
345 country::Code::Nl => Tz::Europe__Amsterdam,
346 country::Code::No => Tz::Europe__Oslo,
347 country::Code::Pl => Tz::Europe__Warsaw,
348 country::Code::Pt => Tz::Europe__Lisbon,
349 country::Code::Ro => Tz::Europe__Bucharest,
350 country::Code::Rs => Tz::Europe__Belgrade,
351 country::Code::Ru => Tz::Europe__Moscow,
352 country::Code::Se => Tz::Europe__Stockholm,
353 country::Code::Si => Tz::Europe__Ljubljana,
354 country::Code::Sk => Tz::Europe__Bratislava,
355 country::Code::Sm => Tz::Europe__San_Marino,
356 country::Code::Tr => Tz::Turkey,
357 country::Code::Ua => Tz::Europe__Kiev,
358 _ => return None,
359 };
360
361 debug!(%tz, "time-zone detected");
362
363 Some(tz)
364}
365
366#[cfg(test)]
367pub mod test {
368 #![allow(clippy::missing_panics_doc, reason = "tests are allowed to panic")]
369 #![allow(clippy::panic, reason = "tests are allowed panic")]
370
371 use std::collections::BTreeMap;
372
373 use crate::{cdr, json, warning};
374
375 use super::{Source, WarningKind};
376
377 #[derive(serde::Deserialize)]
379 pub struct FindOrInferExpect {
380 pub timezone: Option<String>,
382
383 pub warnings: BTreeMap<String, Vec<String>>,
385 }
386
387 #[track_caller]
388 pub(crate) fn assert_find_or_infer_outcome(
389 cdr: &cdr::Versioned<'_>,
390 timezone: Source,
391 expect: Option<&FindOrInferExpect>,
392 warnings: &warning::Set<WarningKind>,
393 ) {
394 let elem_map = json::test::ElementMap::for_elem(cdr.as_element());
395
396 let Some(expect) = expect else {
397 return;
398 };
399
400 for warning in warnings {
401 let path = elem_map.path(warning.elem_id().unwrap());
402 let id = warning.id();
403 let path_string = path.to_string();
404
405 if let Some(timezone_expected) = &expect.timezone {
406 assert_eq!(timezone_expected, &timezone.into_timezone().to_string());
407 }
408
409 let Some(expected_warnings) = expect.warnings.get(&*path_string) else {
410 panic!(
411 "There are no warnings expected for path: `{path_string}`; warnings: {:?}",
412 warnings.iter().map(crate::Warning::id).collect::<Vec<_>>()
413 );
414 };
415
416 assert!(
417 expected_warnings.iter().any(|w| warning.id() == *w),
418 "Warning `{id}` not expected for path: `{path_string}`",
419 );
420 }
421 }
422}
423
424#[cfg(test)]
425mod test_find_or_infer {
426 use assert_matches::assert_matches;
427
428 use crate::{cdr, json, test, timezone::WarningKind, warning, Version};
429
430 use super::{find_or_infer, Source};
431
432 #[test]
433 fn should_find_timezone() {
434 const JSON: &str = r#"{
435 "country_code": "NL",
436 "cdr_location": {
437 "time_zone": "Europe/Amsterdam"
438 }
439}"#;
440
441 test::setup();
442 let (_cdr, timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON);
443
444 assert_matches!(timezone, Source::Found(chrono_tz::Tz::Europe__Amsterdam));
445 assert_matches!(*warnings, []);
446 }
447
448 #[test]
449 fn should_find_timezone_but_warn_about_use_of_location_for_v221_cdr() {
450 const JSON: &str = r#"{
451 "country_code": "NL",
452 "location": {
453 "time_zone": "Europe/Amsterdam"
454 }
455}"#;
456
457 test::setup();
458 let cdr::ParseReport {
460 cdr,
461 unexpected_fields,
462 } = cdr::parse_with_version(JSON, Version::V221).unwrap();
463
464 test::assert_unexpected_fields(&unexpected_fields, &["$.location", "$.location.time_zone"]);
466
467 let (timezone_source, warnings) = find_or_infer(&cdr).into_parts();
468 let warnings = warnings.into_kind_vec();
469 let timezone_source = timezone_source.unwrap();
470
471 assert_matches!(
472 timezone_source,
473 Source::Found(chrono_tz::Tz::Europe__Amsterdam)
474 );
475 assert_matches!(*warnings, [WarningKind::V221CdrHasLocationField]);
477 }
478
479 #[test]
480 fn should_find_timezone_without_cdr_country() {
481 const JSON: &str = r#"{
482 "cdr_location": {
483 "time_zone": "Europe/Amsterdam"
484 }
485}"#;
486
487 test::setup();
488 let (_cdr, timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON);
489
490 assert_matches!(timezone, Source::Found(chrono_tz::Tz::Europe__Amsterdam));
491 assert_matches!(*warnings, []);
492 }
493
494 #[test]
495 fn should_infer_timezone_and_warn_about_invalid_type() {
496 const JSON: &str = r#"{
497 "country_code": "NL",
498 "cdr_location": {
499 "time_zone": null,
500 "country": "BEL"
501 }
502}"#;
503
504 test::setup();
505 let (_cdr, timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON);
506 let warnings = warnings.into_kind_vec();
507
508 assert_matches!(timezone, Source::Inferred(chrono_tz::Tz::Europe__Brussels));
509 assert_matches!(*warnings, [WarningKind::InvalidTimezoneType]);
510 }
511
512 #[test]
513 fn should_find_timezone_and_warn_about_invalid_type() {
514 const JSON: &str = r#"{
515 "country_code": "NL",
516 "cdr_location": {
517 "time_zone": "Europe/Hamsterdam",
518 "country": "BEL"
519 }
520}"#;
521
522 test::setup();
523 let (_cdr, timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON);
524 let warnings = warnings.into_kind_vec();
525
526 assert_matches!(timezone, Source::Inferred(chrono_tz::Tz::Europe__Brussels));
527 assert_matches!(*warnings, [WarningKind::InvalidTimezone]);
528 }
529
530 #[test]
531 fn should_find_timezone_and_warn_about_escape_codes_and_invalid_type() {
532 const JSON: &str = r#"{
533 "country_code": "NL",
534 "cdr_location": {
535 "time_zone": "Europe\/Hamsterdam",
536 "country": "BEL"
537 }
538}"#;
539
540 test::setup();
541 let (cdr, timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON);
542 let warnings = warnings.into_parts_vec();
543
544 assert_matches!(timezone, Source::Inferred(chrono_tz::Tz::Europe__Brussels));
545 let elem_id = assert_matches!(
546 &*warnings,
547 [
548 (WarningKind::ContainsEscapeCodes, elem_id),
549 (WarningKind::InvalidTimezone, _)
550 ] => elem_id
551 );
552
553 let cdr_elem = cdr.into_element();
554 let elem_map = json::test::ElementMap::for_elem(&cdr_elem);
555 let elem = elem_map.get(elem_id.unwrap());
556 assert_eq!(elem.path(), "$.cdr_location.time_zone");
557 }
558
559 #[test]
560 fn should_find_timezone_and_warn_about_escape_codes() {
561 const JSON: &str = r#"{
562 "country_code": "NL",
563 "cdr_location": {
564 "time_zone": "Europe\/Amsterdam",
565 "country": "BEL"
566 }
567}"#;
568
569 test::setup();
570 let (cdr, timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON);
571 let warnings = warnings.into_parts_vec();
572
573 assert_matches!(timezone, Source::Found(chrono_tz::Tz::Europe__Amsterdam));
574 let elem_id = assert_matches!(
575 &*warnings,
576 [( WarningKind::ContainsEscapeCodes, elem_id )] => elem_id
577 );
578 assert_elem_path(
579 cdr.as_element(),
580 elem_id.unwrap(),
581 "$.cdr_location.time_zone",
582 );
583 }
584
585 #[test]
586 fn should_infer_timezone_from_location_country() {
587 const JSON: &str = r#"{
588 "country_code": "NL",
589 "cdr_location": {
590 "country": "BEL"
591 }
592}"#;
593
594 test::setup();
595 let (_cdr, timezone, warnings) = parse_expect_v221(JSON);
596
597 assert_matches!(
598 timezone,
599 Some(Source::Inferred(chrono_tz::Tz::Europe__Brussels))
600 );
601 assert_matches!(*warnings, []);
602 }
603
604 #[test]
605 fn should_find_timezone_but_report_alpha2_location_country_code() {
606 const JSON: &str = r#"{
607 "country_code": "NL",
608 "cdr_location": {
609 "country": "BE"
610 }
611}"#;
612
613 test::setup();
614 let (cdr, timezone, warnings) = parse_expect_v221(JSON);
615 let warnings = warnings.into_parts_vec();
616
617 assert_matches!(
618 timezone,
619 Some(Source::Inferred(chrono_tz::Tz::Europe__Brussels))
620 );
621 let elem_id = assert_matches!(
622 &*warnings,
623 [(
624 WarningKind::LocationCountryShouldBeAlpha3,
625 elem_id
626 )] => elem_id
627 );
628
629 assert_elem_path(cdr.as_element(), elem_id.unwrap(), "$.cdr_location.country");
630 }
631
632 #[test]
633 fn should_not_find_timezone_due_to_no_location() {
634 const JSON: &str = r#"{ "country_code": "BE" }"#;
635
636 test::setup();
637 let (cdr, source, warnings) = parse_expect_v221(JSON);
638 let warnings = warnings.into_parts_vec();
639 assert_matches!(source, None);
640
641 let elem_id = assert_matches!(&*warnings, [( WarningKind::NoLocation, elem_id)] => elem_id);
642
643 assert_elem_path(cdr.as_element(), elem_id.unwrap(), "$");
644 }
645
646 #[test]
647 fn should_not_find_timezone_due_to_no_country() {
648 const JSON: &str = r#"{
650 "country_code": "BELGIUM",
651 "cdr_location": {}
652}"#;
653
654 test::setup();
655 let (cdr, source, warnings) = parse_expect_v221(JSON);
656 let warnings = warnings.into_parts_vec();
657
658 assert_matches!(source, None);
659 let elem_id =
660 assert_matches!(&*warnings, [(WarningKind::NoLocationCountry, elem_id)] => elem_id);
661 assert_elem_path(cdr.as_element(), elem_id.unwrap(), "$.cdr_location");
662 }
663
664 #[test]
665 fn should_not_find_timezone_due_to_country_having_many_timezones() {
666 const JSON: &str = r#"{
667 "country_code": "BE",
668 "cdr_location": {
669 "country": "CHN"
670 }
671}"#;
672
673 test::setup();
674 let (cdr, source, warnings) = parse_expect_v221(JSON);
675 let warnings = warnings.into_parts_vec();
676 assert_matches!(source, None);
677
678 let elem_id = assert_matches!(
679 &*warnings,
680 [(WarningKind::CantInferTimezoneFromCountry("CN"), elem_id)] => elem_id
681 );
682
683 assert_elem_path(cdr.as_element(), elem_id.unwrap(), "$.cdr_location.country");
684 }
685
686 #[test]
687 fn should_fail_due_to_json_not_being_object() {
688 const JSON: &str = r#"["not_a_cdr"]"#;
689
690 test::setup();
691 let (cdr, source, warnings) = parse_expect_v221(JSON);
692 let warnings = warnings.into_parts_vec();
693 assert_matches!(source, None);
694
695 let elem_id = assert_matches!(
696 &*warnings,
697 [(WarningKind::ShouldBeAnObject, elem_id)] => elem_id
698 );
699 assert_elem_path(cdr.as_element(), elem_id.unwrap(), "$");
700 }
701
702 #[track_caller]
704 fn parse_expect_v221(
705 json: &str,
706 ) -> (
707 cdr::Versioned<'_>,
708 Option<Source>,
709 warning::Set<WarningKind>,
710 ) {
711 let cdr::ParseReport {
712 cdr,
713 unexpected_fields,
714 } = cdr::parse_with_version(json, Version::V221).unwrap();
715 test::assert_no_unexpected_fields(&unexpected_fields);
716
717 let (timezone_source, warnings) = find_or_infer(&cdr).into_parts();
718 (cdr, timezone_source, warnings)
719 }
720
721 #[track_caller]
723 fn parse_expect_v221_and_time_zone_field(
724 json: &str,
725 ) -> (cdr::Versioned<'_>, Source, warning::Set<WarningKind>) {
726 let cdr::ParseReport {
727 cdr,
728 unexpected_fields,
729 } = cdr::parse_with_version(json, Version::V221).unwrap();
730 test::assert_unexpected_fields(&unexpected_fields, &["$.cdr_location.time_zone"]);
731
732 let (timezone_source, warnings) = find_or_infer(&cdr).into_parts();
733 (cdr, timezone_source.unwrap(), warnings)
734 }
735
736 #[track_caller]
738 fn assert_elem_path(elem: &json::Element<'_>, elem_id: json::ElemId, path: &str) {
739 let elem_map = json::test::ElementMap::for_elem(elem);
740 let elem = elem_map.get(elem_id);
741
742 assert_eq!(elem.path(), path);
743 }
744}