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, into_caveat_all, 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) => kind.id(),
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>);
86into_caveat_all!(chrono::NaiveTime, chrono::NaiveDate);
87
88impl json::FromJson<'_, '_> for DateTime<Utc> {
89    type WarningKind = WarningKind;
90
91    fn from_json(elem: &json::Element<'_>) -> Verdict<Self, Self::WarningKind> {
92        let mut warnings = warning::Set::new();
93        let Some(s) = elem.as_raw_str() else {
94            warnings.with_elem(WarningKind::InvalidType, elem);
95            return Err(warnings);
96        };
97
98        let pending_str = s.has_escapes(elem).gather_warnings_into(&mut warnings);
99
100        let s = match pending_str {
101            json::decode::PendingStr::NoEscapes(s) => s,
102            json::decode::PendingStr::HasEscapes(_) => {
103                warnings.with_elem(WarningKind::ContainsEscapeCodes, elem);
104                return Err(warnings);
105            }
106        };
107
108        // First try parsing with a timezone, if that doesn't work try to parse without
109        let err = match s.parse::<DateTime<Utc>>() {
110            Ok(date) => return Ok(date.into_caveat(warnings)),
111            Err(err) => err,
112        };
113
114        let Ok(date) = s.parse::<NaiveDateTime>() else {
115            warnings.with_elem(WarningKind::Invalid(err.to_string()), elem);
116            return Err(warnings);
117        };
118
119        let datetime = Utc.from_utc_datetime(&date);
120        Ok(datetime.into_caveat(warnings))
121    }
122}
123
124impl json::FromJson<'_, '_> for chrono::NaiveDate {
125    type WarningKind = WarningKind;
126
127    fn from_json(elem: &json::Element<'_>) -> Verdict<Self, Self::WarningKind> {
128        let mut warnings = warning::Set::new();
129        let Some(s) = elem.as_raw_str() else {
130            warnings.with_elem(WarningKind::InvalidType, elem);
131            return Err(warnings);
132        };
133
134        let pending_str = s.has_escapes(elem).gather_warnings_into(&mut warnings);
135
136        let s = match pending_str {
137            json::decode::PendingStr::NoEscapes(s) => s,
138            json::decode::PendingStr::HasEscapes(_) => {
139                warnings.with_elem(WarningKind::ContainsEscapeCodes, elem);
140                return Err(warnings);
141            }
142        };
143
144        let date = match s.parse::<chrono::NaiveDate>() {
145            Ok(v) => v,
146            Err(err) => {
147                warnings.with_elem(WarningKind::Invalid(err.to_string()), elem);
148                return Err(warnings);
149            }
150        };
151
152        Ok(date.into_caveat(warnings))
153    }
154}
155
156impl json::FromJson<'_, '_> for chrono::NaiveTime {
157    type WarningKind = WarningKind;
158
159    fn from_json(elem: &json::Element<'_>) -> Verdict<Self, Self::WarningKind> {
160        let mut warnings = warning::Set::new();
161        let value = elem.as_value();
162
163        let Some(s) = value.as_raw_str() else {
164            warnings.with_elem(WarningKind::InvalidType, elem);
165            return Err(warnings);
166        };
167
168        let pending_str = s.has_escapes(elem).gather_warnings_into(&mut warnings);
169
170        let s = match pending_str {
171            json::decode::PendingStr::NoEscapes(s) => s,
172            json::decode::PendingStr::HasEscapes(_) => {
173                warnings.with_elem(WarningKind::ContainsEscapeCodes, elem);
174                return Err(warnings);
175            }
176        };
177
178        let date = match chrono::NaiveTime::parse_from_str(s, "%H:%M") {
179            Ok(v) => v,
180            Err(err) => {
181                warnings.with_elem(WarningKind::Invalid(err.to_string()), elem);
182                return Err(warnings);
183            }
184        };
185
186        Ok(date.into_caveat(warnings))
187    }
188}
189
190#[cfg(test)]
191pub mod test {
192    use chrono::{DateTime, NaiveDateTime, TimeZone as _, Utc};
193
194    use crate::test::ApproxEq;
195
196    impl ApproxEq for DateTime<Utc> {
197        fn approx_eq(&self, other: &Self) -> bool {
198            /// Use a tolerance of 2 second when comparing `DateTime` amounts.
199            const EQ_TOLERANCE: i64 = 2;
200
201            let diff = *self - *other;
202            diff.num_seconds().abs() <= EQ_TOLERANCE
203        }
204    }
205
206    /// Deserialize an OCPI date as string into a `DateTime<Utc>`.
207    pub fn deser_to_utc<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
208    where
209        D: serde::Deserializer<'de>,
210    {
211        use serde::Deserialize;
212
213        let date_string = String::deserialize(deserializer)?;
214
215        // First try parsing with a timezone, if that doesn't work try to parse without
216        let err = match date_string.parse::<DateTime<Utc>>() {
217            Ok(date) => return Ok(date),
218            Err(err) => err,
219        };
220
221        if let Ok(date) = date_string.parse::<NaiveDateTime>() {
222            Ok(Utc.from_utc_datetime(&date))
223        } else {
224            Err(serde::de::Error::custom(err))
225        }
226    }
227}
228
229#[cfg(test)]
230mod test_datetime_from_json {
231    #![allow(
232        clippy::unwrap_in_result,
233        reason = "unwraps are allowed anywhere in tests"
234    )]
235
236    use assert_matches::assert_matches;
237    use chrono::{DateTime, TimeZone, Utc};
238
239    use crate::{
240        json::{self, FromJson as _},
241        Verdict,
242    };
243
244    use super::WarningKind;
245
246    #[track_caller]
247    fn parse_timestamp_from_json(json: &'static str) -> Verdict<DateTime<Utc>, WarningKind> {
248        let elem = json::parse(json).unwrap();
249        let date_time_time = elem.find_field("start_date_time").unwrap();
250        DateTime::<Utc>::from_json(date_time_time.element())
251    }
252
253    #[test]
254    fn should_parse_utc_datetime() {
255        const JSON: &str = r#"{ "start_date_time": "2015-06-29T22:39:09Z" }"#;
256
257        let (datetime, warnings) = parse_timestamp_from_json(JSON).unwrap().into_parts();
258        assert_matches!(*warnings, []);
259        assert_eq!(
260            datetime,
261            Utc.with_ymd_and_hms(2015, 6, 29, 22, 39, 9).unwrap()
262        );
263    }
264
265    #[test]
266    fn should_parse_timezone_to_utc() {
267        const JSON: &str = r#"{ "start_date_time": "2015-06-29T22:39:09+02:00" }"#;
268
269        let (datetime, warnings) = parse_timestamp_from_json(JSON).unwrap().into_parts();
270        assert_matches!(*warnings, []);
271        assert_eq!(
272            datetime,
273            Utc.with_ymd_and_hms(2015, 6, 29, 20, 39, 9).unwrap()
274        );
275    }
276
277    #[test]
278    fn should_parse_timezone_naive_to_utc() {
279        // This is a mess, but unfortunately OCPI 2.1.1 and 2.2 specify that datetimes without any
280        // timezone specification are also allowed
281        const JSON: &str = r#"{ "start_date_time": "2015-06-29T22:39:09" }"#;
282
283        let (datetime, warnings) = parse_timestamp_from_json(JSON).unwrap().into_parts();
284        assert_matches!(*warnings, []);
285
286        assert_eq!(
287            datetime,
288            Utc.with_ymd_and_hms(2015, 6, 29, 22, 39, 9).unwrap()
289        );
290    }
291}