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 _},
18 IntoCaveat as _, ParseError, 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 Deserialize(ParseError),
38
39 InvalidLocationType,
41
42 InvalidTimezone,
46
47 InvalidTimezoneType,
49
50 LocationCountryShouldBeAlpha3,
54
55 NoLocationCountry,
57
58 NoLocation,
60
61 Parser(json::Error),
63
64 ShouldBeAnObject,
66
67 V221CdrHasLocationField,
69}
70
71from_warning_all!(
72 country::Warning => Warning::Country,
73 json::decode::Warning => Warning::Decode
74);
75
76impl fmt::Display for Warning {
77 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78 match self {
79 Self::CantInferTimezoneFromCountry(country_code) => write!(f, "Unable to infer timezone from the `location`'s `country`: `{country_code}`"),
80 Self::ContainsEscapeCodes => f.write_str("The CDR location contains needless escape codes."),
81 Self::Country(kind) => fmt::Display::fmt(kind, f),
82 Self::Decode(warning) => fmt::Display::fmt(warning, f),
83 Self::Deserialize(err) => fmt::Display::fmt(err, f),
84 Self::InvalidLocationType => f.write_str("The CDR location is not a String."),
85 Self::InvalidTimezone => f.write_str("The CDR location did not contain a valid IANA time-zone."),
86 Self::InvalidTimezoneType => f.write_str("The CDR timezone is not a String."),
87 Self::LocationCountryShouldBeAlpha3 => f.write_str("The `location.country` field should be an alpha-3 country code."),
88 Self::NoLocationCountry => {
89 f.write_str("The CDR's `location` has no `country` element and so the timezone can't be inferred.")
90 },
91 Self::NoLocation => {
92 f.write_str("The CDR has no `location` element and so the timezone can't be found or inferred.")
93 }
94 Self::Parser(err) => fmt::Display::fmt(err, f),
95 Self::ShouldBeAnObject => f.write_str("The CDR should be a JSON object"),
96 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."),
97
98 }
99 }
100}
101
102impl crate::Warning for Warning {
103 fn id(&self) -> warning::Id {
104 match self {
105 Self::CantInferTimezoneFromCountry(_) => {
106 warning::Id::from_static("cant_infer_timezone_from_country")
107 }
108 Self::ContainsEscapeCodes => warning::Id::from_static("contains_escape_codes"),
109 Self::Decode(warning) => warning.id(),
110 Self::Deserialize(err) => warning::Id::from_string(format!("deserialize.{err}")),
111 Self::Country(warning) => warning.id(),
112 Self::InvalidLocationType => warning::Id::from_static("invalid_location_type"),
113 Self::InvalidTimezone => warning::Id::from_static("invalid_timezone"),
114 Self::InvalidTimezoneType => warning::Id::from_static("invalid_timezone_type"),
115 Self::LocationCountryShouldBeAlpha3 => {
116 warning::Id::from_static("location_country_should_be_alpha3")
117 }
118 Self::NoLocationCountry => warning::Id::from_static("no_location_country"),
119 Self::NoLocation => warning::Id::from_static("no_location"),
120 Self::Parser(_err) => warning::Id::from_static("parser_error"),
121 Self::ShouldBeAnObject => warning::Id::from_static("should_be_an_object"),
122 Self::V221CdrHasLocationField => {
123 warning::Id::from_static("v221_cdr_has_location_field")
124 }
125 }
126 }
127}
128
129#[derive(Copy, Clone, Debug)]
131pub enum Source {
132 Found(Tz),
134
135 Inferred(Tz),
137}
138
139impl Source {
140 pub fn into_timezone(self) -> Tz {
142 match self {
143 Source::Found(tz) | Source::Inferred(tz) => tz,
144 }
145 }
146}
147
148pub fn find_or_infer(cdr: &cdr::Versioned<'_>) -> Verdict<Source, Warning> {
162 const LOCATION_FIELD_V211: &str = "location";
163 const LOCATION_FIELD_V221: &str = "cdr_location";
164 const TIMEZONE_FIELD: &str = "time_zone";
165 const COUNTRY_FIELD: &str = "country";
166
167 let mut warnings = warning::Set::new();
168
169 let cdr_root = cdr.as_element();
170 let Some(fields) = cdr_root.as_object_fields() else {
171 return warnings.bail(Warning::ShouldBeAnObject, cdr_root);
172 };
173
174 let cdr_fields = fields.as_raw_map();
175
176 let v211_location = cdr_fields.get(LOCATION_FIELD_V211);
177
178 if cdr.version() == Version::V221 && v211_location.is_some() {
183 warnings.insert(Warning::V221CdrHasLocationField, cdr_root);
184 }
185
186 let Some(location_elem) = v211_location.or_else(|| cdr_fields.get(LOCATION_FIELD_V221)) else {
193 return warnings.bail(Warning::NoLocation, cdr_root);
194 };
195
196 let json::Value::Object(fields) = location_elem.as_value() else {
197 return warnings.bail(Warning::InvalidLocationType, cdr_root);
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
209 .get(TIMEZONE_FIELD)
210 .map(|elem| try_parse_location_timezone(elem).gather_warnings_into(&mut warnings))
211 .transpose();
212
213 let tz = tz
217 .map_err(|err_set| {
218 warnings.deescalate_error(err_set);
219 })
220 .ok()
221 .flatten();
222
223 if let Some(tz) = tz {
224 return Ok(Source::Found(tz).into_caveat(warnings));
225 }
226
227 debug!("No time-zone found in CDR; trying to infer time-zone from country");
228
229 let Some(country_elem) = location_fields.get(COUNTRY_FIELD) else {
231 return warnings.bail(Warning::NoLocationCountry, location_elem);
236 };
237 let tz =
238 infer_timezone_from_location_country(country_elem).gather_warnings_into(&mut warnings)?;
239
240 Ok(Source::Inferred(tz).into_caveat(warnings))
241}
242
243fn try_parse_location_timezone(tz_elem: &json::Element<'_>) -> Verdict<Tz, Warning> {
245 let tz = tz_elem.as_value();
246 debug!(tz = %tz, "Raw time-zone found in CDR");
247
248 let mut warnings = warning::Set::new();
249 let Some(tz) = tz.to_raw_str() else {
250 return warnings.bail(Warning::InvalidTimezoneType, tz_elem);
251 };
252
253 let tz = tz
254 .decode_escapes(tz_elem)
255 .gather_warnings_into(&mut warnings);
256
257 if matches!(tz, Cow::Owned(_)) {
258 warnings.insert(Warning::ContainsEscapeCodes, tz_elem);
259 }
260
261 debug!(%tz, "Escaped time-zone found in CDR");
262
263 let Ok(tz) = tz.parse::<Tz>() else {
264 return warnings.bail(Warning::InvalidTimezone, tz_elem);
265 };
266
267 Ok(tz.into_caveat(warnings))
268}
269
270#[instrument(skip_all)]
272fn infer_timezone_from_location_country(country_elem: &json::Element<'_>) -> Verdict<Tz, Warning> {
273 let mut warnings = warning::Set::new();
274 let code_set = country::CodeSet::from_json(country_elem)?.gather_warnings_into(&mut warnings);
275
276 let country_code = match code_set {
280 country::CodeSet::Alpha2(code) => {
281 warnings.insert(Warning::LocationCountryShouldBeAlpha3, country_elem);
282 code
283 }
284 country::CodeSet::Alpha3(code) => code,
285 };
286 let Some(tz) = try_detect_timezone(country_code) else {
287 return warnings.bail(
288 Warning::CantInferTimezoneFromCountry(country_code.into_alpha_2_str()),
289 country_elem,
290 );
291 };
292
293 Ok(tz.into_caveat(warnings))
294}
295
296#[instrument]
304#[expect(
305 clippy::wildcard_enum_match_arm,
306 reason = "There are many `Code` variants that do not map to a timezone."
307)]
308fn try_detect_timezone(country_code: country::Code) -> Option<Tz> {
309 let tz = match country_code {
310 country::Code::Ad => Tz::Europe__Andorra,
311 country::Code::Al => Tz::Europe__Tirane,
312 country::Code::At => Tz::Europe__Vienna,
313 country::Code::Ba => Tz::Europe__Sarajevo,
314 country::Code::Be => Tz::Europe__Brussels,
315 country::Code::Bg => Tz::Europe__Sofia,
316 country::Code::By => Tz::Europe__Minsk,
317 country::Code::Ch => Tz::Europe__Zurich,
318 country::Code::Cy => Tz::Europe__Nicosia,
319 country::Code::Cz => Tz::Europe__Prague,
320 country::Code::De => Tz::Europe__Berlin,
321 country::Code::Dk => Tz::Europe__Copenhagen,
322 country::Code::Ee => Tz::Europe__Tallinn,
323 country::Code::Es => Tz::Europe__Madrid,
324 country::Code::Fi => Tz::Europe__Helsinki,
325 country::Code::Fr => Tz::Europe__Paris,
326 country::Code::Gb => Tz::Europe__London,
327 country::Code::Gr => Tz::Europe__Athens,
328 country::Code::Hr => Tz::Europe__Zagreb,
329 country::Code::Hu => Tz::Europe__Budapest,
330 country::Code::Ie => Tz::Europe__Dublin,
331 country::Code::Is => Tz::Iceland,
332 country::Code::It => Tz::Europe__Rome,
333 country::Code::Li => Tz::Europe__Vaduz,
334 country::Code::Lt => Tz::Europe__Vilnius,
335 country::Code::Lu => Tz::Europe__Luxembourg,
336 country::Code::Lv => Tz::Europe__Riga,
337 country::Code::Mc => Tz::Europe__Monaco,
338 country::Code::Md => Tz::Europe__Chisinau,
339 country::Code::Me => Tz::Europe__Podgorica,
340 country::Code::Mk => Tz::Europe__Skopje,
341 country::Code::Mt => Tz::Europe__Malta,
342 country::Code::Nl => Tz::Europe__Amsterdam,
343 country::Code::No => Tz::Europe__Oslo,
344 country::Code::Pl => Tz::Europe__Warsaw,
345 country::Code::Pt => Tz::Europe__Lisbon,
346 country::Code::Ro => Tz::Europe__Bucharest,
347 country::Code::Rs => Tz::Europe__Belgrade,
348 country::Code::Ru => Tz::Europe__Moscow,
349 country::Code::Se => Tz::Europe__Stockholm,
350 country::Code::Si => Tz::Europe__Ljubljana,
351 country::Code::Sk => Tz::Europe__Bratislava,
352 country::Code::Sm => Tz::Europe__San_Marino,
353 country::Code::Tr => Tz::Turkey,
354 country::Code::Ua => Tz::Europe__Kiev,
355 _ => return None,
356 };
357
358 debug!(%tz, "time-zone detected");
359
360 Some(tz)
361}