Skip to main content

ocpi_kit/types/
datetime.rs

1//! `DateTime` — the OCPI timestamp type: RFC 3339, always UTC.
2
3use core::fmt;
4use core::str::FromStr;
5
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7use time::{OffsetDateTime, PrimitiveDateTime, UtcOffset};
8
9use super::validate::{Validate, Validator, ViolationCode};
10
11/// An OCPI timestamp: an instant in UTC, serialised as RFC 3339 with a `Z` designator.
12///
13/// > *All timestamps are formatted as string(25) following RFC 3339, with some additional
14/// > limitations. All timestamps SHALL be in UTC. The absence of the timezone designator implies
15/// > a UTC timestamp. Fractional seconds MAY be used.*
16///
17/// The six forms the spec lists as the only allowed ones are, by example:
18///
19/// ```text
20/// 2015-06-29T20:39:09Z        2015-06-29T20:39:09
21/// 2016-12-29T17:45:09.2Z      2016-12-29T17:45:09.2
22/// 2018-01-01T01:08:01.123Z    2018-01-01T01:08:01.123
23/// ```
24///
25/// # `+00:00` is not `Z`
26///
27/// The spec is explicit: *"NOTE: +00:00 is not the same as UTC."* A `DateTime` parsed from a
28/// timestamp carrying an explicit offset — even `+00:00` — is converted to the correct UTC
29/// instant so no data is lost, but is flagged: [`DateTime::is_canonical`] returns `false` and
30/// [`Validate::validate`] reports it. Serialising always emits the canonical `Z` form, so a
31/// value that passes through this crate comes out conformant.
32///
33/// # Fractional seconds are preserved
34///
35/// A timestamp read as `…09.2Z` is written back as `…09.2Z`, not `…09.200Z`. The number of
36/// fractional digits is formatting metadata: it takes no part in [`PartialEq`], [`Ord`] or
37/// [`std::hash::Hash`], which compare the instant alone.
38///
39/// ```
40/// use ocpi_kit::types::DateTime;
41///
42/// let t: DateTime = "2016-12-29T17:45:09.2Z".parse().unwrap();
43/// assert_eq!(t.to_string(), "2016-12-29T17:45:09.2Z");
44/// assert_eq!(t, "2016-12-29T17:45:09.200Z".parse::<DateTime>().unwrap());
45/// ```
46///
47/// Spec: 2.3.0 §types_datetime_type
48#[derive(Clone, Copy)]
49pub struct DateTime {
50    /// Always normalised to `UtcOffset::UTC`.
51    instant: OffsetDateTime,
52    /// Number of fractional-second digits to emit (0..=9).
53    frac_digits: u8,
54    /// Whether the source text used one of the six forms the spec permits.
55    canonical: bool,
56}
57
58impl DateTime {
59    /// The current time, with second precision.
60    #[must_use]
61    pub fn now() -> Self {
62        Self::from_utc(
63            OffsetDateTime::now_utc().replace_nanosecond(0).unwrap_or_else(|_| OffsetDateTime::now_utc()),
64        )
65    }
66
67    /// Wraps an [`OffsetDateTime`], converting it to UTC.
68    ///
69    /// The number of fractional digits emitted is chosen to be the shortest that represents the
70    /// value exactly: none, three, six or nine.
71    ///
72    /// Crate-private for the same reason as
73    /// [`as_offset_date_time`](Self::as_offset_date_time): the backend stays swappable.
74    /// [`from_unix_timestamp`](Self::from_unix_timestamp), [`parse`](Self::parse) and
75    /// [`now`](Self::now) are the public constructors.
76    pub(crate) fn from_utc(value: OffsetDateTime) -> Self {
77        let instant = value.to_offset(UtcOffset::UTC);
78        Self { instant, frac_digits: shortest_frac_digits(instant.nanosecond()), canonical: true }
79    }
80
81    /// The wall clock this instant shows in a zone `offset_seconds` east of UTC.
82    ///
83    /// Every time-of-day and day-of-week rule in OCPI is written in a Location's local time —
84    /// tariff restrictions, opening hours — so reading one means converting first. The result is
85    /// in this crate's own [`LocalParts`](crate::types::LocalParts), so no date-time library
86    /// reaches the API.
87    ///
88    /// The offset has to come from somewhere: a `Location` carries an IANA `time_zone`, and
89    /// [`TimeZone`](crate::tariffs::TimeZone) resolves it against the zone database, which is
90    /// what a rule spanning a daylight-saving change needs.
91    ///
92    /// ```
93    /// use ocpi_kit::types::DateTime;
94    ///
95    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
96    /// let instant: DateTime = "2024-01-15T23:30:00Z".parse()?;
97    /// let local = instant.local_parts(3600); // CET, UTC+1
98    /// assert_eq!(local.time.hour(), 0);
99    /// assert_eq!(local.date.day(), 16);
100    /// assert_eq!(local.iso_weekday, 2); // Tuesday
101    /// # Ok(())
102    /// # }
103    /// ```
104    #[must_use]
105    pub fn local_parts(self, offset_seconds: i32) -> crate::types::LocalParts {
106        let local = self.instant + time::Duration::seconds(i64::from(offset_seconds));
107        crate::types::LocalParts {
108            date: crate::types::LocalDate::from_date(local.date()),
109            // Hour and minute out of a real timestamp are always in range.
110            time: crate::types::LocalTime::new(local.hour(), local.minute())
111                .expect("an hour and minute from a timestamp are in range"),
112            iso_weekday: local.weekday().number_from_monday(),
113        }
114    }
115
116    /// Seconds since the Unix epoch.
117    #[must_use]
118    pub const fn unix_timestamp(self) -> i64 {
119        self.instant.unix_timestamp()
120    }
121
122    /// Builds a timestamp from seconds since the Unix epoch.
123    ///
124    /// # Errors
125    ///
126    /// Returns [`InvalidDateTime`] if the value is outside the supported range.
127    pub fn from_unix_timestamp(secs: i64) -> Result<Self, InvalidDateTime> {
128        OffsetDateTime::from_unix_timestamp(secs)
129            .map(Self::from_utc)
130            .map_err(|_| InvalidDateTime::new("timestamp out of range"))
131    }
132
133    /// Whether the value was written in one of the six forms the spec allows.
134    ///
135    /// `false` means the source used an explicit UTC offset (including `+00:00`), a lower-case
136    /// `z`, or a space instead of `T`. The instant itself is still correct.
137    #[must_use]
138    pub const fn is_canonical(self) -> bool {
139        self.canonical
140    }
141
142    /// Returns a copy that will be written with exactly `digits` fractional-second digits.
143    ///
144    /// `digits` is clamped to 9.
145    #[must_use]
146    pub const fn with_fractional_digits(mut self, digits: u8) -> Self {
147        self.frac_digits = if digits > 9 { 9 } else { digits };
148        self
149    }
150
151    /// How many fractional-second digits this value will be written with.
152    #[must_use]
153    pub const fn fractional_digits(self) -> u8 {
154        self.frac_digits
155    }
156
157    /// Parses one of the RFC 3339 forms OCPI allows, plus the tolerated deviations described on
158    /// the type.
159    ///
160    /// # Errors
161    ///
162    /// Returns [`InvalidDateTime`] when the text is not a date-time at all, or carries an
163    /// offset that would place it outside the representable range.
164    pub fn parse(text: &str) -> Result<Self, InvalidDateTime> {
165        parse_ocpi_datetime(text)
166    }
167}
168
169fn shortest_frac_digits(nanos: u32) -> u8 {
170    if nanos == 0 {
171        0
172    } else if nanos.is_multiple_of(1_000_000) {
173        3
174    } else if nanos.is_multiple_of(1_000) {
175        6
176    } else {
177        9
178    }
179}
180
181/// Why a timestamp could not be parsed.
182#[derive(Clone, Debug, PartialEq, Eq)]
183pub struct InvalidDateTime(String);
184
185impl InvalidDateTime {
186    fn new(message: impl Into<String>) -> Self {
187        Self(message.into())
188    }
189}
190
191impl fmt::Display for InvalidDateTime {
192    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193        write!(f, "invalid OCPI DateTime: {}", self.0)
194    }
195}
196
197impl std::error::Error for InvalidDateTime {}
198
199#[allow(clippy::too_many_lines)]
200fn parse_ocpi_datetime(text: &str) -> Result<DateTime, InvalidDateTime> {
201    let bytes = text.as_bytes();
202    let err = |m: &str| InvalidDateTime::new(format!("{m} in {text:?}"));
203
204    if bytes.len() < 19 {
205        return Err(err("too short for YYYY-MM-DDTHH:MM:SS"));
206    }
207    let digits = |from: usize, len: usize| -> Result<u32, InvalidDateTime> {
208        let slice = text.get(from..from + len).ok_or_else(|| err("truncated"))?;
209        if !slice.bytes().all(|b| b.is_ascii_digit()) {
210            return Err(err("expected digits"));
211        }
212        slice.parse::<u32>().map_err(|_| err("expected digits"))
213    };
214    if bytes[4] != b'-' || bytes[7] != b'-' {
215        return Err(err("expected YYYY-MM-DD"));
216    }
217    if bytes[13] != b':' || bytes[16] != b':' {
218        return Err(err("expected HH:MM:SS"));
219    }
220
221    let mut canonical = true;
222    match bytes[10] {
223        b'T' => {}
224        b't' | b' ' => canonical = false,
225        _ => return Err(err("expected 'T' between date and time")),
226    }
227
228    let year = i32::try_from(digits(0, 4)?).map_err(|_| err("year out of range"))?;
229    let month = u8::try_from(digits(5, 2)?).map_err(|_| err("month out of range"))?;
230    let day = u8::try_from(digits(8, 2)?).map_err(|_| err("day out of range"))?;
231    let hour = u8::try_from(digits(11, 2)?).map_err(|_| err("hour out of range"))?;
232    let minute = u8::try_from(digits(14, 2)?).map_err(|_| err("minute out of range"))?;
233    let second = u8::try_from(digits(17, 2)?).map_err(|_| err("second out of range"))?;
234
235    let mut idx = 19;
236    let mut nanos: u32 = 0;
237    let mut frac_digits: u8 = 0;
238    if bytes.get(idx) == Some(&b'.') || bytes.get(idx) == Some(&b',') {
239        if bytes[idx] == b',' {
240            canonical = false;
241        }
242        idx += 1;
243        let start = idx;
244        while bytes.get(idx).is_some_and(u8::is_ascii_digit) {
245            idx += 1;
246        }
247        if idx == start {
248            return Err(err("fractional separator with no digits"));
249        }
250        let raw = &text[start..idx];
251        // More digits than a `u8` can count is still "more than nine", so it clamps and is
252        // flagged like any other over-long fraction rather than falling through as canonical.
253        frac_digits = u8::try_from(raw.len()).unwrap_or(u8::MAX);
254        // Scale the first nine digits to nanoseconds; ignore any beyond.
255        let mut scaled = String::with_capacity(9);
256        scaled.push_str(&raw[..raw.len().min(9)]);
257        while scaled.len() < 9 {
258            scaled.push('0');
259        }
260        nanos = scaled.parse().map_err(|_| err("bad fractional seconds"))?;
261        if frac_digits > 9 {
262            frac_digits = 9;
263            canonical = false;
264        }
265    }
266
267    let offset_minutes: i32 = match bytes.get(idx) {
268        None => {
269            // "The absence of the timezone designator implies a UTC timestamp."
270            0
271        }
272        Some(b'Z') => {
273            idx += 1;
274            0
275        }
276        Some(b'z') => {
277            canonical = false;
278            idx += 1;
279            0
280        }
281        Some(sign @ (b'+' | b'-')) => {
282            // Spec: "+00:00 is not the same as UTC" — accepted, but not canonical.
283            canonical = false;
284            let sign = if *sign == b'-' { -1 } else { 1 };
285            idx += 1;
286            let oh = i32::try_from(digits(idx, 2)?).map_err(|_| err("offset out of range"))?;
287            idx += 2;
288            if bytes.get(idx) == Some(&b':') {
289                idx += 1;
290            }
291            let om = i32::try_from(digits(idx, 2)?).map_err(|_| err("offset out of range"))?;
292            idx += 2;
293            sign * (oh * 60 + om)
294        }
295        Some(_) => return Err(err("unexpected trailing characters")),
296    };
297    if idx != bytes.len() {
298        return Err(err("unexpected trailing characters"));
299    }
300
301    let date = time::Date::from_calendar_date(
302        year,
303        time::Month::try_from(month).map_err(|_| err("month out of range"))?,
304        day,
305    )
306    .map_err(|_| err("no such calendar date"))?;
307    // RFC 3339 allows second 60 for leap seconds; clamp to 59 as `time` has no leap seconds.
308    let (second, leap) = if second == 60 { (59, true) } else { (second, false) };
309    let time_of_day =
310        time::Time::from_hms_nano(hour, minute, second, nanos).map_err(|_| err("no such time of day"))?;
311    if leap {
312        canonical = false;
313    }
314    let naive = PrimitiveDateTime::new(date, time_of_day);
315    let offset =
316        UtcOffset::from_whole_seconds(offset_minutes * 60).map_err(|_| err("offset out of range"))?;
317    let instant = naive.assume_offset(offset).to_offset(UtcOffset::UTC);
318
319    Ok(DateTime { instant, frac_digits, canonical })
320}
321
322impl fmt::Display for DateTime {
323    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
324        let d = self.instant.date();
325        let t = self.instant.time();
326        write!(
327            f,
328            "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
329            d.year(),
330            u8::from(d.month()),
331            d.day(),
332            t.hour(),
333            t.minute(),
334            t.second()
335        )?;
336        if self.frac_digits > 0 {
337            let nanos = t.nanosecond();
338            let text = format!("{nanos:09}");
339            f.write_str(".")?;
340            f.write_str(&text[..usize::from(self.frac_digits)])?;
341        }
342        f.write_str("Z")
343    }
344}
345
346impl fmt::Debug for DateTime {
347    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
348        write!(f, "DateTime({self})")
349    }
350}
351
352impl PartialEq for DateTime {
353    fn eq(&self, other: &Self) -> bool {
354        self.instant == other.instant
355    }
356}
357impl Eq for DateTime {}
358
359impl PartialOrd for DateTime {
360    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
361        Some(self.cmp(other))
362    }
363}
364impl Ord for DateTime {
365    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
366        self.instant.cmp(&other.instant)
367    }
368}
369impl core::hash::Hash for DateTime {
370    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
371        self.instant.hash(state);
372    }
373}
374
375impl From<OffsetDateTime> for DateTime {
376    fn from(value: OffsetDateTime) -> Self {
377        Self::from_utc(value)
378    }
379}
380
381impl From<DateTime> for OffsetDateTime {
382    fn from(value: DateTime) -> Self {
383        value.instant
384    }
385}
386
387impl FromStr for DateTime {
388    type Err = InvalidDateTime;
389    fn from_str(s: &str) -> Result<Self, Self::Err> {
390        parse_ocpi_datetime(s)
391    }
392}
393
394impl TryFrom<&str> for DateTime {
395    type Error = InvalidDateTime;
396    fn try_from(s: &str) -> Result<Self, Self::Error> {
397        parse_ocpi_datetime(s)
398    }
399}
400
401impl Validate for DateTime {
402    fn validate_in(&self, v: &mut Validator) {
403        if !self.canonical {
404            v.report(
405                ViolationCode::Inconsistent,
406                "timestamp was not written in one of the six forms OCPI allows \
407                 (an explicit UTC offset, a lower-case 'z' or a space separator was used); \
408                 note that the spec states \"+00:00 is not the same as UTC\"",
409            );
410        }
411    }
412}
413
414impl Serialize for DateTime {
415    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
416        serializer.collect_str(self)
417    }
418}
419
420impl<'de> Deserialize<'de> for DateTime {
421    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
422        struct V;
423        impl serde::de::Visitor<'_> for V {
424            type Value = DateTime;
425            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
426                f.write_str("an RFC 3339 UTC timestamp such as \"2015-06-29T20:39:09Z\"")
427            }
428            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<DateTime, E> {
429                parse_ocpi_datetime(v).map_err(E::custom)
430            }
431        }
432        deserializer.deserialize_str(V)
433    }
434}
435
436#[cfg(feature = "schema")]
437impl schemars::JsonSchema for DateTime {
438    fn schema_name() -> std::borrow::Cow<'static, str> {
439        "DateTime".into()
440    }
441    fn json_schema(_g: &mut schemars::SchemaGenerator) -> schemars::Schema {
442        schemars::json_schema!({
443            "type": "string",
444            "format": "date-time",
445            "maxLength": 25,
446            "description": "OCPI DateTime: RFC 3339, always UTC",
447        })
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    #[test]
456    fn parses_all_six_spec_forms() {
457        for (text, expect) in [
458            ("2015-06-29T20:39:09Z", "2015-06-29T20:39:09Z"),
459            ("2015-06-29T20:39:09", "2015-06-29T20:39:09Z"),
460            ("2016-12-29T17:45:09.2Z", "2016-12-29T17:45:09.2Z"),
461            ("2016-12-29T17:45:09.2", "2016-12-29T17:45:09.2Z"),
462            ("2018-01-01T01:08:01.123Z", "2018-01-01T01:08:01.123Z"),
463            ("2018-01-01T01:08:01.123", "2018-01-01T01:08:01.123Z"),
464        ] {
465            let dt: DateTime = text.parse().unwrap();
466            assert!(dt.is_canonical(), "{text} should be canonical");
467            assert_eq!(dt.to_string(), expect, "round-trip of {text}");
468        }
469    }
470
471    #[test]
472    fn explicit_offsets_are_converted_and_flagged() {
473        let dt: DateTime = "2015-06-29T22:39:09+02:00".parse().unwrap();
474        assert_eq!(dt.to_string(), "2015-06-29T20:39:09Z");
475        assert!(!dt.is_canonical());
476        let violations = dt.validate().unwrap_err();
477        assert_eq!(violations.as_slice()[0].code, ViolationCode::Inconsistent);
478
479        // The spec's own note: +00:00 is not the same as UTC.
480        let zero: DateTime = "2015-06-29T20:39:09+00:00".parse().unwrap();
481        assert_eq!(zero.to_string(), "2015-06-29T20:39:09Z");
482        assert!(!zero.is_canonical());
483    }
484
485    #[test]
486    fn equality_ignores_fractional_digit_count() {
487        let a: DateTime = "2016-12-29T17:45:09.2Z".parse().unwrap();
488        let b: DateTime = "2016-12-29T17:45:09.200Z".parse().unwrap();
489        assert_eq!(a, b);
490        assert_ne!(a.to_string(), b.to_string(), "but formatting is preserved");
491    }
492
493    #[test]
494    fn rejects_nonsense() {
495        for bad in [
496            "",
497            "2015-06-29",
498            "not a date",
499            "2015-13-01T00:00:00Z",
500            "2015-06-29T25:00:00Z",
501            "2015-06-29T20:39:09Zjunk",
502        ] {
503            assert!(bad.parse::<DateTime>().is_err(), "{bad} should not parse");
504        }
505    }
506
507    #[test]
508    fn an_over_long_fraction_is_truncated_and_flagged() {
509        // Nine digits is all a nanosecond timestamp can hold, and `string(25)` cannot carry
510        // them anyway; the instant survives, the deviation is reported.
511        let dt: DateTime = "2018-01-01T01:08:01.1234567891234Z".parse().unwrap();
512        assert_eq!(dt.fractional_digits(), 9);
513        assert!(!dt.is_canonical());
514        assert_eq!(dt.to_string(), "2018-01-01T01:08:01.123456789Z");
515
516        let absurd = format!("2018-01-01T01:08:01.{}Z", "1".repeat(300));
517        let dt: DateTime = absurd.parse().unwrap();
518        assert!(!dt.is_canonical(), "300 fractional digits is not one of the six forms");
519    }
520
521    #[test]
522    fn serde_round_trip() {
523        let json = "\"2018-01-01T01:08:01.123Z\"";
524        let dt: DateTime = serde_json::from_str(json).unwrap();
525        assert_eq!(serde_json::to_string(&dt).unwrap(), json);
526    }
527
528    #[test]
529    fn from_utc_picks_the_shortest_exact_fraction() {
530        let base = OffsetDateTime::from_unix_timestamp(1_500_000_000).unwrap();
531        assert_eq!(DateTime::from_utc(base).fractional_digits(), 0);
532        let ms = base.replace_nanosecond(120_000_000).unwrap();
533        assert_eq!(DateTime::from_utc(ms).fractional_digits(), 3);
534        let us = base.replace_nanosecond(120_000_100).unwrap();
535        assert_eq!(DateTime::from_utc(us).fractional_digits(), 9);
536    }
537}