ocpi_tariffs/
datetime.rs

1//! Timestamps are formatted as a string with a max length of 25 chars. Each timestamp follows  RFC 3339,
2//! with some additional limitations. All timestamps are expected to be in UTC. The absence of the
3//! timezone designator implies a UTC timestamp. Fractional seconds may be used.
4//!
5//! # Examples
6//!
7//! Example of how timestamps should be formatted in OCPI, other formats/patterns are not allowed:
8//!
9//! - `"2015-06-29T20:39:09Z"`
10//! - `"2015-06-29T20:39:09"`
11//! - `"2016-12-29T17:45:09.2Z"`
12//! - `"2016-12-29T17:45:09.2"`
13//! - `"2018-01-01T01:08:01.123Z"`
14//! - `"2018-01-01T01:08:01.123"`
15
16use std::fmt;
17
18use chrono::{DateTime, NaiveDateTime, TimeZone as _, Utc};
19
20use crate::{
21    into_caveat, json,
22    warning::{self, GatherWarnings as _},
23    IntoCaveat, Verdict,
24};
25
26/// The warnings that can happen when parsing or linting a `NaiveDate`, `NaiveTime`, or `DateTime`.
27#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
28pub enum WarningKind {
29    /// The datetime does not need to contain escape codes.
30    ContainsEscapeCodes,
31
32    /// The field at the path could not be decoded.
33    Decode(json::decode::WarningKind),
34
35    /// The datetime is not valid.
36    ///
37    /// Timestamps are formatted as a string with a max length of 25 chars. Each timestamp follows  RFC 3339,
38    /// with some additional limitations. All timestamps are expected to be in UTC. The absence of the
39    /// timezone designator implies a UTC timestamp. Fractional seconds may be used.
40    ///
41    /// # Examples
42    ///
43    /// Example of how timestamps should be formatted in OCPI, other formats/patterns are not allowed:
44    ///
45    /// - `"2015-06-29T20:39:09Z"`
46    /// - `"2015-06-29T20:39:09"`
47    /// - `"2016-12-29T17:45:09.2Z"`
48    /// - `"2016-12-29T17:45:09.2"`
49    /// - `"2018-01-01T01:08:01.123Z"`
50    /// - `"2018-01-01T01:08:01.123"`
51    Invalid(String),
52
53    /// The JSON value given is not a string.
54    InvalidType,
55}
56
57impl fmt::Display for WarningKind {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        match self {
60            WarningKind::ContainsEscapeCodes => write!(f, "contains_escape_codes"),
61            WarningKind::Decode(warning) => fmt::Display::fmt(warning, f),
62            WarningKind::Invalid(_) => write!(f, "invalid"),
63            WarningKind::InvalidType => write!(f, "invalid_type"),
64        }
65    }
66}
67
68impl warning::Kind for WarningKind {
69    fn id(&self) -> std::borrow::Cow<'static, str> {
70        match self {
71            WarningKind::ContainsEscapeCodes => "contains_escape_codes".into(),
72            WarningKind::Decode(kind) => format!("decode.{}", kind.id()).into(),
73            WarningKind::Invalid(_) => "invalid".into(),
74            WarningKind::InvalidType => "invalid_type".into(),
75        }
76    }
77}
78
79impl From<json::decode::WarningKind> for WarningKind {
80    fn from(warn_kind: json::decode::WarningKind) -> Self {
81        Self::Decode(warn_kind)
82    }
83}
84
85into_caveat!(DateTime<Utc>);
86
87/// Deserialize an OCPI date as string into a `DateTime<Utc>`.
88///
89/// Usage: `#[serde(deserialize_with = "deser_to_utc")]`
90pub(crate) fn deser_to_utc<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
91where
92    D: serde::Deserializer<'de>,
93{
94    use serde::Deserialize;
95
96    let date_string = String::deserialize(deserializer)?;
97
98    // First try parsing with a timezone, if that doesn't work try to parse without
99    let err = match date_string.parse::<DateTime<Utc>>() {
100        Ok(date) => return Ok(date),
101        Err(err) => err,
102    };
103
104    if let Ok(date) = date_string.parse::<NaiveDateTime>() {
105        Ok(Utc.from_utc_datetime(&date))
106    } else {
107        Err(serde::de::Error::custom(err))
108    }
109}
110
111impl json::FromJson<'_, '_> for DateTime<Utc> {
112    type WarningKind = WarningKind;
113
114    fn from_json(elem: &json::Element<'_>) -> Verdict<Self, Self::WarningKind> {
115        let mut warnings = warning::Set::new();
116        let Some(s) = elem.as_raw_str() else {
117            warnings.with_elem(WarningKind::InvalidType, elem);
118            return Err(warnings);
119        };
120
121        let pending_str = s.has_escapes(elem).gather_warnings_into(&mut warnings);
122
123        let s = match pending_str {
124            json::decode::PendingStr::NoEscapes(s) => s,
125            json::decode::PendingStr::HasEscapes(_) => {
126                warnings.with_elem(WarningKind::ContainsEscapeCodes, elem);
127                return Err(warnings);
128            }
129        };
130
131        // First try parsing with a timezone, if that doesn't work try to parse without
132        let err = match s.parse::<DateTime<Utc>>() {
133            Ok(date) => return Ok(date.into_caveat(warnings)),
134            Err(err) => err,
135        };
136
137        let Ok(date) = s.parse::<NaiveDateTime>() else {
138            warnings.with_elem(WarningKind::Invalid(err.to_string()), elem);
139            return Err(warnings);
140        };
141
142        let datetime = Utc.from_utc_datetime(&date);
143        Ok(datetime.into_caveat(warnings))
144    }
145}
146
147into_caveat!(chrono::NaiveDate);
148
149impl json::FromJson<'_, '_> for chrono::NaiveDate {
150    type WarningKind = WarningKind;
151
152    fn from_json(elem: &json::Element<'_>) -> Verdict<Self, Self::WarningKind> {
153        let mut warnings = warning::Set::new();
154        let Some(s) = elem.as_raw_str() else {
155            warnings.with_elem(WarningKind::InvalidType, elem);
156            return Err(warnings);
157        };
158
159        let pending_str = s.has_escapes(elem).gather_warnings_into(&mut warnings);
160
161        let s = match pending_str {
162            json::decode::PendingStr::NoEscapes(s) => s,
163            json::decode::PendingStr::HasEscapes(_) => {
164                warnings.with_elem(WarningKind::ContainsEscapeCodes, elem);
165                return Err(warnings);
166            }
167        };
168
169        let date = match s.parse::<chrono::NaiveDate>() {
170            Ok(v) => v,
171            Err(err) => {
172                warnings.with_elem(WarningKind::Invalid(err.to_string()), elem);
173                return Err(warnings);
174            }
175        };
176
177        Ok(date.into_caveat(warnings))
178    }
179}
180
181into_caveat!(chrono::NaiveTime);
182
183impl json::FromJson<'_, '_> for chrono::NaiveTime {
184    type WarningKind = WarningKind;
185
186    fn from_json(elem: &json::Element<'_>) -> Verdict<Self, Self::WarningKind> {
187        let mut warnings = warning::Set::new();
188        let value = elem.as_value();
189
190        let Some(s) = value.as_raw_str() else {
191            warnings.with_elem(WarningKind::InvalidType, elem);
192            return Err(warnings);
193        };
194
195        let pending_str = s.has_escapes(elem).gather_warnings_into(&mut warnings);
196
197        let s = match pending_str {
198            json::decode::PendingStr::NoEscapes(s) => s,
199            json::decode::PendingStr::HasEscapes(_) => {
200                warnings.with_elem(WarningKind::ContainsEscapeCodes, elem);
201                return Err(warnings);
202            }
203        };
204
205        let date = match chrono::NaiveTime::parse_from_str(s, "%H:%M") {
206            Ok(v) => v,
207            Err(err) => {
208                warnings.with_elem(WarningKind::Invalid(err.to_string()), elem);
209                return Err(warnings);
210            }
211        };
212
213        Ok(date.into_caveat(warnings))
214    }
215}
216
217#[cfg(test)]
218mod test {
219    use chrono::{DateTime, Utc};
220
221    use crate::test::ApproxEq;
222
223    impl ApproxEq for DateTime<Utc> {
224        fn approx_eq(&self, other: &Self) -> bool {
225            /// Use a tolerance of 2 second when comparing `DateTime` amounts.
226            const EQ_TOLERANCE: i64 = 2;
227
228            let diff = *self - *other;
229            diff.num_seconds().abs() <= EQ_TOLERANCE
230        }
231    }
232}
233
234#[cfg(test)]
235mod test_datetime_serde_deser {
236    use chrono::{DateTime, TimeZone, Utc};
237    use serde::de::{value::StrDeserializer, IntoDeserializer as _};
238
239    use super::deser_to_utc;
240
241    #[track_caller]
242    fn parse_timestamp(timestamp: &str) -> DateTime<Utc> {
243        let de: StrDeserializer<'_, serde::de::value::Error> = timestamp.into_deserializer();
244        deser_to_utc(de).unwrap()
245    }
246
247    #[test]
248    fn should_parse_utc_datetime() {
249        assert_eq!(
250            parse_timestamp("2015-06-29T22:39:09Z"),
251            Utc.with_ymd_and_hms(2015, 6, 29, 22, 39, 9).unwrap()
252        );
253    }
254
255    #[test]
256    fn should_parse_timezone_to_utc() {
257        assert_eq!(
258            parse_timestamp("2015-06-29T22:39:09+02:00"),
259            Utc.with_ymd_and_hms(2015, 6, 29, 20, 39, 9).unwrap()
260        );
261    }
262
263    #[test]
264    fn should_parse_timezone_naive_to_utc() {
265        // This is a mess, but unfortunately OCPI 2.1.1 and 2.2 specify that datetimes without any
266        // timezone specification are also allowed
267        assert_eq!(
268            parse_timestamp("2015-06-29T22:39:09"),
269            Utc.with_ymd_and_hms(2015, 6, 29, 22, 39, 9).unwrap()
270        );
271    }
272}
273
274#[cfg(test)]
275mod test_datetime_from_json {
276    #![allow(
277        clippy::unwrap_in_result,
278        reason = "unwraps are allowed anywhere in tests"
279    )]
280
281    use assert_matches::assert_matches;
282    use chrono::{DateTime, TimeZone, Utc};
283
284    use crate::{
285        json::{self, FromJson as _},
286        Verdict,
287    };
288
289    use super::WarningKind;
290
291    #[track_caller]
292    fn parse_timestamp_from_json(json: &'static str) -> Verdict<DateTime<Utc>, WarningKind> {
293        let elem = json::parse(json).unwrap();
294        let date_time_time = elem.find_field("start_date_time").unwrap();
295        DateTime::<Utc>::from_json(date_time_time.element())
296    }
297
298    #[test]
299    fn should_parse_utc_datetime() {
300        const JSON: &str = r#"{ "start_date_time": "2015-06-29T22:39:09Z" }"#;
301
302        let (datetime, warnings) = parse_timestamp_from_json(JSON).unwrap().into_parts();
303        assert_matches!(*warnings, []);
304        assert_eq!(
305            datetime,
306            Utc.with_ymd_and_hms(2015, 6, 29, 22, 39, 9).unwrap()
307        );
308    }
309
310    #[test]
311    fn should_parse_timezone_to_utc() {
312        const JSON: &str = r#"{ "start_date_time": "2015-06-29T22:39:09+02:00" }"#;
313
314        let (datetime, warnings) = parse_timestamp_from_json(JSON).unwrap().into_parts();
315        assert_matches!(*warnings, []);
316        assert_eq!(
317            datetime,
318            Utc.with_ymd_and_hms(2015, 6, 29, 20, 39, 9).unwrap()
319        );
320    }
321
322    #[test]
323    fn should_parse_timezone_naive_to_utc() {
324        // This is a mess, but unfortunately OCPI 2.1.1 and 2.2 specify that datetimes without any
325        // timezone specification are also allowed
326        const JSON: &str = r#"{ "start_date_time": "2015-06-29T22:39:09" }"#;
327
328        let (datetime, warnings) = parse_timestamp_from_json(JSON).unwrap().into_parts();
329        assert_matches!(*warnings, []);
330
331        assert_eq!(
332            datetime,
333            Utc.with_ymd_and_hms(2015, 6, 29, 22, 39, 9).unwrap()
334        );
335    }
336}