use std::{borrow::Cow, fmt};
use chrono_tz::Tz;
use tracing::{debug, instrument};
use crate::{
cdr, country, from_warning_set_to, into_caveat,
json::{self, FieldsAsExt as _, FromJson as _},
warning::{self, GatherWarnings as _, OptionExt as _},
Caveat, IntoCaveat, ParseError, Verdict, VerdictExt, Version, Versioned, Warning,
};
#[derive(Debug)]
pub enum WarningKind {
CantInferTimezoneFromCountry(&'static str),
ContainsEscapeCodes,
Country(country::WarningKind),
Decode(json::decode::WarningKind),
Deserialize(ParseError),
InvalidLocationType,
InvalidTimezone,
InvalidTimezoneType,
LocationCountryShouldBeAlpha3,
NoLocationCountry,
NoLocation,
Parser(json::Error),
ShouldBeAnObject,
V221CdrHasLocationField,
}
from_warning_set_to!(country::WarningKind => WarningKind);
impl fmt::Display for WarningKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
WarningKind::CantInferTimezoneFromCountry(country_code) => write!(f, "Unable to infer timezone from the `location`'s `country`: `{country_code}`"),
WarningKind::ContainsEscapeCodes => f.write_str("The CDR location contains needless escape codes."),
WarningKind::Country(kind) => fmt::Display::fmt(kind, f),
WarningKind::Decode(warning) => fmt::Display::fmt(warning, f),
WarningKind::Deserialize(err) => fmt::Display::fmt(err, f),
WarningKind::InvalidLocationType => f.write_str("The CDR location is not a String."),
WarningKind::InvalidTimezone => f.write_str("The CDR location did not contain a valid IANA time-zone."),
WarningKind::InvalidTimezoneType => f.write_str("The CDR timezone is not a String."),
WarningKind::LocationCountryShouldBeAlpha3 => f.write_str("The `location.country` field should be an alpha-3 country code."),
WarningKind::NoLocationCountry => {
f.write_str("The CDR's `location` has no `country` element and so the timezone can't be inferred.")
},
WarningKind::NoLocation => {
f.write_str("The CDR has no `location` element and so the timezone can't be found or inferred.")
}
WarningKind::Parser(err) => fmt::Display::fmt(err, f),
WarningKind::ShouldBeAnObject => f.write_str("The CDR should be a JSON object"),
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."),
}
}
}
impl warning::Kind for WarningKind {
fn id(&self) -> Cow<'static, str> {
match self {
WarningKind::CantInferTimezoneFromCountry(_) => {
"cant_infer_timezone_from_country".into()
}
WarningKind::ContainsEscapeCodes => "contains_escape_codes".into(),
WarningKind::Decode(warning) => format!("decode.{}", warning.id()).into(),
WarningKind::Deserialize(err) => format!("deserialize.{err}").into(),
WarningKind::Country(warning) => format!("country.{}", warning.id()).into(),
WarningKind::InvalidLocationType => "invalid_location_type".into(),
WarningKind::InvalidTimezone => "invalid_timezone".into(),
WarningKind::InvalidTimezoneType => "invalid_timezone_type".into(),
WarningKind::LocationCountryShouldBeAlpha3 => {
"location_country_should_be_alpha3".into()
}
WarningKind::NoLocationCountry => "no_location_country".into(),
WarningKind::NoLocation => "no_location".into(),
WarningKind::Parser(err) => format!("parser.{err}").into(),
WarningKind::ShouldBeAnObject => "should_be_an_object".into(),
WarningKind::V221CdrHasLocationField => "v221_cdr_has_location_field".into(),
}
}
}
#[derive(Copy, Clone, Debug)]
pub enum Source {
Found(Tz),
Inferred(Tz),
}
into_caveat!(Source);
into_caveat!(Tz);
impl Source {
pub fn into_timezone(self) -> Tz {
match self {
Source::Found(tz) | Source::Inferred(tz) => tz,
}
}
}
pub fn find_or_infer(cdr: &cdr::Versioned<'_>) -> Caveat<Option<Source>, WarningKind> {
const LOCATION_FIELD_V211: &str = "location";
const LOCATION_FIELD_V221: &str = "cdr_location";
const TIMEZONE_FIELD: &str = "time_zone";
const COUNTRY_FIELD: &str = "country";
let mut warnings = warning::Set::new();
let cdr_root = cdr.as_element();
let Some(fields) = cdr_root.as_object_fields() else {
warnings.with_elem(WarningKind::ShouldBeAnObject, cdr_root);
return None.into_caveat(warnings);
};
let cdr_fields = fields.as_raw_map();
let v211_location = cdr_fields.get(LOCATION_FIELD_V211);
if cdr.version() == Version::V221 && v211_location.is_some() {
warnings.with_elem(WarningKind::V221CdrHasLocationField, cdr_root);
}
let Some(location_elem) = v211_location.or_else(|| cdr_fields.get(LOCATION_FIELD_V221)) else {
warnings.with_elem(WarningKind::NoLocation, cdr_root);
return None.into_caveat(warnings);
};
let json::Value::Object(fields) = location_elem.as_value() else {
warnings.with_elem(WarningKind::InvalidLocationType, cdr_root);
return None.into_caveat(warnings);
};
let location_fields = fields.as_raw_map();
debug!("Searching for time-zone in CDR");
let tz = location_fields.get(TIMEZONE_FIELD).and_then(|elem| {
let tz = try_parse_location_timezone(elem).ok_caveat();
tz.gather_warnings_into(&mut warnings)
});
if let Some(tz) = tz {
return Some(Source::Found(tz)).into_caveat(warnings);
}
debug!("No time-zone found in CDR; trying to infer time-zone from country");
let Some(country_elem) = location_fields.get(COUNTRY_FIELD) else {
warnings.with_elem(WarningKind::NoLocationCountry, location_elem);
return None.into_caveat(warnings);
};
let Some(timezone) = infer_timezone_from_location_country(country_elem)
.ok_caveat()
.gather_warnings_into(&mut warnings)
else {
return None.into_caveat(warnings);
};
Some(Source::Inferred(timezone)).into_caveat(warnings)
}
impl From<json::decode::WarningKind> for WarningKind {
fn from(warn_kind: json::decode::WarningKind) -> Self {
Self::Decode(warn_kind)
}
}
impl From<country::WarningKind> for WarningKind {
fn from(warn_kind: country::WarningKind) -> Self {
Self::Country(warn_kind)
}
}
fn try_parse_location_timezone(tz_elem: &json::Element<'_>) -> Verdict<Tz, WarningKind> {
let tz = tz_elem.as_value();
debug!(tz = %tz, "Raw time-zone found in CDR");
let mut warnings = warning::Set::new();
let Some(tz) = tz.as_raw_str() else {
warnings.with_elem(WarningKind::InvalidTimezoneType, tz_elem);
return Err(warnings);
};
let tz = tz
.decode_escapes(tz_elem)
.gather_warnings_into(&mut warnings);
if matches!(tz, Cow::Owned(_)) {
warnings.with_elem(WarningKind::ContainsEscapeCodes, tz_elem);
}
debug!(%tz, "Escaped time-zone found in CDR");
let Ok(tz) = tz.parse::<Tz>() else {
warnings.with_elem(WarningKind::InvalidTimezone, tz_elem);
return Err(warnings);
};
Ok(tz.into_caveat(warnings))
}
#[instrument(skip_all)]
fn infer_timezone_from_location_country(
country_elem: &json::Element<'_>,
) -> Verdict<Tz, WarningKind> {
let mut warnings = warning::Set::new();
let code_set = country::CodeSet::from_json(country_elem)?.gather_warnings_into(&mut warnings);
let country_code = match code_set {
country::CodeSet::Alpha2(code) => {
warnings.with_elem(WarningKind::LocationCountryShouldBeAlpha3, country_elem);
code
}
country::CodeSet::Alpha3(code) => code,
};
let tz = try_detect_timezone(country_code).exit_with_warning(warnings, || {
Warning::with_elem(
WarningKind::CantInferTimezoneFromCountry(country_code.into_str()),
country_elem,
)
})?;
Ok(tz)
}
#[instrument]
fn try_detect_timezone(country_code: country::Code) -> Option<Tz> {
let tz = match country_code {
country::Code::Ad => Tz::Europe__Andorra,
country::Code::Al => Tz::Europe__Tirane,
country::Code::At => Tz::Europe__Vienna,
country::Code::Ba => Tz::Europe__Sarajevo,
country::Code::Be => Tz::Europe__Brussels,
country::Code::Bg => Tz::Europe__Sofia,
country::Code::By => Tz::Europe__Minsk,
country::Code::Ch => Tz::Europe__Zurich,
country::Code::Cy => Tz::Europe__Nicosia,
country::Code::Cz => Tz::Europe__Prague,
country::Code::De => Tz::Europe__Berlin,
country::Code::Dk => Tz::Europe__Copenhagen,
country::Code::Ee => Tz::Europe__Tallinn,
country::Code::Es => Tz::Europe__Madrid,
country::Code::Fi => Tz::Europe__Helsinki,
country::Code::Fr => Tz::Europe__Paris,
country::Code::Gb => Tz::Europe__London,
country::Code::Gr => Tz::Europe__Athens,
country::Code::Hr => Tz::Europe__Zagreb,
country::Code::Hu => Tz::Europe__Budapest,
country::Code::Ie => Tz::Europe__Dublin,
country::Code::Is => Tz::Iceland,
country::Code::It => Tz::Europe__Rome,
country::Code::Li => Tz::Europe__Vaduz,
country::Code::Lt => Tz::Europe__Vilnius,
country::Code::Lu => Tz::Europe__Luxembourg,
country::Code::Lv => Tz::Europe__Riga,
country::Code::Mc => Tz::Europe__Monaco,
country::Code::Md => Tz::Europe__Chisinau,
country::Code::Me => Tz::Europe__Podgorica,
country::Code::Mk => Tz::Europe__Skopje,
country::Code::Mt => Tz::Europe__Malta,
country::Code::Nl => Tz::Europe__Amsterdam,
country::Code::No => Tz::Europe__Oslo,
country::Code::Pl => Tz::Europe__Warsaw,
country::Code::Pt => Tz::Europe__Lisbon,
country::Code::Ro => Tz::Europe__Bucharest,
country::Code::Rs => Tz::Europe__Belgrade,
country::Code::Ru => Tz::Europe__Moscow,
country::Code::Se => Tz::Europe__Stockholm,
country::Code::Si => Tz::Europe__Ljubljana,
country::Code::Sk => Tz::Europe__Bratislava,
country::Code::Sm => Tz::Europe__San_Marino,
country::Code::Tr => Tz::Turkey,
country::Code::Ua => Tz::Europe__Kiev,
_ => return None,
};
debug!(%tz, "time-zone detected");
Some(tz)
}
#[cfg(test)]
pub mod test {
#![allow(clippy::missing_panics_doc, reason = "tests are allowed to panic")]
#![allow(clippy::panic, reason = "tests are allowed panic")]
use std::collections::BTreeMap;
use crate::{cdr, json, warning};
use super::{Source, WarningKind};
#[derive(serde::Deserialize)]
pub struct FindOrInferExpect {
pub timezone: Option<String>,
pub warnings: BTreeMap<String, Vec<String>>,
}
#[track_caller]
pub(crate) fn assert_find_or_infer_outcome(
cdr: &cdr::Versioned<'_>,
timezone: Source,
expect: Option<&FindOrInferExpect>,
warnings: &warning::Set<WarningKind>,
) {
let elem_map = json::test::ElementMap::for_elem(cdr.as_element());
let Some(expect) = expect else {
return;
};
for warning in warnings {
let path = elem_map.path(warning.elem_id().unwrap());
let id = warning.id();
let path_string = path.to_string();
if let Some(timezone_expected) = &expect.timezone {
assert_eq!(timezone_expected, &timezone.into_timezone().to_string());
}
let Some(expected_warnings) = expect.warnings.get(&*path_string) else {
panic!(
"There are no warnings expected for path: `{path_string}`; warnings: {:?}",
warnings.iter().map(crate::Warning::id).collect::<Vec<_>>()
);
};
assert!(
expected_warnings.iter().any(|w| warning.id() == *w),
"Warning `{id}` not expected for path: `{path_string}`",
);
}
}
}
#[cfg(test)]
mod test_find_or_infer {
use assert_matches::assert_matches;
use crate::{cdr, json, test, timezone::WarningKind, warning, Version};
use super::{find_or_infer, Source};
#[test]
fn should_find_timezone() {
const JSON: &str = r#"{
"country_code": "NL",
"cdr_location": {
"time_zone": "Europe/Amsterdam"
}
}"#;
test::setup();
let (_cdr, timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON);
assert_matches!(timezone, Source::Found(chrono_tz::Tz::Europe__Amsterdam));
assert_matches!(*warnings, []);
}
#[test]
fn should_find_timezone_but_warn_about_use_of_location_for_v221_cdr() {
const JSON: &str = r#"{
"country_code": "NL",
"location": {
"time_zone": "Europe/Amsterdam"
}
}"#;
test::setup();
let cdr::ParseReport {
cdr,
unexpected_fields,
} = cdr::parse_with_version(JSON, Version::V221).unwrap();
test::assert_unexpected_fields(&unexpected_fields, &["$.location", "$.location.time_zone"]);
let (timezone_source, warnings) = find_or_infer(&cdr).into_parts();
let warnings = warnings.into_kind_vec();
let timezone_source = timezone_source.unwrap();
assert_matches!(
timezone_source,
Source::Found(chrono_tz::Tz::Europe__Amsterdam)
);
assert_matches!(*warnings, [WarningKind::V221CdrHasLocationField]);
}
#[test]
fn should_find_timezone_without_cdr_country() {
const JSON: &str = r#"{
"cdr_location": {
"time_zone": "Europe/Amsterdam"
}
}"#;
test::setup();
let (_cdr, timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON);
assert_matches!(timezone, Source::Found(chrono_tz::Tz::Europe__Amsterdam));
assert_matches!(*warnings, []);
}
#[test]
fn should_infer_timezone_and_warn_about_invalid_type() {
const JSON: &str = r#"{
"country_code": "NL",
"cdr_location": {
"time_zone": null,
"country": "BEL"
}
}"#;
test::setup();
let (_cdr, timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON);
let warnings = warnings.into_kind_vec();
assert_matches!(timezone, Source::Inferred(chrono_tz::Tz::Europe__Brussels));
assert_matches!(*warnings, [WarningKind::InvalidTimezoneType]);
}
#[test]
fn should_find_timezone_and_warn_about_invalid_type() {
const JSON: &str = r#"{
"country_code": "NL",
"cdr_location": {
"time_zone": "Europe/Hamsterdam",
"country": "BEL"
}
}"#;
test::setup();
let (_cdr, timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON);
let warnings = warnings.into_kind_vec();
assert_matches!(timezone, Source::Inferred(chrono_tz::Tz::Europe__Brussels));
assert_matches!(*warnings, [WarningKind::InvalidTimezone]);
}
#[test]
fn should_find_timezone_and_warn_about_escape_codes_and_invalid_type() {
const JSON: &str = r#"{
"country_code": "NL",
"cdr_location": {
"time_zone": "Europe\/Hamsterdam",
"country": "BEL"
}
}"#;
test::setup();
let (cdr, timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON);
let warnings = warnings.into_parts_vec();
assert_matches!(timezone, Source::Inferred(chrono_tz::Tz::Europe__Brussels));
let elem_id = assert_matches!(
&*warnings,
[
(WarningKind::ContainsEscapeCodes, elem_id),
(WarningKind::InvalidTimezone, _)
] => elem_id
);
let cdr_elem = cdr.into_element();
let elem_map = json::test::ElementMap::for_elem(&cdr_elem);
let elem = elem_map.get(elem_id.unwrap());
assert_eq!(elem.path(), "$.cdr_location.time_zone");
}
#[test]
fn should_find_timezone_and_warn_about_escape_codes() {
const JSON: &str = r#"{
"country_code": "NL",
"cdr_location": {
"time_zone": "Europe\/Amsterdam",
"country": "BEL"
}
}"#;
test::setup();
let (cdr, timezone, warnings) = parse_expect_v221_and_time_zone_field(JSON);
let warnings = warnings.into_parts_vec();
assert_matches!(timezone, Source::Found(chrono_tz::Tz::Europe__Amsterdam));
let elem_id = assert_matches!(
&*warnings,
[( WarningKind::ContainsEscapeCodes, elem_id )] => elem_id
);
assert_elem_path(
cdr.as_element(),
elem_id.unwrap(),
"$.cdr_location.time_zone",
);
}
#[test]
fn should_infer_timezone_from_location_country() {
const JSON: &str = r#"{
"country_code": "NL",
"cdr_location": {
"country": "BEL"
}
}"#;
test::setup();
let (_cdr, timezone, warnings) = parse_expect_v221(JSON);
assert_matches!(
timezone,
Some(Source::Inferred(chrono_tz::Tz::Europe__Brussels))
);
assert_matches!(*warnings, []);
}
#[test]
fn should_find_timezone_but_report_alpha2_location_country_code() {
const JSON: &str = r#"{
"country_code": "NL",
"cdr_location": {
"country": "BE"
}
}"#;
test::setup();
let (cdr, timezone, warnings) = parse_expect_v221(JSON);
let warnings = warnings.into_parts_vec();
assert_matches!(
timezone,
Some(Source::Inferred(chrono_tz::Tz::Europe__Brussels))
);
let elem_id = assert_matches!(
&*warnings,
[(
WarningKind::LocationCountryShouldBeAlpha3,
elem_id
)] => elem_id
);
assert_elem_path(cdr.as_element(), elem_id.unwrap(), "$.cdr_location.country");
}
#[test]
fn should_not_find_timezone_due_to_no_location() {
const JSON: &str = r#"{ "country_code": "BE" }"#;
test::setup();
let (cdr, source, warnings) = parse_expect_v221(JSON);
let warnings = warnings.into_parts_vec();
assert_matches!(source, None);
let elem_id = assert_matches!(&*warnings, [( WarningKind::NoLocation, elem_id)] => elem_id);
assert_elem_path(cdr.as_element(), elem_id.unwrap(), "$");
}
#[test]
fn should_not_find_timezone_due_to_no_country() {
const JSON: &str = r#"{
"country_code": "BELGIUM",
"cdr_location": {}
}"#;
test::setup();
let (cdr, source, warnings) = parse_expect_v221(JSON);
let warnings = warnings.into_parts_vec();
assert_matches!(source, None);
let elem_id =
assert_matches!(&*warnings, [(WarningKind::NoLocationCountry, elem_id)] => elem_id);
assert_elem_path(cdr.as_element(), elem_id.unwrap(), "$.cdr_location");
}
#[test]
fn should_not_find_timezone_due_to_country_having_many_timezones() {
const JSON: &str = r#"{
"country_code": "BE",
"cdr_location": {
"country": "CHN"
}
}"#;
test::setup();
let (cdr, source, warnings) = parse_expect_v221(JSON);
let warnings = warnings.into_parts_vec();
assert_matches!(source, None);
let elem_id = assert_matches!(
&*warnings,
[(WarningKind::CantInferTimezoneFromCountry("CN"), elem_id)] => elem_id
);
assert_elem_path(cdr.as_element(), elem_id.unwrap(), "$.cdr_location.country");
}
#[test]
fn should_fail_due_to_json_not_being_object() {
const JSON: &str = r#"["not_a_cdr"]"#;
test::setup();
let (cdr, source, warnings) = parse_expect_v221(JSON);
let warnings = warnings.into_parts_vec();
assert_matches!(source, None);
let elem_id = assert_matches!(
&*warnings,
[(WarningKind::ShouldBeAnObject, elem_id)] => elem_id
);
assert_elem_path(cdr.as_element(), elem_id.unwrap(), "$");
}
#[track_caller]
fn parse_expect_v221(
json: &str,
) -> (
cdr::Versioned<'_>,
Option<Source>,
warning::Set<WarningKind>,
) {
let cdr::ParseReport {
cdr,
unexpected_fields,
} = cdr::parse_with_version(json, Version::V221).unwrap();
test::assert_no_unexpected_fields(&unexpected_fields);
let (timezone_source, warnings) = find_or_infer(&cdr).into_parts();
(cdr, timezone_source, warnings)
}
#[track_caller]
fn parse_expect_v221_and_time_zone_field(
json: &str,
) -> (cdr::Versioned<'_>, Source, warning::Set<WarningKind>) {
let cdr::ParseReport {
cdr,
unexpected_fields,
} = cdr::parse_with_version(json, Version::V221).unwrap();
test::assert_unexpected_fields(&unexpected_fields, &["$.cdr_location.time_zone"]);
let (timezone_source, warnings) = find_or_infer(&cdr).into_parts();
(cdr, timezone_source.unwrap(), warnings)
}
#[track_caller]
fn assert_elem_path(elem: &json::Element<'_>, elem_id: json::ElemId, path: &str) {
let elem_map = json::test::ElementMap::for_elem(elem);
let elem = elem_map.get(elem_id);
assert_eq!(elem.path(), path);
}
}