1#[cfg(test)]
5pub mod test;
6
7#[cfg(test)]
8mod test_find_or_infer;
9
10use std::{borrow::Cow, fmt};
11
12use chrono_tz::Tz;
13use tracing::{debug, instrument};
14
15use crate::{
16 cdr, country, from_warning_all, json,
17 schema::{self, FromSchema as _, HasElement as _},
18 warning::{self, GatherWarnings as _, WithElement as _},
19 IntoCaveat as _, Verdict,
20};
21
22#[derive(Debug)]
24pub enum Warning {
25 CantInferTimezoneFromCountry(&'static str),
27
28 ContainsEscapeCodes,
30
31 Country(country::Warning),
33
34 Decode(json::decode::Warning),
36
37 InvalidTimezone,
41
42 LocationCountryShouldBeAlpha3,
46
47 NoLocationCountry,
49
50 NoLocation,
52
53 V221CdrHasLocationField,
55}
56
57from_warning_all!(
58 country::Warning => Warning::Country,
59 json::decode::Warning => Warning::Decode
60);
61
62impl fmt::Display for Warning {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64 match self {
65 Self::CantInferTimezoneFromCountry(country_code) => write!(f, "Unable to infer timezone from the `location`'s `country`: `{country_code}`"),
66 Self::ContainsEscapeCodes => f.write_str("The CDR location contains needless escape codes."),
67 Self::Country(kind) => fmt::Display::fmt(kind, f),
68 Self::Decode(warning) => fmt::Display::fmt(warning, f),
69 Self::InvalidTimezone => f.write_str("The CDR location did not contain a valid IANA time-zone."),
70 Self::LocationCountryShouldBeAlpha3 => f.write_str("The `location.country` field should be an alpha-3 country code."),
71 Self::NoLocationCountry => {
72 f.write_str("The CDR's `location` has no `country` element and so the timezone can't be inferred.")
73 },
74 Self::NoLocation => {
75 f.write_str("The CDR has no `location` element and so the timezone can't be found or inferred.")
76 }
77 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."),
78
79 }
80 }
81}
82
83impl crate::Warning for Warning {
84 fn id(&self) -> warning::Id {
85 match self {
86 Self::CantInferTimezoneFromCountry(_) => {
87 warning::Id::from_static("cant_infer_timezone_from_country")
88 }
89 Self::ContainsEscapeCodes => warning::Id::from_static("contains_escape_codes"),
90 Self::Decode(warning) => warning.id(),
91 Self::Country(warning) => warning.id(),
92 Self::InvalidTimezone => warning::Id::from_static("invalid_timezone"),
93 Self::LocationCountryShouldBeAlpha3 => {
94 warning::Id::from_static("location_country_should_be_alpha3")
95 }
96 Self::NoLocationCountry => warning::Id::from_static("no_location_country"),
97 Self::NoLocation => warning::Id::from_static("no_location"),
98 Self::V221CdrHasLocationField => {
99 warning::Id::from_static("v221_cdr_has_location_field")
100 }
101 }
102 }
103}
104
105#[derive(Copy, Clone, Debug)]
107pub enum Source {
108 Found(Tz),
110
111 Inferred(Tz),
113}
114
115impl Source {
116 pub fn into_timezone(self) -> Tz {
118 match self {
119 Source::Found(tz) | Source::Inferred(tz) => tz,
120 }
121 }
122}
123
124pub fn find_or_infer(cdr: &cdr::Versioned<'_>) -> Verdict<Source, Warning> {
138 let mut warnings = warning::Set::new();
139
140 let location = location(cdr)?.gather_warnings_into(&mut warnings);
141
142 debug!("Searching for time-zone in CDR");
143
144 let tz = match &location.time_zone {
150 schema::Integrity::Ok(Some(tz)) => try_parse_location_timezone(tz),
151 schema::Integrity::Ok(None) | schema::Integrity::Missing(_) | schema::Integrity::Err(_) => {
154 return infer_from_country(&location, warnings)
155 }
156 };
157
158 let tz = match tz {
161 Ok(tz) => Some(tz.gather_warnings_into(&mut warnings)),
162 Err(err_set) => {
163 warnings.deescalate_error(err_set);
164 None
165 }
166 };
167
168 let Some(tz) = tz else {
169 return infer_from_country(&location, warnings);
170 };
171
172 Ok(Source::Found(tz).into_caveat(warnings))
173}
174
175struct Location<'a, 'buf> {
178 time_zone: &'a schema::Integrity<Option<schema::Str<'buf>>>,
179 country: &'a schema::Integrity<schema::Str<'buf>>,
180}
181
182fn location<'a, 'buf>(cdr: &'a cdr::Versioned<'buf>) -> Verdict<Location<'a, 'buf>, Warning> {
192 let mut warnings = warning::Set::new();
193
194 let (time_zone, country) = match cdr.schema() {
195 cdr::Version::V211(ir) => match &ir.location {
196 schema::Integrity::Ok(location) => (&location.time_zone, &location.country),
197 schema::Integrity::Missing(elem) | schema::Integrity::Err(elem) => {
198 return warnings.bail_at(elem.clone(), Warning::NoLocation)
199 }
200 },
201 cdr::Version::V221(ir) => match (&ir.location, &ir.cdr_location) {
204 (schema::Integrity::Ok(location), _) => {
205 warnings.insert(cdr.as_element(), Warning::V221CdrHasLocationField);
208
209 (&location.time_zone, &location.country)
210 }
211 (_, schema::Integrity::Ok(location)) => (&location.time_zone, &location.country),
212 (_, schema::Integrity::Missing(elem) | schema::Integrity::Err(elem)) => {
213 return warnings.bail_at(elem.clone(), Warning::NoLocation)
214 }
215 },
216 };
217
218 Ok(Location { time_zone, country }.into_caveat(warnings))
219}
220
221fn infer_from_country(
223 location: &Location<'_, '_>,
224 mut warnings: warning::Set<Warning>,
225) -> Verdict<Source, Warning> {
226 debug!("No time-zone found in CDR; trying to infer time-zone from country");
227
228 let country = match &location.country {
234 schema::Integrity::Ok(country) => country,
235 schema::Integrity::Missing(elem) | schema::Integrity::Err(elem) => {
236 return warnings.bail_at(elem.clone(), Warning::NoLocationCountry)
237 }
238 };
239
240 let tz = infer_timezone_from_location_country(country).gather_warnings_into(&mut warnings)?;
241
242 Ok(Source::Inferred(tz).into_caveat(warnings))
243}
244
245fn try_parse_location_timezone(tz: &schema::Str<'_>) -> Verdict<Tz, Warning> {
247 let elem = tz.element();
248 let mut warnings = warning::Set::new();
249
250 let raw = tz.value();
252 let tz = raw
253 .decode_escapes()
254 .with_element(elem)
255 .gather_warnings_into(&mut warnings);
256
257 if matches!(tz, Cow::Owned(_)) {
258 warnings.insert(elem, Warning::ContainsEscapeCodes);
259 }
260
261 debug!(%tz, "Escaped time-zone found in CDR");
262
263 let Ok(tz) = tz.parse::<Tz>() else {
264 return warnings.bail(elem, Warning::InvalidTimezone);
265 };
266
267 Ok(tz.into_caveat(warnings))
268}
269
270#[instrument(skip_all)]
272fn infer_timezone_from_location_country(country: &schema::Str<'_>) -> Verdict<Tz, Warning> {
273 let elem = country.element();
274 let mut warnings = warning::Set::new();
275 let code_set = country::CodeSet::from_schema(country)?.gather_warnings_into(&mut warnings);
276
277 let country_code = match code_set {
281 country::CodeSet::Alpha2(code) => {
282 warnings.insert(elem, Warning::LocationCountryShouldBeAlpha3);
283 code
284 }
285 country::CodeSet::Alpha3(code) => code,
286 };
287 let Some(tz) = try_detect_timezone(country_code) else {
288 return warnings.bail(
289 elem,
290 Warning::CantInferTimezoneFromCountry(country_code.into_alpha_2_str()),
291 );
292 };
293
294 Ok(tz.into_caveat(warnings))
295}
296
297#[instrument]
305#[expect(
306 clippy::wildcard_enum_match_arm,
307 reason = "There are many `Code` variants that do not map to a timezone."
308)]
309fn try_detect_timezone(country_code: country::Code) -> Option<Tz> {
310 let tz = match country_code {
311 country::Code::Ad => Tz::Europe__Andorra,
312 country::Code::Al => Tz::Europe__Tirane,
313 country::Code::At => Tz::Europe__Vienna,
314 country::Code::Ba => Tz::Europe__Sarajevo,
315 country::Code::Be => Tz::Europe__Brussels,
316 country::Code::Bg => Tz::Europe__Sofia,
317 country::Code::By => Tz::Europe__Minsk,
318 country::Code::Ch => Tz::Europe__Zurich,
319 country::Code::Cy => Tz::Europe__Nicosia,
320 country::Code::Cz => Tz::Europe__Prague,
321 country::Code::De => Tz::Europe__Berlin,
322 country::Code::Dk => Tz::Europe__Copenhagen,
323 country::Code::Ee => Tz::Europe__Tallinn,
324 country::Code::Es => Tz::Europe__Madrid,
325 country::Code::Fi => Tz::Europe__Helsinki,
326 country::Code::Fr => Tz::Europe__Paris,
327 country::Code::Gb => Tz::Europe__London,
328 country::Code::Gr => Tz::Europe__Athens,
329 country::Code::Hr => Tz::Europe__Zagreb,
330 country::Code::Hu => Tz::Europe__Budapest,
331 country::Code::Ie => Tz::Europe__Dublin,
332 country::Code::Is => Tz::Iceland,
333 country::Code::It => Tz::Europe__Rome,
334 country::Code::Li => Tz::Europe__Vaduz,
335 country::Code::Lt => Tz::Europe__Vilnius,
336 country::Code::Lu => Tz::Europe__Luxembourg,
337 country::Code::Lv => Tz::Europe__Riga,
338 country::Code::Mc => Tz::Europe__Monaco,
339 country::Code::Md => Tz::Europe__Chisinau,
340 country::Code::Me => Tz::Europe__Podgorica,
341 country::Code::Mk => Tz::Europe__Skopje,
342 country::Code::Mt => Tz::Europe__Malta,
343 country::Code::Nl => Tz::Europe__Amsterdam,
344 country::Code::No => Tz::Europe__Oslo,
345 country::Code::Pl => Tz::Europe__Warsaw,
346 country::Code::Pt => Tz::Europe__Lisbon,
347 country::Code::Ro => Tz::Europe__Bucharest,
348 country::Code::Rs => Tz::Europe__Belgrade,
349 country::Code::Ru => Tz::Europe__Moscow,
350 country::Code::Se => Tz::Europe__Stockholm,
351 country::Code::Si => Tz::Europe__Ljubljana,
352 country::Code::Sk => Tz::Europe__Bratislava,
353 country::Code::Sm => Tz::Europe__San_Marino,
354 country::Code::Tr => Tz::Turkey,
355 country::Code::Ua => Tz::Europe__Kiev,
356 _ => return None,
357 };
358
359 debug!(%tz, "time-zone detected");
360
361 Some(tz)
362}