Skip to main content

ocpi_tariffs/
timezone.rs

1//! Parse an IANA Timezone from JSON or find a timezone in a CDR.
2
3/// Test support shared by this module's other test files.
4#[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/// The warnings possible when parsing or linting an IANA timezone.
23#[derive(Debug)]
24pub enum Warning {
25    /// A timezone can't be inferred from the `location`'s `country`.
26    CantInferTimezoneFromCountry(&'static str),
27
28    /// Neither the timezone or country field require char escape codes.
29    ContainsEscapeCodes,
30
31    /// The CDR location is not a valid `ISO 3166-1` alpha-3 code.
32    Country(country::Warning),
33
34    /// The field at the path could not be decoded.
35    Decode(json::decode::Warning),
36
37    /// The CDR location did not contain a valid IANA time-zone.
38    ///
39    /// See: <https://www.iana.org/time-zones>.
40    InvalidTimezone,
41
42    /// The `location.country` field should be an alpha-3 country code.
43    ///
44    /// The alpha-2 code can be converted into an alpha-3 but the caller should be warned.
45    LocationCountryShouldBeAlpha3,
46
47    /// The CDR's `location` has no `country` element and so the timezone can't be inferred.
48    NoLocationCountry,
49
50    /// The CDR has no `location` element and so the timezone can't be found or inferred.
51    NoLocation,
52
53    /// A v221 CDR is given but it contains a `location` field instead of a `cdr_location` as defined in the spec.
54    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/// The source of the timezone.
106#[derive(Copy, Clone, Debug)]
107pub enum Source {
108    /// The timezone was found in the `location` element.
109    Found(Tz),
110
111    /// The timezone is inferred from the `location`'s `country`.
112    Inferred(Tz),
113}
114
115impl Source {
116    /// Return the timezone and disregard where it came from.
117    pub fn into_timezone(self) -> Tz {
118        match self {
119            Source::Found(tz) | Source::Inferred(tz) => tz,
120        }
121    }
122}
123
124/// Try to find or infer the timezone from the `CDR` JSON.
125///
126/// Return `Some` if the timezone can be found or inferred.
127/// Return `None` if the timezone is not found and can't be inferred.
128///
129/// Finding a timezone is an infallible operation. If invalid data is found a `None` is returned
130/// with an appropriate warning.
131///
132/// If the `CDR` contains a `time_zone` in the location object then that is simply returned.
133/// Only pre-`v2.2.1` CDR's have a `time_zone` field in the `Location` object.
134///
135/// Inferring the timezone only works for `CDR`s from European countries.
136///
137pub 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    // The `location::time_zone` field is optional in v211 and not part of the v221 spec,
145    // where the schema reports it as a `NonSpecField` and reads it anyway.
146    //
147    // See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc#mod_cdrs_cdr_location_class>
148    // See: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_locations.md#31-location-object>
149    let tz = match &location.time_zone {
150        schema::Integrity::Ok(Some(tz)) => try_parse_location_timezone(tz),
151        // An absent, `null`, or wrongly typed `time_zone` is located by the schema walk.
152        // There may still be a country to infer from, so the search continues.
153        schema::Integrity::Ok(None) | schema::Integrity::Missing(_) | schema::Integrity::Err(_) => {
154            return infer_from_country(&location, warnings)
155        }
156    };
157
158    // A `time_zone` that is present but unusable is not fatal. The failure is deescalated to
159    // a warning and the country is tried instead.
160    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
175/// The two fields a timezone can be found or inferred from, borrowed out of whichever
176/// version's location object carries them.
177struct Location<'a, 'buf> {
178    time_zone: &'a schema::Integrity<Option<schema::Str<'buf>>>,
179    country: &'a schema::Integrity<schema::Str<'buf>>,
180}
181
182/// Borrow the location fields out of the CDR's schema IR.
183///
184/// Describes the location that the charge-session took place at. The v211 CDR has a
185/// `location` field while the v221 CDR has a `cdr_location` field. A v221 CDR that still
186/// uses the old name is read from it anyway, because that is the only place its timezone
187/// can be.
188///
189/// * See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc#131-cdr-object>
190/// * See: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_cdrs.md#3-object-description>
191fn 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        // A `location` object in a v221 CDR is the v211 field name. It is preferred over
202        // `cdr_location`, because a CDR that carries it put the timezone there.
203        cdr::Version::V221(ir) => match (&ir.location, &ir.cdr_location) {
204            (schema::Integrity::Ok(location), _) => {
205                // Anchored at the document rather than at the field: the schema walk's
206                // `NonSpecField` warning already locates the field itself.
207                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
221/// Infer the timezone from the location's `country`, having found no usable `time_zone`.
222fn 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    // `ISO 3166-1 alpha-3` code for the country of this location. The field is required in
229    // both versions.
230    //
231    // See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc#mod_cdrs_cdr_location_class>
232    // See: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_locations.md#31-location-object>
233    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
245/// Try to parse the location's `time_zone` into a `Tz`.
246fn try_parse_location_timezone(tz: &schema::Str<'_>) -> Verdict<Tz, Warning> {
247    let elem = tz.element();
248    let mut warnings = warning::Set::new();
249
250    // The schema proved the value is a JSON string but keeps it raw, so it is decoded here.
251    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/// Try to infer a timezone from the location's `country` field.
271#[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    // The `location.country` field should be an alpha-3 country code.
278    //
279    // The alpha-2 code can be converted into an alpha-3 but the caller should be warned.
280    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/// Mapping of European countries to time-zones with geographical naming
298///
299/// This is only possible for countries with a single time-zone and only for countries as they
300/// currently exist (2024). It's a best effort approach to determine a time-zone from just an
301/// ALPHA-3 `ISO 3166-1` country code.
302///
303/// In small edge cases (e.g. Gibraltar) this detection might generate the wrong time-zone.
304#[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}