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, json,
16 schema::{self, FromSchema as _, HasElement as _},
17 warning::{self, GatherWarnings as _, WithElement as _},
18 IntoCaveat as _, Verdict,
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 InvalidTimezone,
40
41 LocationCountryShouldBeAlpha3,
45
46 NoLocationCountry,
48
49 NoLocation,
51
52 V221CdrHasLocationField,
54}
55
56from_warning_all!(
57 country::Warning => Warning::Country,
58 json::decode::Warning => Warning::Decode
59);
60
61impl fmt::Display for Warning {
62 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 match self {
64 Self::CantInferTimezoneFromCountry(country_code) => write!(f, "Unable to infer timezone from the `location`'s `country`: `{country_code}`"),
65 Self::ContainsEscapeCodes => f.write_str("The CDR location contains needless escape codes."),
66 Self::Country(kind) => fmt::Display::fmt(kind, f),
67 Self::Decode(warning) => fmt::Display::fmt(warning, f),
68 Self::InvalidTimezone => f.write_str("The CDR location did not contain a valid IANA time-zone."),
69 Self::LocationCountryShouldBeAlpha3 => f.write_str("The `location.country` field should be an alpha-3 country code."),
70 Self::NoLocationCountry => {
71 f.write_str("The CDR's `location` has no `country` element and so the timezone can't be inferred.")
72 },
73 Self::NoLocation => {
74 f.write_str("The CDR has no `location` element and so the timezone can't be found or inferred.")
75 }
76 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."),
77
78 }
79 }
80}
81
82impl crate::Warning for Warning {
83 fn id(&self) -> warning::Id {
84 match self {
85 Self::CantInferTimezoneFromCountry(_) => {
86 warning::Id::from_static("cant_infer_timezone_from_country")
87 }
88 Self::ContainsEscapeCodes => warning::Id::from_static("contains_escape_codes"),
89 Self::Decode(warning) => warning.id(),
90 Self::Country(warning) => warning.id(),
91 Self::InvalidTimezone => warning::Id::from_static("invalid_timezone"),
92 Self::LocationCountryShouldBeAlpha3 => {
93 warning::Id::from_static("location_country_should_be_alpha3")
94 }
95 Self::NoLocationCountry => warning::Id::from_static("no_location_country"),
96 Self::NoLocation => warning::Id::from_static("no_location"),
97 Self::V221CdrHasLocationField => {
98 warning::Id::from_static("v221_cdr_has_location_field")
99 }
100 }
101 }
102}
103
104#[derive(Copy, Clone, Debug)]
106pub enum Source {
107 Found(Tz),
109
110 Inferred(Tz),
112}
113
114impl Source {
115 pub fn into_timezone(self) -> Tz {
117 match self {
118 Source::Found(tz) | Source::Inferred(tz) => tz,
119 }
120 }
121}
122
123pub fn find_or_infer(cdr: &cdr::Versioned<'_>) -> Verdict<Source, Warning> {
137 let mut warnings = warning::Set::new();
138
139 let location = location(cdr)?.gather_warnings_into(&mut warnings);
140
141 debug!("Searching for time-zone in CDR");
142
143 let tz = match &location.time_zone {
149 schema::Integrity::Ok(Some(tz)) => try_parse_location_timezone(tz),
150 schema::Integrity::Ok(None) | schema::Integrity::Missing(_) | schema::Integrity::Err(_) => {
153 return infer_from_country(&location, warnings)
154 }
155 };
156
157 let tz = match tz {
160 Ok(tz) => Some(tz.gather_warnings_into(&mut warnings)),
161 Err(err_set) => {
162 warnings.deescalate_error(err_set);
163 None
164 }
165 };
166
167 let Some(tz) = tz else {
168 return infer_from_country(&location, warnings);
169 };
170
171 Ok(Source::Found(tz).into_caveat(warnings))
172}
173
174struct Location<'a, 'buf> {
177 time_zone: &'a schema::Integrity<Option<schema::Str<'buf>>>,
178 country: &'a schema::Integrity<schema::Str<'buf>>,
179}
180
181fn location<'a, 'buf>(cdr: &'a cdr::Versioned<'buf>) -> Verdict<Location<'a, 'buf>, Warning> {
191 let mut warnings = warning::Set::new();
192
193 let (time_zone, country) = match cdr.schema() {
194 cdr::Version::V211(ir) => match &ir.location {
195 schema::Integrity::Ok(location) => (&location.time_zone, &location.country),
196 schema::Integrity::Missing(elem) | schema::Integrity::Err(elem) => {
197 return warnings.bail_at(elem.clone(), Warning::NoLocation)
198 }
199 },
200 cdr::Version::V221(ir) => match (&ir.location, &ir.cdr_location) {
203 (schema::Integrity::Ok(location), _) => {
204 warnings.insert(cdr.as_element(), Warning::V221CdrHasLocationField);
207
208 (&location.time_zone, &location.country)
209 }
210 (_, schema::Integrity::Ok(location)) => (&location.time_zone, &location.country),
211 (_, schema::Integrity::Missing(elem) | schema::Integrity::Err(elem)) => {
212 return warnings.bail_at(elem.clone(), Warning::NoLocation)
213 }
214 },
215 };
216
217 Ok(Location { time_zone, country }.into_caveat(warnings))
218}
219
220fn infer_from_country(
222 location: &Location<'_, '_>,
223 mut warnings: warning::Set<Warning>,
224) -> Verdict<Source, Warning> {
225 debug!("No time-zone found in CDR; trying to infer time-zone from country");
226
227 let country = match &location.country {
233 schema::Integrity::Ok(country) => country,
234 schema::Integrity::Missing(elem) | schema::Integrity::Err(elem) => {
235 return warnings.bail_at(elem.clone(), Warning::NoLocationCountry)
236 }
237 };
238
239 let tz = infer_timezone_from_location_country(country).gather_warnings_into(&mut warnings)?;
240
241 Ok(Source::Inferred(tz).into_caveat(warnings))
242}
243
244fn try_parse_location_timezone(tz: &schema::Str<'_>) -> Verdict<Tz, Warning> {
246 let elem = tz.element();
247 let mut warnings = warning::Set::new();
248
249 let raw = tz.value();
251 let tz = raw
252 .decode_escapes()
253 .with_element(elem)
254 .gather_warnings_into(&mut warnings);
255
256 if matches!(tz, Cow::Owned(_)) {
257 warnings.insert(elem, Warning::ContainsEscapeCodes);
258 }
259
260 debug!(%tz, "Escaped time-zone found in CDR");
261
262 let Ok(tz) = tz.parse::<Tz>() else {
263 return warnings.bail(elem, Warning::InvalidTimezone);
264 };
265
266 Ok(tz.into_caveat(warnings))
267}
268
269#[instrument(skip_all)]
271fn infer_timezone_from_location_country(country: &schema::Str<'_>) -> Verdict<Tz, Warning> {
272 let elem = country.element();
273 let mut warnings = warning::Set::new();
274 let code_set = country::CodeSet::from_schema(country)?.gather_warnings_into(&mut warnings);
275
276 let country_code = match code_set {
280 country::CodeSet::Alpha2(code) => {
281 warnings.insert(elem, Warning::LocationCountryShouldBeAlpha3);
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 elem,
289 Warning::CantInferTimezoneFromCountry(country_code.into_alpha_2_str()),
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}