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