Skip to main content

ocpp_types/
timestamp.rs

1//! A fixed-size timestamp for OCPP's `dateTime` fields.
2//!
3//! Every OCPP version types its timestamps identically -- `{"type":
4//! "string", "format": "date-time"}` -- and not one of the 142 such fields
5//! across 1.6J, 2.0.1 and 2.1 declares a `maxLength`. Treated as an
6//! unbounded string, each reserved the generator's 1024-byte default, which
7//! is how `v16::HeartbeatResponse` came to be 1,032 bytes to carry a
8//! 24-character instant.
9//!
10//! [`OcppTimestamp`] stores the instant decomposed instead: 16 bytes, no
11//! allocator, and comparable, which a string is not.
12//!
13//! # Representation
14//!
15//! The instant is Unix seconds plus nanoseconds, with the UTC offset kept
16//! alongside so a local-time timestamp survives a round trip in the form the
17//! peer sent it. The offset is *presentation*, not identity: two values
18//! naming the same instant in different offsets are equal and compare equal,
19//! because they are the same moment. Use [`OcppTimestamp::utc_offset_minutes`]
20//! when the written form matters.
21//!
22//! # Format
23//!
24//! Parsing accepts RFC 3339 as the specification requires, with `T`/`t`/` `
25//! as the date-time separator and `Z`/`z` or `±HH:MM` as the offset.
26//! Fractional seconds of any length are accepted and kept to nanosecond
27//! precision.
28//!
29//! Writing emits a canonical RFC 3339 form: `Z` for UTC, `±HH:MM` otherwise,
30//! and fractional seconds only when non-zero (trimmed to milliseconds when
31//! they divide evenly, which is what OCPP deployments use in practice).
32
33use core::fmt;
34
35/// The widest string [`OcppTimestamp::to_rfc3339`] can produce:
36/// `-262143-01-01T00:00:00.123456789+01:00`. Callers sizing their own buffer
37/// should use this.
38pub const MAX_RFC3339_LEN: usize = 40;
39
40/// A `dateTime` value from any OCPP version.
41///
42/// See this module's documentation for the representation and its
43/// equality semantics.
44#[derive(Debug, Clone, Copy)]
45pub struct OcppTimestamp {
46    /// Seconds since the Unix epoch, UTC.
47    secs: i64,
48    /// Nanoseconds within the second, `0..1_000_000_000`.
49    nanos: u32,
50    /// Offset from UTC in minutes, as written by the peer.
51    offset_minutes: i16,
52}
53
54/// Why a string is not an OCPP `dateTime`.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
56pub enum TimestampError {
57    /// Not RFC 3339 shaped -- wrong length, wrong separators, or a
58    /// non-digit where a digit belongs.
59    Malformed,
60    /// Shaped correctly but not a real instant, e.g. `2024-02-30` or an
61    /// hour of 24.
62    OutOfRange,
63}
64
65impl fmt::Display for TimestampError {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        f.write_str(match self {
68            Self::Malformed => "not an RFC 3339 date-time",
69            Self::OutOfRange => "RFC 3339 date-time with an out-of-range field",
70        })
71    }
72}
73
74impl core::error::Error for TimestampError {}
75
76/// Days from the Unix epoch to the given civil date, by Howard Hinnant's
77/// `days_from_civil`. Valid for any proleptic Gregorian date; the era
78/// arithmetic relies on truncating division, which is what Rust's `/` does.
79const fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
80    let year = year - if month <= 2 { 1 } else { 0 };
81    let era = if year >= 0 { year } else { year - 399 } / 400;
82    let year_of_era = year - era * 400;
83    let month = month as i64;
84    let day_of_year = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day as i64 - 1;
85    let day_of_era =
86        year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
87
88    era * 146097 + day_of_era - 719468
89}
90
91/// Inverse of [`days_from_civil`].
92const fn civil_from_days(days: i64) -> (i64, u32, u32) {
93    let days = days + 719468;
94    let era = if days >= 0 { days } else { days - 146096 } / 146097;
95    let day_of_era = days - era * 146097;
96    let year_of_era = (day_of_era - day_of_era / 1460 + day_of_era / 36524
97        - day_of_era / 146096)
98        / 365;
99    let year = year_of_era + era * 400;
100    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
101    let mp = (5 * day_of_year + 2) / 153;
102    let day = (day_of_year - (153 * mp + 2) / 5 + 1) as u32;
103    let month = (mp + if mp < 10 { 3 } else { -9 }) as u32;
104
105    (year + if month <= 2 { 1 } else { 0 }, month, day)
106}
107
108const fn is_leap(year: i64) -> bool {
109    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
110}
111
112const fn days_in_month(year: i64, month: u32) -> u32 {
113    match month {
114        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
115        4 | 6 | 9 | 11 => 30,
116        2 if is_leap(year) => 29,
117        2 => 28,
118        _ => 0,
119    }
120}
121
122impl OcppTimestamp {
123    /// The Unix epoch, `1970-01-01T00:00:00Z`.
124    pub const UNIX_EPOCH: Self = Self {
125        secs: 0,
126        nanos: 0,
127        offset_minutes: 0,
128    };
129
130    /// Builds a UTC timestamp from Unix seconds and nanoseconds.
131    ///
132    /// Returns [`TimestampError::OutOfRange`] if `nanos` is not a valid
133    /// subsecond value.
134    pub const fn from_unix(secs: i64, nanos: u32) -> Result<Self, TimestampError> {
135        if nanos >= 1_000_000_000 {
136            return Err(TimestampError::OutOfRange);
137        }
138
139        Ok(Self {
140            secs,
141            nanos,
142            offset_minutes: 0,
143        })
144    }
145
146    /// Seconds since the Unix epoch, UTC, regardless of the written offset.
147    pub const fn unix_seconds(&self) -> i64 {
148        self.secs
149    }
150
151    /// Nanoseconds within the second.
152    pub const fn subsec_nanos(&self) -> u32 {
153        self.nanos
154    }
155
156    /// The UTC offset this timestamp was written with, in minutes. Zero for
157    /// a `Z` timestamp, and for anything built from Unix time.
158    pub const fn utc_offset_minutes(&self) -> i16 {
159        self.offset_minutes
160    }
161
162    /// Returns the same instant, to be written with `offset_minutes` instead.
163    ///
164    /// Returns [`TimestampError::OutOfRange`] for an offset beyond ±24 hours.
165    pub const fn with_utc_offset_minutes(self, offset_minutes: i16) -> Result<Self, TimestampError> {
166        if offset_minutes <= -1440 || offset_minutes >= 1440 {
167            return Err(TimestampError::OutOfRange);
168        }
169
170        Ok(Self {
171            offset_minutes,
172            ..self
173        })
174    }
175
176    /// Parses an RFC 3339 date-time, as every OCPP version's `dateTime`
177    /// fields carry.
178    pub fn parse_rfc3339(text: &str) -> Result<Self, TimestampError> {
179        let bytes = text.as_bytes();
180
181        // `YYYY-MM-DDTHH:MM:SS` is the shortest legal prefix, and an offset
182        // (`Z` at minimum) is required by RFC 3339.
183        if bytes.len() < 20 {
184            return Err(TimestampError::Malformed);
185        }
186
187        let year = parse_number(&bytes[0..4])? as i64;
188        expect(bytes[4], b'-')?;
189        let month = parse_number(&bytes[5..7])?;
190        expect(bytes[7], b'-')?;
191        let day = parse_number(&bytes[8..10])?;
192
193        match bytes[10] {
194            b'T' | b't' | b' ' => {}
195            _ => return Err(TimestampError::Malformed),
196        }
197
198        let hour = parse_number(&bytes[11..13])?;
199        expect(bytes[13], b':')?;
200        let minute = parse_number(&bytes[14..16])?;
201        expect(bytes[16], b':')?;
202        let second = parse_number(&bytes[17..19])?;
203
204        let mut cursor = 19;
205        let mut nanos = 0u32;
206
207        if bytes[cursor] == b'.' || bytes[cursor] == b',' {
208            cursor += 1;
209            let start = cursor;
210
211            while cursor < bytes.len() && bytes[cursor].is_ascii_digit() {
212                // Only the first nine digits are representable; the rest are
213                // still consumed so the offset parses from the right place.
214                if cursor - start < 9 {
215                    nanos = nanos * 10 + u32::from(bytes[cursor] - b'0');
216                }
217                cursor += 1;
218            }
219
220            let digits = cursor - start;
221
222            if digits == 0 {
223                return Err(TimestampError::Malformed);
224            }
225
226            // Scale a short fraction up to nanoseconds: `.5` is 500_000_000.
227            for _ in digits.min(9)..9 {
228                nanos *= 10;
229            }
230        }
231
232        if cursor >= bytes.len() {
233            return Err(TimestampError::Malformed);
234        }
235
236        let offset_minutes = match bytes[cursor] {
237            b'Z' | b'z' if cursor + 1 == bytes.len() => 0i32,
238            b'+' | b'-' => {
239                if cursor + 6 != bytes.len() {
240                    return Err(TimestampError::Malformed);
241                }
242
243                let sign = if bytes[cursor] == b'-' { -1i32 } else { 1 };
244                let offset_hour = parse_number(&bytes[cursor + 1..cursor + 3])? as i32;
245                expect(bytes[cursor + 3], b':')?;
246                let offset_minute = parse_number(&bytes[cursor + 4..cursor + 6])? as i32;
247
248                if offset_hour > 23 || offset_minute > 59 {
249                    return Err(TimestampError::OutOfRange);
250                }
251
252                sign * (offset_hour * 60 + offset_minute)
253            }
254            _ => return Err(TimestampError::Malformed),
255        };
256
257        if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) {
258            return Err(TimestampError::OutOfRange);
259        }
260
261        // A leap second is reported as `:60`. There is no representation for
262        // it, so it clamps to the last representable instant of the minute
263        // rather than being rejected -- a peer may legitimately send one.
264        if hour > 23 || minute > 59 || second > 60 {
265            return Err(TimestampError::OutOfRange);
266        }
267
268        let second = second.min(59);
269        let days = days_from_civil(year, month, day);
270        let secs = days * 86_400
271            + i64::from(hour) * 3600
272            + i64::from(minute) * 60
273            + i64::from(second)
274            - i64::from(offset_minutes) * 60;
275
276        Ok(Self {
277            secs,
278            nanos,
279            offset_minutes: offset_minutes as i16,
280        })
281    }
282
283    /// Writes this timestamp as RFC 3339 into `buf`, returning the written
284    /// slice.
285    ///
286    /// `buf` must be at least [`MAX_RFC3339_LEN`] bytes; a shorter buffer
287    /// yields `None` rather than a truncated timestamp.
288    pub fn to_rfc3339<'buf>(&self, buf: &'buf mut [u8]) -> Option<&'buf str> {
289        if buf.len() < MAX_RFC3339_LEN {
290            return None;
291        }
292
293        // Render in the written offset, not UTC.
294        let local = self.secs + i64::from(self.offset_minutes) * 60;
295        let days = local.div_euclid(86_400);
296        let time_of_day = local.rem_euclid(86_400);
297        let (year, month, day) = civil_from_days(days);
298
299        let mut at = 0;
300
301        write_year(buf, &mut at, year);
302        buf[at] = b'-';
303        at += 1;
304        write_two(buf, &mut at, month as u64);
305        buf[at] = b'-';
306        at += 1;
307        write_two(buf, &mut at, day as u64);
308        buf[at] = b'T';
309        at += 1;
310        write_two(buf, &mut at, (time_of_day / 3600) as u64);
311        buf[at] = b':';
312        at += 1;
313        write_two(buf, &mut at, (time_of_day % 3600 / 60) as u64);
314        buf[at] = b':';
315        at += 1;
316        write_two(buf, &mut at, (time_of_day % 60) as u64);
317
318        if self.nanos != 0 {
319            buf[at] = b'.';
320            at += 1;
321
322            // Milliseconds when they divide evenly, which is what OCPP
323            // deployments emit; full nanosecond precision otherwise.
324            if self.nanos.is_multiple_of(1_000_000) {
325                write_padded(buf, &mut at, u64::from(self.nanos / 1_000_000), 3);
326            } else {
327                write_padded(buf, &mut at, u64::from(self.nanos), 9);
328            }
329        }
330
331        if self.offset_minutes == 0 {
332            buf[at] = b'Z';
333            at += 1;
334        } else {
335            let (sign, magnitude) = if self.offset_minutes < 0 {
336                (b'-', -i32::from(self.offset_minutes))
337            } else {
338                (b'+', i32::from(self.offset_minutes))
339            };
340
341            buf[at] = sign;
342            at += 1;
343            write_two(buf, &mut at, (magnitude / 60) as u64);
344            buf[at] = b':';
345            at += 1;
346            write_two(buf, &mut at, (magnitude % 60) as u64);
347        }
348
349        core::str::from_utf8(&buf[..at]).ok()
350    }
351}
352
353fn expect(actual: u8, expected: u8) -> Result<(), TimestampError> {
354    if actual == expected {
355        Ok(())
356    } else {
357        Err(TimestampError::Malformed)
358    }
359}
360
361fn parse_number(bytes: &[u8]) -> Result<u32, TimestampError> {
362    let mut value = 0u32;
363
364    for &byte in bytes {
365        if !byte.is_ascii_digit() {
366            return Err(TimestampError::Malformed);
367        }
368
369        value = value * 10 + u32::from(byte - b'0');
370    }
371
372    Ok(value)
373}
374
375fn write_two(buf: &mut [u8], at: &mut usize, value: u64) {
376    write_padded(buf, at, value, 2);
377}
378
379fn write_padded(buf: &mut [u8], at: &mut usize, value: u64, width: usize) {
380    let mut digits = [0u8; 20];
381    let mut count = 0;
382    let mut value = value;
383
384    while value > 0 {
385        digits[count] = b'0' + (value % 10) as u8;
386        value /= 10;
387        count += 1;
388    }
389
390    for _ in count..width {
391        buf[*at] = b'0';
392        *at += 1;
393    }
394
395    for index in (0..count).rev() {
396        buf[*at] = digits[index];
397        *at += 1;
398    }
399}
400
401fn write_year(buf: &mut [u8], at: &mut usize, year: i64) {
402    if year < 0 {
403        buf[*at] = b'-';
404        *at += 1;
405        write_padded(buf, at, year.unsigned_abs(), 4);
406    } else {
407        write_padded(buf, at, year as u64, 4);
408    }
409}
410
411impl fmt::Display for OcppTimestamp {
412    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
413        let mut buf = [0u8; MAX_RFC3339_LEN];
414
415        match self.to_rfc3339(&mut buf) {
416            Some(text) => f.write_str(text),
417            None => Err(fmt::Error),
418        }
419    }
420}
421
422impl core::str::FromStr for OcppTimestamp {
423    type Err = TimestampError;
424
425    fn from_str(text: &str) -> Result<Self, Self::Err> {
426        Self::parse_rfc3339(text)
427    }
428}
429
430impl TryFrom<&str> for OcppTimestamp {
431    type Error = TimestampError;
432
433    fn try_from(text: &str) -> Result<Self, Self::Error> {
434        Self::parse_rfc3339(text)
435    }
436}
437
438// Identity is the instant, not the way it was written: `2024-01-01T00:00:00Z`
439// and `2024-01-01T01:00:00+01:00` are the same moment. Comparing the written
440// offset too would make `Ord` disagree with what a reader means by "earlier".
441impl PartialEq for OcppTimestamp {
442    fn eq(&self, other: &Self) -> bool {
443        self.secs == other.secs && self.nanos == other.nanos
444    }
445}
446
447impl Eq for OcppTimestamp {}
448
449impl core::hash::Hash for OcppTimestamp {
450    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
451        self.secs.hash(state);
452        self.nanos.hash(state);
453    }
454}
455
456impl PartialOrd for OcppTimestamp {
457    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
458        Some(self.cmp(other))
459    }
460}
461
462impl Ord for OcppTimestamp {
463    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
464        self.secs
465            .cmp(&other.secs)
466            .then(self.nanos.cmp(&other.nanos))
467    }
468}
469
470#[cfg(feature = "serde")]
471impl serde::Serialize for OcppTimestamp {
472    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
473        let mut buf = [0u8; MAX_RFC3339_LEN];
474        let text = self
475            .to_rfc3339(&mut buf)
476            .ok_or_else(|| serde::ser::Error::custom("timestamp is not representable"))?;
477
478        serializer.serialize_str(text)
479    }
480}
481
482#[cfg(feature = "serde")]
483impl<'de> serde::Deserialize<'de> for OcppTimestamp {
484    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
485        struct Visitor;
486
487        impl<'v> serde::de::Visitor<'v> for Visitor {
488            type Value = OcppTimestamp;
489
490            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
491                f.write_str("an RFC 3339 date-time")
492            }
493
494            fn visit_str<E: serde::de::Error>(self, text: &str) -> Result<Self::Value, E> {
495                OcppTimestamp::parse_rfc3339(text).map_err(serde::de::Error::custom)
496            }
497        }
498
499        deserializer.deserialize_str(Visitor)
500    }
501}
502
503/// Conversions to and from [`chrono`], for callers that already use it.
504///
505/// Deliberately an optional feature rather than the crate's representation:
506/// chrono's own serde support formats through an allocating `to_rfc3339`,
507/// which the no-`alloc` build -- the one these sizes matter most for -- cannot
508/// use. Keeping chrono at the boundary gives its ergonomics to callers who
509/// want them without putting an allocator on the path of everyone else.
510#[cfg(feature = "chrono")]
511mod chrono_interop {
512    use super::OcppTimestamp;
513    use chrono::{DateTime, FixedOffset, TimeZone, Utc};
514
515    impl From<OcppTimestamp> for DateTime<Utc> {
516        fn from(value: OcppTimestamp) -> Self {
517            Utc.timestamp_opt(value.unix_seconds(), value.subsec_nanos())
518                .single()
519                .expect("an OcppTimestamp always names exactly one UTC instant")
520        }
521    }
522
523    impl From<DateTime<Utc>> for OcppTimestamp {
524        fn from(value: DateTime<Utc>) -> Self {
525            OcppTimestamp::from_unix(value.timestamp(), value.timestamp_subsec_nanos())
526                .expect("chrono keeps subsec nanos below one second")
527        }
528    }
529
530    impl From<DateTime<FixedOffset>> for OcppTimestamp {
531        fn from(value: DateTime<FixedOffset>) -> Self {
532            let offset_minutes = (value.offset().local_minus_utc() / 60) as i16;
533
534            OcppTimestamp::from_unix(value.timestamp(), value.timestamp_subsec_nanos())
535                .expect("chrono keeps subsec nanos below one second")
536                .with_utc_offset_minutes(offset_minutes)
537                .expect("chrono offsets are within +/- 24 hours")
538        }
539    }
540
541    impl From<OcppTimestamp> for DateTime<FixedOffset> {
542        fn from(value: OcppTimestamp) -> Self {
543            let offset = FixedOffset::east_opt(i32::from(value.utc_offset_minutes()) * 60)
544                .expect("an OcppTimestamp offset is within +/- 24 hours");
545
546            DateTime::<Utc>::from(value).with_timezone(&offset)
547        }
548    }
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    fn rendered(text: &str) -> heapless::String<MAX_RFC3339_LEN> {
556        let parsed = OcppTimestamp::parse_rfc3339(text).expect(text);
557        let mut buf = [0u8; MAX_RFC3339_LEN];
558
559        heapless::String::try_from(parsed.to_rfc3339(&mut buf).unwrap()).unwrap()
560    }
561
562    /// Anchors against instants whose Unix time is independently known, so a
563    /// wrong civil-date algorithm can't pass by being self-consistent.
564    #[test]
565    fn parses_known_instants_to_their_known_unix_time() {
566        for (text, secs) in [
567            ("1970-01-01T00:00:00Z", 0),
568            ("2000-03-01T00:00:00Z", 951_868_800),
569            ("2024-02-29T12:00:00Z", 1_709_208_000),
570            ("2038-01-19T03:14:07Z", 2_147_483_647),
571            ("1969-12-31T23:59:59Z", -1),
572            ("1900-01-01T00:00:00Z", -2_208_988_800),
573        ] {
574            let parsed = OcppTimestamp::parse_rfc3339(text).expect(text);
575            assert_eq!(parsed.unix_seconds(), secs, "for {text}");
576        }
577    }
578
579    #[test]
580    fn round_trips_every_shape_the_spec_allows() {
581        for text in [
582            "2024-01-01T00:00:00Z",
583            "2024-06-15T12:34:56Z",
584            "2024-06-15T12:34:56.789Z",
585            "1970-01-01T00:00:00Z",
586            "2038-01-19T03:14:07Z",
587        ] {
588            assert_eq!(rendered(text).as_str(), text);
589        }
590    }
591
592    #[test]
593    fn accepts_the_separators_and_cases_rfc3339_permits() {
594        let canonical = OcppTimestamp::parse_rfc3339("2024-01-01T00:00:00Z").unwrap();
595
596        for text in [
597            "2024-01-01t00:00:00Z",
598            "2024-01-01 00:00:00Z",
599            "2024-01-01T00:00:00z",
600        ] {
601            assert_eq!(OcppTimestamp::parse_rfc3339(text).unwrap(), canonical, "for {text}");
602        }
603    }
604
605    #[test]
606    fn an_offset_names_the_same_instant_as_its_utc_form() {
607        let with_offset = OcppTimestamp::parse_rfc3339("2024-01-01T01:00:00+01:00").unwrap();
608        let utc = OcppTimestamp::parse_rfc3339("2024-01-01T00:00:00Z").unwrap();
609
610        assert_eq!(with_offset, utc);
611        assert_eq!(with_offset.unix_seconds(), utc.unix_seconds());
612        // ...but the written form is preserved, not normalized to UTC.
613        assert_eq!(with_offset.utc_offset_minutes(), 60);
614        assert_eq!(rendered("2024-01-01T01:00:00+01:00").as_str(), "2024-01-01T01:00:00+01:00");
615        assert_eq!(rendered("2023-12-31T19:00:00-05:00").as_str(), "2023-12-31T19:00:00-05:00");
616        assert_eq!(rendered("2024-01-01T00:30:00+05:30").as_str(), "2024-01-01T00:30:00+05:30");
617    }
618
619    /// A fraction is scaled by its digit count, not read as an integer:
620    /// `.5` is 500ms, not 5ns.
621    #[test]
622    fn scales_fractional_seconds_by_their_length() {
623        for (text, nanos) in [
624            ("2024-01-01T00:00:00.5Z", 500_000_000),
625            ("2024-01-01T00:00:00.05Z", 50_000_000),
626            ("2024-01-01T00:00:00.123Z", 123_000_000),
627            ("2024-01-01T00:00:00.000001Z", 1_000),
628            ("2024-01-01T00:00:00.123456789Z", 123_456_789),
629        ] {
630            assert_eq!(
631                OcppTimestamp::parse_rfc3339(text).unwrap().subsec_nanos(),
632                nanos,
633                "for {text}"
634            );
635        }
636
637        // More than nanosecond precision is truncated, not rejected, and the
638        // offset after it still parses.
639        let over = OcppTimestamp::parse_rfc3339("2024-01-01T00:00:00.1234567891Z").unwrap();
640        assert_eq!(over.subsec_nanos(), 123_456_789);
641    }
642
643    #[test]
644    fn writes_milliseconds_when_they_divide_evenly_and_nanoseconds_otherwise() {
645        assert_eq!(rendered("2024-01-01T00:00:00.250Z").as_str(), "2024-01-01T00:00:00.250Z");
646        assert_eq!(
647            rendered("2024-01-01T00:00:00.123456789Z").as_str(),
648            "2024-01-01T00:00:00.123456789Z"
649        );
650        // A zero fraction is omitted entirely.
651        assert_eq!(rendered("2024-01-01T00:00:00.000Z").as_str(), "2024-01-01T00:00:00Z");
652    }
653
654    #[test]
655    fn rejects_dates_that_do_not_exist() {
656        for text in [
657            "2023-02-29T00:00:00Z", // 2023 is not a leap year
658            "1900-02-29T00:00:00Z", // divisible by 100, not by 400
659            "2024-13-01T00:00:00Z",
660            "2024-04-31T00:00:00Z",
661            "2024-01-01T24:00:00Z",
662            "2024-01-01T00:60:00Z",
663            "2024-01-01T00:00:00+24:00",
664        ] {
665            assert_eq!(
666                OcppTimestamp::parse_rfc3339(text),
667                Err(TimestampError::OutOfRange),
668                "for {text}"
669            );
670        }
671
672        // ...but 2000 and 2024 *are* leap years.
673        assert!(OcppTimestamp::parse_rfc3339("2000-02-29T00:00:00Z").is_ok());
674        assert!(OcppTimestamp::parse_rfc3339("2024-02-29T00:00:00Z").is_ok());
675    }
676
677    #[test]
678    fn rejects_strings_that_are_not_rfc3339() {
679        for text in [
680            "",
681            "2024-01-01",
682            "2024-01-01T00:00:00",       // no offset
683            "2024/01/01T00:00:00Z",
684            "2024-01-01T00:00:00.Z",     // empty fraction
685            "2024-01-01T00:00:00+0100",  // offset needs a colon
686            "2024-01-01T00:00:00Zextra",
687            "not-a-date-at-all!!!",
688        ] {
689            assert_eq!(
690                OcppTimestamp::parse_rfc3339(text),
691                Err(TimestampError::Malformed),
692                "for {text}"
693            );
694        }
695    }
696
697    /// A leap second is a real thing a peer may send; there is no instant to
698    /// map it to, so it clamps rather than failing the whole message.
699    #[test]
700    fn clamps_a_leap_second_rather_than_rejecting_it() {
701        let parsed = OcppTimestamp::parse_rfc3339("2016-12-31T23:59:60Z").unwrap();
702
703        assert_eq!(
704            parsed,
705            OcppTimestamp::parse_rfc3339("2016-12-31T23:59:59Z").unwrap()
706        );
707    }
708
709    #[test]
710    fn orders_by_instant_regardless_of_written_offset() {
711        let earlier = OcppTimestamp::parse_rfc3339("2024-01-01T00:00:00Z").unwrap();
712        let later = OcppTimestamp::parse_rfc3339("2024-01-01T00:00:01Z").unwrap();
713        let same_instant_other_offset =
714            OcppTimestamp::parse_rfc3339("2024-01-01T01:00:00+01:00").unwrap();
715
716        assert!(earlier < later);
717        assert_eq!(earlier.cmp(&same_instant_other_offset), core::cmp::Ordering::Equal);
718    }
719
720    #[test]
721    fn round_trips_across_a_wide_span_of_days() {
722        // Every 37th day for ~200 years, to exercise leap years, century
723        // rules and the pre-epoch branch of the civil-date algorithm.
724        let mut day = -36_500i64;
725
726        while day < 36_500 {
727            let stamp = OcppTimestamp::from_unix(day * 86_400 + 3661, 0).unwrap();
728            let mut buf = [0u8; MAX_RFC3339_LEN];
729            let text = stamp.to_rfc3339(&mut buf).unwrap();
730
731            assert_eq!(
732                OcppTimestamp::parse_rfc3339(text).unwrap(),
733                stamp,
734                "failed to round-trip {text}"
735            );
736
737            day += 37;
738        }
739    }
740
741    #[test]
742    fn to_rfc3339_refuses_a_buffer_it_could_overflow() {
743        let stamp = OcppTimestamp::UNIX_EPOCH;
744        let mut small = [0u8; MAX_RFC3339_LEN - 1];
745
746        assert_eq!(stamp.to_rfc3339(&mut small), None);
747    }
748
749    #[test]
750    fn is_sixteen_bytes() {
751        // The whole point: a `dateTime` field used to reserve 1024.
752        assert_eq!(core::mem::size_of::<OcppTimestamp>(), 16);
753    }
754
755    // --- OcppTimeOfDay / OcppDate ---------------------------------------
756
757    /// Rendering needs no buffer, but the tests need something to compare.
758    fn rendered_civil(value: &impl fmt::Display) -> heapless::String<16> {
759        let mut out = heapless::String::new();
760        core::fmt::Write::write_fmt(&mut out, format_args!("{value}")).unwrap();
761        out
762    }
763
764    #[test]
765    fn the_civil_types_are_as_small_as_their_fields_allow() {
766        // `HH:MM` has 1,440 distinct values, so a minute resolution needs
767        // more than a byte; two is the floor.
768        assert_eq!(core::mem::size_of::<OcppTimeOfDay>(), 2);
769        assert_eq!(core::mem::size_of::<OcppDate>(), 4);
770    }
771
772    #[test]
773    fn time_of_day_round_trips_the_specs_format() {
774        for text in ["00:00", "09:05", "13:45", "23:59"] {
775            let parsed = OcppTimeOfDay::parse(text).expect(text);
776            assert_eq!(rendered_civil(&parsed).as_str(), text);
777        }
778    }
779
780    #[test]
781    fn time_of_day_rejects_impossible_and_malformed_values() {
782        for text in ["24:00", "23:60", "99:99"] {
783            assert_eq!(OcppTimeOfDay::parse(text), Err(TimestampError::OutOfRange), "{text}");
784        }
785
786        // The spec's regex requires leading zeros and exactly `HH:MM`.
787        for text in ["", "9:05", "09:5", "09:05:00", "0905", "ab:cd", "09-05"] {
788            assert_eq!(OcppTimeOfDay::parse(text), Err(TimestampError::Malformed), "{text}");
789        }
790    }
791
792    #[test]
793    fn time_of_day_orders_chronologically() {
794        let early = OcppTimeOfDay::parse("09:05").unwrap();
795        let late = OcppTimeOfDay::parse("09:30").unwrap();
796
797        assert!(early < late);
798        assert!(OcppTimeOfDay::MIDNIGHT < early);
799        assert_eq!(late.minutes_since_midnight(), 570);
800    }
801
802    #[test]
803    fn date_round_trips_the_specs_format() {
804        for text in ["1970-01-01", "2015-12-24", "2024-02-29", "9999-12-31"] {
805            let parsed = OcppDate::parse(text).expect(text);
806            assert_eq!(rendered_civil(&parsed).as_str(), text);
807        }
808    }
809
810    #[test]
811    fn date_rejects_days_the_month_does_not_have() {
812        for text in ["2023-02-29", "1900-02-29", "2024-04-31", "2024-13-01", "2024-00-10"] {
813            assert_eq!(OcppDate::parse(text), Err(TimestampError::OutOfRange), "{text}");
814        }
815
816        assert!(OcppDate::parse("2000-02-29").is_ok());
817        assert!(OcppDate::parse("2024-02-29").is_ok());
818    }
819
820    #[test]
821    fn date_orders_chronologically_and_converts_to_epoch_days() {
822        let earlier = OcppDate::parse("2024-01-31").unwrap();
823        let later = OcppDate::parse("2024-02-01").unwrap();
824
825        assert!(earlier < later);
826        assert_eq!(OcppDate::parse("1970-01-01").unwrap().days_from_epoch(), 0);
827        assert_eq!(later.days_from_epoch() - earlier.days_from_epoch(), 1);
828    }
829
830}
831
832#[cfg(all(test, feature = "chrono"))]
833mod chrono_tests {
834    use super::*;
835    use chrono::{DateTime, FixedOffset, Utc};
836
837    #[test]
838    fn round_trips_through_chrono_utc() {
839        let ours = OcppTimestamp::parse_rfc3339("2024-06-15T12:34:56.789Z").unwrap();
840        let theirs: DateTime<Utc> = ours.into();
841
842        assert_eq!(theirs.timestamp(), ours.unix_seconds());
843        assert_eq!(theirs.timestamp_subsec_nanos(), ours.subsec_nanos());
844        assert_eq!(OcppTimestamp::from(theirs), ours);
845    }
846
847    /// The written offset is what a fixed-offset conversion must preserve --
848    /// converting through UTC and back would silently rewrite the peer's
849    /// local time as `Z`.
850    #[test]
851    fn a_fixed_offset_survives_the_round_trip() {
852        let ours = OcppTimestamp::parse_rfc3339("2024-01-01T01:00:00+01:00").unwrap();
853        let theirs: DateTime<FixedOffset> = ours.into();
854
855        assert_eq!(theirs.offset().local_minus_utc(), 3600);
856
857        let back = OcppTimestamp::from(theirs);
858        assert_eq!(back, ours);
859        assert_eq!(back.utc_offset_minutes(), 60);
860    }
861
862    #[test]
863    fn chrono_agrees_with_our_parser_on_a_span_of_instants() {
864        let mut day = -20_000i64;
865
866        while day < 20_000 {
867            let ours = OcppTimestamp::from_unix(day * 86_400 + 7261, 0).unwrap();
868            let mut buf = [0u8; MAX_RFC3339_LEN];
869            let text = ours.to_rfc3339(&mut buf).unwrap();
870
871            let theirs = DateTime::parse_from_rfc3339(text)
872                .unwrap_or_else(|e| panic!("chrono rejected our output {text}: {e}"));
873
874            assert_eq!(theirs.timestamp(), ours.unix_seconds(), "for {text}");
875            day += 61;
876        }
877    }
878}
879
880/// A local time of day, `HH:MM`.
881///
882/// 2.1's tariff conditions carry `startTimeOfDay`/`endTimeOfDay`, whose
883/// property descriptions state the format (and its regex) while the schema
884/// itself declares no `maxLength` -- so as strings they took the generator's
885/// unbounded default.
886///
887/// Two bytes: an hour and a minute. One would not do, since a minute
888/// resolution has 1,440 distinct values and a byte holds 256.
889///
890/// Ordering is chronological within the day, which is what the derive gives:
891/// the fields are declared most-significant first.
892#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
893pub struct OcppTimeOfDay {
894    hour: u8,
895    minute: u8,
896}
897
898impl OcppTimeOfDay {
899    /// Midnight, `00:00`.
900    pub const MIDNIGHT: Self = Self { hour: 0, minute: 0 };
901
902    /// Builds a time of day, rejecting an hour past 23 or a minute past 59.
903    pub const fn new(hour: u8, minute: u8) -> Result<Self, TimestampError> {
904        if hour > 23 || minute > 59 {
905            return Err(TimestampError::OutOfRange);
906        }
907
908        Ok(Self { hour, minute })
909    }
910
911    pub const fn hour(&self) -> u8 {
912        self.hour
913    }
914
915    pub const fn minute(&self) -> u8 {
916        self.minute
917    }
918
919    /// Minutes since midnight, for arithmetic against a window.
920    pub const fn minutes_since_midnight(&self) -> u16 {
921        self.hour as u16 * 60 + self.minute as u16
922    }
923
924    /// Parses `HH:MM`, as the specification's own regex describes. Leading
925    /// zeros are required, matching that regex.
926    pub fn parse(text: &str) -> Result<Self, TimestampError> {
927        let bytes = text.as_bytes();
928
929        if bytes.len() != 5 {
930            return Err(TimestampError::Malformed);
931        }
932
933        let hour = parse_number(&bytes[0..2])?;
934        expect(bytes[2], b':')?;
935        let minute = parse_number(&bytes[3..5])?;
936
937        Self::new(hour as u8, minute as u8)
938    }
939}
940
941impl fmt::Display for OcppTimeOfDay {
942    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
943        write!(f, "{:02}:{:02}", self.hour, self.minute)
944    }
945}
946
947impl core::str::FromStr for OcppTimeOfDay {
948    type Err = TimestampError;
949
950    fn from_str(text: &str) -> Result<Self, Self::Err> {
951        Self::parse(text)
952    }
953}
954
955impl TryFrom<&str> for OcppTimeOfDay {
956    type Error = TimestampError;
957
958    fn try_from(text: &str) -> Result<Self, Self::Error> {
959        Self::parse(text)
960    }
961}
962
963/// A local calendar date, `YYYY-MM-DD`.
964///
965/// The companion to [`OcppTimeOfDay`]: 2.1's `validFromDate`/`validToDate`
966/// state this format in prose and leave the property unbounded.
967///
968/// Four bytes, and chronologically ordered by the derive since the fields
969/// run most-significant first.
970#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
971pub struct OcppDate {
972    year: u16,
973    month: u8,
974    day: u8,
975}
976
977impl OcppDate {
978    /// Builds a date, rejecting a month outside 1..=12 or a day the month
979    /// doesn't have -- including 29 February in a common year.
980    pub const fn new(year: u16, month: u8, day: u8) -> Result<Self, TimestampError> {
981        if month < 1 || month > 12 || day < 1 || day > days_in_month(year as i64, month as u32) as u8
982        {
983            return Err(TimestampError::OutOfRange);
984        }
985
986        Ok(Self { year, month, day })
987    }
988
989    pub const fn year(&self) -> u16 {
990        self.year
991    }
992
993    pub const fn month(&self) -> u8 {
994        self.month
995    }
996
997    pub const fn day(&self) -> u8 {
998        self.day
999    }
1000
1001    /// Days since the Unix epoch, for arithmetic against other dates.
1002    pub const fn days_from_epoch(&self) -> i64 {
1003        days_from_civil(self.year as i64, self.month as u32, self.day as u32)
1004    }
1005
1006    /// Parses `YYYY-MM-DD`, as the specification's own regex describes.
1007    pub fn parse(text: &str) -> Result<Self, TimestampError> {
1008        let bytes = text.as_bytes();
1009
1010        if bytes.len() != 10 {
1011            return Err(TimestampError::Malformed);
1012        }
1013
1014        let year = parse_number(&bytes[0..4])?;
1015        expect(bytes[4], b'-')?;
1016        let month = parse_number(&bytes[5..7])?;
1017        expect(bytes[7], b'-')?;
1018        let day = parse_number(&bytes[8..10])?;
1019
1020        Self::new(year as u16, month as u8, day as u8)
1021    }
1022}
1023
1024impl fmt::Display for OcppDate {
1025    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1026        write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
1027    }
1028}
1029
1030impl core::str::FromStr for OcppDate {
1031    type Err = TimestampError;
1032
1033    fn from_str(text: &str) -> Result<Self, Self::Err> {
1034        Self::parse(text)
1035    }
1036}
1037
1038impl TryFrom<&str> for OcppDate {
1039    type Error = TimestampError;
1040
1041    fn try_from(text: &str) -> Result<Self, Self::Error> {
1042        Self::parse(text)
1043    }
1044}
1045
1046/// Shared by [`OcppTimeOfDay`] and [`OcppDate`]: both render short, fixed
1047/// forms, so `Display` is enough and no buffer needs threading through.
1048#[cfg(feature = "serde")]
1049macro_rules! serde_via_display {
1050    ($ty:ty, $expecting:literal) => {
1051        #[cfg(feature = "serde")]
1052        impl serde::Serialize for $ty {
1053            fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1054                serializer.collect_str(self)
1055            }
1056        }
1057
1058        #[cfg(feature = "serde")]
1059        impl<'de> serde::Deserialize<'de> for $ty {
1060            fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1061                struct Visitor;
1062
1063                impl<'v> serde::de::Visitor<'v> for Visitor {
1064                    type Value = $ty;
1065
1066                    fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1067                        f.write_str($expecting)
1068                    }
1069
1070                    fn visit_str<E: serde::de::Error>(self, text: &str) -> Result<Self::Value, E> {
1071                        <$ty>::parse(text).map_err(serde::de::Error::custom)
1072                    }
1073                }
1074
1075                deserializer.deserialize_str(Visitor)
1076            }
1077        }
1078    };
1079}
1080
1081#[cfg(feature = "serde")]
1082serde_via_display!(OcppTimeOfDay, "a time of day as HH:MM");
1083#[cfg(feature = "serde")]
1084serde_via_display!(OcppDate, "a date as YYYY-MM-DD");