1#[cfg(test)]
4pub mod test;
5
6#[cfg(test)]
7mod test_find_or_infer;
8
9use std::{borrow::Cow, fmt};
10
11use chrono_tz::Tz;
12use tracing::{debug, instrument};
13
14use crate::{
15 cdr, country, from_warning_all,
16 json::{self, FieldsAsExt as _, FromJson as _},
17 warning::{self, GatherWarnings as _, WithElement as _},
18 IntoCaveat as _, Verdict, Version, Versioned as _,
19};
20
21#[derive(Debug)]
23pub enum Warning {
24 CantInferTimezoneFromCountry(&'static str),
26
27 ContainsEscapeCodes,
29
30 Country(country::Warning),
32
33 Decode(json::decode::Warning),
35
36 InvalidLocationType,
38
39 InvalidTimezone,
43
44 InvalidTimezoneType,
46
47 LocationCountryShouldBeAlpha3,
51
52 NoLocationCountry,
54
55 NoLocation,
57
58 ShouldBeAnObject,
60
61 V221CdrHasLocationField,
63}
64
65from_warning_all!(
66 country::Warning => Warning::Country,
67 json::decode::Warning => Warning::Decode
68);
69
70impl fmt::Display for Warning {
71 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72 match self {
73 Self::CantInferTimezoneFromCountry(country_code) => write!(f, "Unable to infer timezone from the `location`'s `country`: `{country_code}`"),
74 Self::ContainsEscapeCodes => f.write_str("The CDR location contains needless escape codes."),
75 Self::Country(kind) => fmt::Display::fmt(kind, f),
76 Self::Decode(warning) => fmt::Display::fmt(warning, f),
77 Self::InvalidLocationType => f.write_str("The CDR location is not a String."),
78 Self::InvalidTimezone => f.write_str("The CDR location did not contain a valid IANA time-zone."),
79 Self::InvalidTimezoneType => f.write_str("The CDR timezone is not a String."),
80 Self::LocationCountryShouldBeAlpha3 => f.write_str("The `location.country` field should be an alpha-3 country code."),
81 Self::NoLocationCountry => {
82 f.write_str("The CDR's `location` has no `country` element and so the timezone can't be inferred.")
83 },
84 Self::NoLocation => {
85 f.write_str("The CDR has no `location` element and so the timezone can't be found or inferred.")
86 }
87 Self::ShouldBeAnObject => f.write_str("The CDR should be a JSON object"),
88 Self::V221CdrHasLocationField => f.write_str("the v2.2.1 CDR contains a `location` field but the v2.2.1 spec defines a `cdr_location` field."),
89
90 }
91 }
92}
93
94impl crate::Warning for Warning {
95 fn id(&self) -> warning::Id {
96 match self {
97 Self::CantInferTimezoneFromCountry(_) => {
98 warning::Id::from_static("cant_infer_timezone_from_country")
99 }
100 Self::ContainsEscapeCodes => warning::Id::from_static("contains_escape_codes"),
101 Self::Decode(warning) => warning.id(),
102 Self::Country(warning) => warning.id(),
103 Self::InvalidLocationType => warning::Id::from_static("invalid_location_type"),
104 Self::InvalidTimezone => warning::Id::from_static("invalid_timezone"),
105 Self::InvalidTimezoneType => warning::Id::from_static("invalid_timezone_type"),
106 Self::LocationCountryShouldBeAlpha3 => {
107 warning::Id::from_static("location_country_should_be_alpha3")
108 }
109 Self::NoLocationCountry => warning::Id::from_static("no_location_country"),
110 Self::NoLocation => warning::Id::from_static("no_location"),
111 Self::ShouldBeAnObject => warning::Id::from_static("should_be_an_object"),
112 Self::V221CdrHasLocationField => {
113 warning::Id::from_static("v221_cdr_has_location_field")
114 }
115 }
116 }
117}
118
119#[derive(Copy, Clone, Debug)]
121pub enum Source {
122 Found(Tz),
124
125 Inferred(Tz),
127}
128
129impl Source {
130 pub fn into_timezone(self) -> Tz {
132 match self {
133 Source::Found(tz) | Source::Inferred(tz) => tz,
134 }
135 }
136}
137
138pub fn find_or_infer(cdr: &cdr::Versioned<'_>) -> Verdict<Source, Warning> {
152 const LOCATION_FIELD_V211: &str = "location";
153 const LOCATION_FIELD_V221: &str = "cdr_location";
154 const TIMEZONE_FIELD: &str = "time_zone";
155 const COUNTRY_FIELD: &str = "country";
156
157 let mut warnings = warning::Set::new();
158
159 let cdr_root = cdr.as_element();
160 let Some(fields) = cdr_root.as_object_fields() else {
161 return warnings.bail(cdr_root, Warning::ShouldBeAnObject);
162 };
163
164 let cdr_fields = fields.as_raw_map();
165
166 let v211_location = cdr_fields.get(LOCATION_FIELD_V211);
167
168 if cdr.version() == Version::V221 && v211_location.is_some() {
173 warnings.insert(cdr_root, Warning::V221CdrHasLocationField);
174 }
175
176 let Some(location_elem) = v211_location.or_else(|| cdr_fields.get(LOCATION_FIELD_V221)) else {
183 return warnings.bail(cdr_root, Warning::NoLocation);
184 };
185
186 let json::Value::Object(fields) = location_elem.as_value() else {
187 return warnings.bail(cdr_root, Warning::InvalidLocationType);
188 };
189
190 let location_fields = fields.as_raw_map();
191
192 debug!("Searching for time-zone in CDR");
193
194 let tz = location_fields
199 .get(TIMEZONE_FIELD)
200 .map(|elem| try_parse_location_timezone(elem).gather_warnings_into(&mut warnings))
201 .transpose();
202
203 let tz = tz
207 .map_err(|err_set| {
208 warnings.deescalate_error(err_set);
209 })
210 .ok()
211 .flatten();
212
213 if let Some(tz) = tz {
214 return Ok(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 return warnings.bail(location_elem, Warning::NoLocationCountry);
226 };
227 let tz =
228 infer_timezone_from_location_country(country_elem).gather_warnings_into(&mut warnings)?;
229
230 Ok(Source::Inferred(tz).into_caveat(warnings))
231}
232
233fn try_parse_location_timezone(tz_elem: &json::Element<'_>) -> Verdict<Tz, Warning> {
235 let tz = tz_elem.as_value();
236 debug!(tz = %tz, "Raw time-zone found in CDR");
237
238 let mut warnings = warning::Set::new();
239 let Some(tz) = tz.to_raw_str() else {
240 return warnings.bail(tz_elem, Warning::InvalidTimezoneType);
241 };
242
243 let tz = tz
244 .decode_escapes()
245 .with_element(tz_elem)
246 .gather_warnings_into(&mut warnings);
247
248 if matches!(tz, Cow::Owned(_)) {
249 warnings.insert(tz_elem, Warning::ContainsEscapeCodes);
250 }
251
252 debug!(%tz, "Escaped time-zone found in CDR");
253
254 let Ok(tz) = tz.parse::<Tz>() else {
255 return warnings.bail(tz_elem, Warning::InvalidTimezone);
256 };
257
258 Ok(tz.into_caveat(warnings))
259}
260
261#[instrument(skip_all)]
263fn infer_timezone_from_location_country(country_elem: &json::Element<'_>) -> Verdict<Tz, Warning> {
264 let mut warnings = warning::Set::new();
265 let code_set = country::CodeSet::from_json(country_elem)?.gather_warnings_into(&mut warnings);
266
267 let country_code = match code_set {
271 country::CodeSet::Alpha2(code) => {
272 warnings.insert(country_elem, Warning::LocationCountryShouldBeAlpha3);
273 code
274 }
275 country::CodeSet::Alpha3(code) => code,
276 };
277 let Some(tz) = try_detect_timezone(country_code) else {
278 return warnings.bail(
279 country_elem,
280 Warning::CantInferTimezoneFromCountry(country_code.into_alpha_2_str()),
281 );
282 };
283
284 Ok(tz.into_caveat(warnings))
285}
286
287#[instrument]
295#[expect(
296 clippy::wildcard_enum_match_arm,
297 reason = "There are many `Code` variants that do not map to a timezone."
298)]
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}