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 _},
18    IntoCaveat as _, ParseError, 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    /// An error occurred while deserializing the `CDR`.
37    Deserialize(ParseError),
38
39    /// The CDR location is not a String.
40    InvalidLocationType,
41
42    /// The CDR location did not contain a valid IANA time-zone.
43    ///
44    /// See: <https://www.iana.org/time-zones>.
45    InvalidTimezone,
46
47    /// The CDR timezone is not a String.
48    InvalidTimezoneType,
49
50    /// The `location.country` field should be an alpha-3 country code.
51    ///
52    /// The alpha-2 code can be converted into an alpha-3 but the caller should be warned.
53    LocationCountryShouldBeAlpha3,
54
55    /// The CDR's `location` has no `country` element and so the timezone can't be inferred.
56    NoLocationCountry,
57
58    /// The CDR has no `location` element and so the timezone can't be found or inferred.
59    NoLocation,
60
61    /// An `Error` occurred while parsing the JSON or deferred JSON String decode.
62    Parser(json::Error),
63
64    /// Both the CDR and tariff JSON should be an Object.
65    ShouldBeAnObject,
66
67    /// A v221 CDR is given but it contains a `location` field instead of a `cdr_location` as defined in the spec.
68    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/// The source of the timezone.
130#[derive(Copy, Clone, Debug)]
131pub enum Source {
132    /// The timezone was found in the `location` element.
133    Found(Tz),
134
135    /// The timezone is inferred from the `location`'s `country`.
136    Inferred(Tz),
137}
138
139impl Source {
140    /// Return the timezone and disregard where it came from.
141    pub fn into_timezone(self) -> Tz {
142        match self {
143            Source::Found(tz) | Source::Inferred(tz) => tz,
144        }
145    }
146}
147
148/// Try to find or infer the timezone from the `CDR` JSON.
149///
150/// Return `Some` if the timezone can be found or inferred.
151/// Return `None` if the timezone is not found and can't be inferred.
152///
153/// Finding a timezone is an infallible operation. If invalid data is found a `None` is returned
154/// with an appropriate warning.
155///
156/// If the `CDR` contains a `time_zone` in the location object then that is simply returned.
157/// Only pre-`v2.2.1` CDR's have a `time_zone` field in the `Location` object.
158///
159/// Inferring the timezone only works for `CDR`s from European countries.
160///
161pub 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    // OCPI v221 changed the `location` field to `cdr_location`.
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    if cdr.version() == Version::V221 && v211_location.is_some() {
183        warnings.insert(Warning::V221CdrHasLocationField, cdr_root);
184    }
185
186    // Describes the location that the charge-session took place at.
187    //
188    // The v211 CDR has a `location` field, while the v221 CDR has a `cdr_location` field.
189    //
190    // * See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc#131-cdr-object>
191    // * See: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_cdrs.md#3-object-description>
192    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    // The `location::time_zone` field is optional in v211 and not defined in the v221 spec.
205    //
206    // See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc#mod_cdrs_cdr_location_class>
207    // See: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_locations.md#31-location-object>
208    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    // We first try to find/parse the timezone in the location object.
214    // If the timezone is not found there or there are any failures,
215    // The failures are deescalated to warnings.
216    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    // ISO 3166-1 alpha-3 code for the country of this location.
230    let Some(country_elem) = location_fields.get(COUNTRY_FIELD) else {
231        // The `location::country` field is required.
232        //
233        // See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc#mod_cdrs_cdr_location_class>
234        // See: <https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_locations.md#31-location-object>
235        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
243/// Try to parse the `location` element's timezone into a `Tz`.
244fn 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/// Try to infer a timezone from the `location` elements `country` field.
271#[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    // The `location.country` field should be an alpha-3 country code.
277    //
278    // The alpha-2 code can be converted into an alpha-3 but the caller should be warned.
279    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/// Mapping of European countries to time-zones with geographical naming
297///
298/// This is only possible for countries with a single time-zone and only for countries as they
299/// currently exist (2024). It's a best effort approach to determine a time-zone from just an
300/// ALPHA-3 ISO 3166-1 country code.
301///
302/// In small edge cases (e.g. Gibraltar) this detection might generate the wrong time-zone.
303#[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}