Skip to main content

codetics_ocpp_core/
datetime.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Kevin Vavelin, Codetics (https://codetics.fr)
3
4//! Minimal RFC 3339 date-time type, sufficient for OCPP `dateTime` fields.
5//!
6//! Stored as microseconds since the Unix epoch in UTC. Parsing is lenient
7//! where real-world charging stations are known to deviate (missing offset,
8//! lowercase `t`/`z`, space separator, arbitrary fraction length); formatting
9//! is strict: `YYYY-MM-DDTHH:MM:SS.mmmZ`.
10
11use std::fmt;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14/// `Default` is the Unix epoch.
15#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
16pub struct DateTime {
17    micros: i64,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct ParseError(&'static str);
22
23impl fmt::Display for ParseError {
24    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25        write!(f, "invalid date-time: {}", self.0)
26    }
27}
28
29impl std::error::Error for ParseError {}
30
31impl DateTime {
32    pub const UNIX_EPOCH: DateTime = DateTime { micros: 0 };
33
34    pub fn now() -> Self {
35        let d = SystemTime::now()
36            .duration_since(UNIX_EPOCH)
37            .unwrap_or_default();
38        DateTime {
39            micros: d.as_micros() as i64,
40        }
41    }
42
43    pub const fn from_unix_micros(micros: i64) -> Self {
44        DateTime { micros }
45    }
46
47    pub const fn from_unix_seconds(secs: i64) -> Self {
48        DateTime {
49            micros: secs * 1_000_000,
50        }
51    }
52
53    pub const fn unix_micros(&self) -> i64 {
54        self.micros
55    }
56
57    pub const fn unix_seconds(&self) -> i64 {
58        self.micros.div_euclid(1_000_000)
59    }
60
61    pub fn add_seconds(&self, secs: i64) -> Self {
62        DateTime {
63            micros: self.micros + secs * 1_000_000,
64        }
65    }
66
67    /// Builds a date-time from UTC calendar fields. Returns `None` on out-of-range fields.
68    pub fn from_utc_fields(
69        year: i64,
70        month: u32,
71        day: u32,
72        hour: u32,
73        min: u32,
74        sec: u32,
75        micros: u32,
76    ) -> Option<Self> {
77        if !(1..=12).contains(&month)
78            || day == 0
79            || day > days_in_month(year, month)
80            || hour > 23
81            || min > 59
82            || sec > 60
83            || micros > 999_999
84        {
85            return None;
86        }
87        // Leap second (sec == 60) is folded into the next second.
88        let days = days_from_civil(year, month, day);
89        let secs = days * 86_400 + hour as i64 * 3_600 + min as i64 * 60 + sec as i64;
90        Some(DateTime {
91            micros: secs * 1_000_000 + micros as i64,
92        })
93    }
94
95    /// Returns (year, month, day, hour, minute, second, microsecond) in UTC.
96    pub fn to_utc_fields(&self) -> (i64, u32, u32, u32, u32, u32, u32) {
97        let secs = self.micros.div_euclid(1_000_000);
98        let micros = self.micros.rem_euclid(1_000_000) as u32;
99        let days = secs.div_euclid(86_400);
100        let sod = secs.rem_euclid(86_400) as u32;
101        let (y, m, d) = civil_from_days(days);
102        (y, m, d, sod / 3_600, (sod % 3_600) / 60, sod % 60, micros)
103    }
104
105    pub fn parse(s: &str) -> Result<Self, ParseError> {
106        let b = s.trim().as_bytes();
107        let mut p = Cursor { b, i: 0 };
108        let year = p.digits(4).ok_or(ParseError("year"))? as i64;
109        p.expect(b'-')
110            .ok_or(ParseError("expected '-' after year"))?;
111        let month = p.digits(2).ok_or(ParseError("month"))?;
112        p.expect(b'-')
113            .ok_or(ParseError("expected '-' after month"))?;
114        let day = p.digits(2).ok_or(ParseError("day"))?;
115        match p.next() {
116            Some(b'T' | b't' | b' ') => {}
117            _ => return Err(ParseError("expected 'T' separator")),
118        }
119        let hour = p.digits(2).ok_or(ParseError("hour"))?;
120        p.expect(b':')
121            .ok_or(ParseError("expected ':' after hour"))?;
122        let min = p.digits(2).ok_or(ParseError("minute"))?;
123        // Seconds are mandatory in RFC 3339, but some stations omit them.
124        let sec = if p.peek() == Some(b':') {
125            p.i += 1;
126            p.digits(2).ok_or(ParseError("second"))?
127        } else {
128            0
129        };
130        let mut micros = 0u32;
131        if matches!(p.peek(), Some(b'.' | b',')) {
132            p.i += 1;
133            let start = p.i;
134            let mut scale = 100_000u32;
135            while let Some(c @ b'0'..=b'9') = p.peek() {
136                micros += (c - b'0') as u32 * scale;
137                scale /= 10;
138                p.i += 1;
139            }
140            if p.i == start {
141                return Err(ParseError("empty fraction"));
142            }
143        }
144        let offset_secs: i64 = match p.next() {
145            // No offset: interpreted as UTC (tolerated, common in OCPP 1.6 stations).
146            None => 0,
147            Some(b'Z' | b'z') => 0,
148            Some(sign @ (b'+' | b'-')) => {
149                let oh = p.digits(2).ok_or(ParseError("offset hour"))? as i64;
150                if p.peek() == Some(b':') {
151                    p.i += 1;
152                }
153                // `+HH` alone is accepted (PostgreSQL text output).
154                let om = if p.peek().is_some() {
155                    p.digits(2).ok_or(ParseError("offset minute"))? as i64
156                } else {
157                    0
158                };
159                if oh > 23 || om > 59 {
160                    return Err(ParseError("offset out of range"));
161                }
162                let o = oh * 3_600 + om * 60;
163                if sign == b'+' { o } else { -o }
164            }
165            Some(_) => return Err(ParseError("unexpected character")),
166        };
167        if p.i != b.len() {
168            return Err(ParseError("trailing characters"));
169        }
170        let local = DateTime::from_utc_fields(year, month, day, hour, min, sec, micros)
171            .ok_or(ParseError("field out of range"))?;
172        Ok(DateTime {
173            micros: local.micros - offset_secs * 1_000_000,
174        })
175    }
176}
177
178impl fmt::Display for DateTime {
179    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180        let (y, mo, d, h, mi, s, us) = self.to_utc_fields();
181        write!(
182            f,
183            "{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{:03}Z",
184            us / 1_000
185        )
186    }
187}
188
189impl fmt::Debug for DateTime {
190    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191        fmt::Display::fmt(self, f)
192    }
193}
194
195impl std::str::FromStr for DateTime {
196    type Err = ParseError;
197    fn from_str(s: &str) -> Result<Self, Self::Err> {
198        DateTime::parse(s)
199    }
200}
201
202impl serde::Serialize for DateTime {
203    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
204        s.collect_str(self)
205    }
206}
207
208impl<'de> serde::Deserialize<'de> for DateTime {
209    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
210        struct V;
211        impl serde::de::Visitor<'_> for V {
212            type Value = DateTime;
213            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
214                f.write_str("an RFC 3339 date-time string")
215            }
216            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<DateTime, E> {
217                DateTime::parse(v)
218                    .map_err(|_| E::invalid_value(serde::de::Unexpected::Str(v), &self))
219            }
220        }
221        d.deserialize_str(V)
222    }
223}
224
225struct Cursor<'a> {
226    b: &'a [u8],
227    i: usize,
228}
229
230impl Cursor<'_> {
231    fn peek(&self) -> Option<u8> {
232        self.b.get(self.i).copied()
233    }
234    fn next(&mut self) -> Option<u8> {
235        let c = self.peek()?;
236        self.i += 1;
237        Some(c)
238    }
239    fn expect(&mut self, c: u8) -> Option<()> {
240        (self.next()? == c).then_some(())
241    }
242    fn digits(&mut self, n: usize) -> Option<u32> {
243        let mut v = 0u32;
244        for _ in 0..n {
245            let c = self.next()?;
246            if !c.is_ascii_digit() {
247                return None;
248            }
249            v = v * 10 + (c - b'0') as u32;
250        }
251        Some(v)
252    }
253}
254
255fn is_leap(y: i64) -> bool {
256    (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
257}
258
259fn days_in_month(y: i64, m: u32) -> u32 {
260    match m {
261        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
262        4 | 6 | 9 | 11 => 30,
263        2 if is_leap(y) => 29,
264        2 => 28,
265        _ => 0,
266    }
267}
268
269// Howard Hinnant's `days_from_civil` / `civil_from_days` algorithms.
270fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
271    let y = if m <= 2 { y - 1 } else { y };
272    let era = y.div_euclid(400);
273    let yoe = y - era * 400;
274    let m = m as i64;
275    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d as i64 - 1;
276    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
277    era * 146_097 + doe - 719_468
278}
279
280fn civil_from_days(z: i64) -> (i64, u32, u32) {
281    let z = z + 719_468;
282    let era = z.div_euclid(146_097);
283    let doe = z - era * 146_097;
284    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
285    let y = yoe + era * 400;
286    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
287    let mp = (5 * doy + 2) / 153;
288    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
289    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
290    (if m <= 2 { y + 1 } else { y }, m, d)
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn roundtrip_and_formats() {
299        let t = DateTime::parse("2013-02-01T20:53:32.486Z").unwrap();
300        assert_eq!(t.to_string(), "2013-02-01T20:53:32.486Z");
301        assert_eq!(t.unix_seconds(), 1_359_752_012);
302        let o = DateTime::parse("2013-02-01T22:53:32.486+02:00").unwrap();
303        assert_eq!(t, o);
304        assert_eq!(
305            DateTime::parse("2013-02-01T20:53:32")
306                .unwrap()
307                .unix_seconds(),
308            1_359_752_012
309        );
310        assert_eq!(
311            DateTime::parse("2013-02-01t20:53:32.123456789z")
312                .unwrap()
313                .unix_micros()
314                % 1_000_000,
315            123_456
316        );
317        assert_eq!(
318            DateTime::parse("1970-01-01T00:00:00Z").unwrap(),
319            DateTime::UNIX_EPOCH
320        );
321        assert_eq!(
322            DateTime::parse("2024-02-29T00:00:00Z").unwrap().to_string(),
323            "2024-02-29T00:00:00.000Z"
324        );
325        assert_eq!(
326            DateTime::parse("1969-12-31T23:59:59Z")
327                .unwrap()
328                .unix_seconds(),
329            -1
330        );
331        assert_eq!(DateTime::parse("2013-02-01 21:53:32.486+01").unwrap(), t);
332    }
333
334    #[test]
335    fn rejects_garbage() {
336        for s in [
337            "",
338            "2023-13-01T00:00:00Z",
339            "2023-02-29T00:00:00Z",
340            "2023-01-01",
341            "2023-01-01T00:00:00Zjunk",
342            "2023-01-01T00:00:00.Z",
343        ] {
344            assert!(DateTime::parse(s).is_err(), "{s}");
345        }
346    }
347
348    #[test]
349    fn calendar_consistency() {
350        for days in (-800_000i64..800_000).step_by(997) {
351            let (y, m, d) = civil_from_days(days);
352            assert_eq!(days_from_civil(y, m, d), days);
353        }
354    }
355}