Skip to main content

ocpi_tariffs/
timezone.rs

1//! Parse an IANA Timezone from JSON or find a timezone in a CDR.
2
3#[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/// The warnings possible when parsing or linting an IANA timezone.
22#[derive(Debug)]
23pub enum Warning {
24    /// A timezone can't be inferred from the `location`'s `country`.
25    CantInferTimezoneFromCountry(&'static str),
26
27    /// Neither the timezone or country field require char escape codes.
28    ContainsEscapeCodes,
29
30    /// The CDR location is not a valid `ISO 3166-1` alpha-3 code.
31    Country(country::Warning),
32
33    /// The field at the path could not be decoded.
34    Decode(json::decode::Warning),
35
36    /// The CDR location is not a String.
37    InvalidLocationType,
38
39    /// The CDR location did not contain a valid IANA time-zone.
40    ///
41    /// See: <https://www.iana.org/time-zones>.
42    InvalidTimezone,
43
44    /// The CDR timezone is not a String.
45    InvalidTimezoneType,
46
47    /// The `location.country` field should be an alpha-3 country code.
48    ///
49    /// The alpha-2 code can be converted into an alpha-3 but the caller should be warned.
50    LocationCountryShouldBeAlpha3,
51
52    /// The CDR's `location` has no `country` element and so the timezone can't be inferred.
53    NoLocationCountry,
54
55    /// The CDR has no `location` element and so the timezone can't be found or inferred.
56    NoLocation,
57
58    /// Both the CDR and tariff JSON should be an Object.
59    ShouldBeAnObject,
60
61    /// A v221 CDR is given but it contains a `location` field instead of a `cdr_location` as defined in the spec.
62    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/// The source of the timezone.
120#[derive(Copy, Clone, Debug)]
121pub enum Source {
122    /// The timezone was found in the `location` element.
123    Found(Tz),
124
125    /// The timezone is inferred from the `location`'s `country`.
126    Inferred(Tz),
127}
128
129impl Source {
130    /// Return the timezone and disregard where it came from.
131    pub fn into_timezone(self) -> Tz {
132        match self {
133            Source::Found(tz) | Source::Inferred(tz) => tz,
134        }
135    }
136}
137
138/// Try to find or infer the timezone from the `CDR` JSON.
139///
140/// Return `Some` if the timezone can be found or inferred.
141/// Return `None` if the timezone is not found and can't be inferred.
142///
143/// Finding a timezone is an infallible operation. If invalid data is found a `None` is returned
144/// with an appropriate warning.
145///
146/// If the `CDR` contains a `time_zone` in the location object then that is simply returned.
147/// Only pre-`v2.2.1` CDR's have a `time_zone` field in the `Location` object.
148///
149/// Inferring the timezone only works for `CDR`s from European countries.
150///
151pub 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    // OCPI v221 changed the `location` field to `cdr_location`.
169    //
170    // * See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc#131-cdr-object>
171    // * See: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_cdrs.md#3-object-description>
172    if cdr.version() == Version::V221 && v211_location.is_some() {
173        warnings.insert(cdr_root, Warning::V221CdrHasLocationField);
174    }
175
176    // Describes the location that the charge-session took place at.
177    //
178    // The v211 CDR has a `location` field, while the v221 CDR has a `cdr_location` field.
179    //
180    // * See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc#131-cdr-object>
181    // * See: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_cdrs.md#3-object-description>
182    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    // The `location::time_zone` field is optional in v211 and not defined in the v221 spec.
195    //
196    // See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc#mod_cdrs_cdr_location_class>
197    // See: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_locations.md#31-location-object>
198    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    // We first try to find/parse the timezone in the location object.
204    // If the timezone is not found there or there are any failures,
205    // The failures are deescalated to warnings.
206    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    // `ISO 3166-1 alpha-3` code for the country of this location.
220    let Some(country_elem) = location_fields.get(COUNTRY_FIELD) else {
221        // The `location::country` field is required.
222        //
223        // See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc#mod_cdrs_cdr_location_class>
224        // See: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_locations.md#31-location-object>
225        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
233/// Try to parse the `location` element's timezone into a `Tz`.
234fn 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/// Try to infer a timezone from the `location` elements `country` field.
262#[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    // The `location.country` field should be an alpha-3 country code.
268    //
269    // The alpha-2 code can be converted into an alpha-3 but the caller should be warned.
270    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/// Mapping of European countries to time-zones with geographical naming
288///
289/// This is only possible for countries with a single time-zone and only for countries as they
290/// currently exist (2024). It's a best effort approach to determine a time-zone from just an
291/// ALPHA-3 `ISO 3166-1` country code.
292///
293/// In small edge cases (e.g. Gibraltar) this detection might generate the wrong time-zone.
294#[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}