codetics-ocpp-core 0.1.0

Codetics OCPP: version-independent OCPP-J building blocks (RPC framing, error codes, RFC 3339 date-time, validation, subprotocol negotiation, message ids)
Documentation
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Kevin Vavelin, Codetics (https://codetics.fr)

//! Minimal RFC 3339 date-time type, sufficient for OCPP `dateTime` fields.
//!
//! Stored as microseconds since the Unix epoch in UTC. Parsing is lenient
//! where real-world charging stations are known to deviate (missing offset,
//! lowercase `t`/`z`, space separator, arbitrary fraction length); formatting
//! is strict: `YYYY-MM-DDTHH:MM:SS.mmmZ`.

use std::fmt;
use std::time::{SystemTime, UNIX_EPOCH};

/// `Default` is the Unix epoch.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub struct DateTime {
    micros: i64,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseError(&'static str);

impl fmt::Display for ParseError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "invalid date-time: {}", self.0)
    }
}

impl std::error::Error for ParseError {}

impl DateTime {
    pub const UNIX_EPOCH: DateTime = DateTime { micros: 0 };

    pub fn now() -> Self {
        let d = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default();
        DateTime {
            micros: d.as_micros() as i64,
        }
    }

    pub const fn from_unix_micros(micros: i64) -> Self {
        DateTime { micros }
    }

    pub const fn from_unix_seconds(secs: i64) -> Self {
        DateTime {
            micros: secs * 1_000_000,
        }
    }

    pub const fn unix_micros(&self) -> i64 {
        self.micros
    }

    pub const fn unix_seconds(&self) -> i64 {
        self.micros.div_euclid(1_000_000)
    }

    pub fn add_seconds(&self, secs: i64) -> Self {
        DateTime {
            micros: self.micros + secs * 1_000_000,
        }
    }

    /// Builds a date-time from UTC calendar fields. Returns `None` on out-of-range fields.
    pub fn from_utc_fields(
        year: i64,
        month: u32,
        day: u32,
        hour: u32,
        min: u32,
        sec: u32,
        micros: u32,
    ) -> Option<Self> {
        if !(1..=12).contains(&month)
            || day == 0
            || day > days_in_month(year, month)
            || hour > 23
            || min > 59
            || sec > 60
            || micros > 999_999
        {
            return None;
        }
        // Leap second (sec == 60) is folded into the next second.
        let days = days_from_civil(year, month, day);
        let secs = days * 86_400 + hour as i64 * 3_600 + min as i64 * 60 + sec as i64;
        Some(DateTime {
            micros: secs * 1_000_000 + micros as i64,
        })
    }

    /// Returns (year, month, day, hour, minute, second, microsecond) in UTC.
    pub fn to_utc_fields(&self) -> (i64, u32, u32, u32, u32, u32, u32) {
        let secs = self.micros.div_euclid(1_000_000);
        let micros = self.micros.rem_euclid(1_000_000) as u32;
        let days = secs.div_euclid(86_400);
        let sod = secs.rem_euclid(86_400) as u32;
        let (y, m, d) = civil_from_days(days);
        (y, m, d, sod / 3_600, (sod % 3_600) / 60, sod % 60, micros)
    }

    pub fn parse(s: &str) -> Result<Self, ParseError> {
        let b = s.trim().as_bytes();
        let mut p = Cursor { b, i: 0 };
        let year = p.digits(4).ok_or(ParseError("year"))? as i64;
        p.expect(b'-')
            .ok_or(ParseError("expected '-' after year"))?;
        let month = p.digits(2).ok_or(ParseError("month"))?;
        p.expect(b'-')
            .ok_or(ParseError("expected '-' after month"))?;
        let day = p.digits(2).ok_or(ParseError("day"))?;
        match p.next() {
            Some(b'T' | b't' | b' ') => {}
            _ => return Err(ParseError("expected 'T' separator")),
        }
        let hour = p.digits(2).ok_or(ParseError("hour"))?;
        p.expect(b':')
            .ok_or(ParseError("expected ':' after hour"))?;
        let min = p.digits(2).ok_or(ParseError("minute"))?;
        // Seconds are mandatory in RFC 3339, but some stations omit them.
        let sec = if p.peek() == Some(b':') {
            p.i += 1;
            p.digits(2).ok_or(ParseError("second"))?
        } else {
            0
        };
        let mut micros = 0u32;
        if matches!(p.peek(), Some(b'.' | b',')) {
            p.i += 1;
            let start = p.i;
            let mut scale = 100_000u32;
            while let Some(c @ b'0'..=b'9') = p.peek() {
                micros += (c - b'0') as u32 * scale;
                scale /= 10;
                p.i += 1;
            }
            if p.i == start {
                return Err(ParseError("empty fraction"));
            }
        }
        let offset_secs: i64 = match p.next() {
            // No offset: interpreted as UTC (tolerated, common in OCPP 1.6 stations).
            None => 0,
            Some(b'Z' | b'z') => 0,
            Some(sign @ (b'+' | b'-')) => {
                let oh = p.digits(2).ok_or(ParseError("offset hour"))? as i64;
                if p.peek() == Some(b':') {
                    p.i += 1;
                }
                // `+HH` alone is accepted (PostgreSQL text output).
                let om = if p.peek().is_some() {
                    p.digits(2).ok_or(ParseError("offset minute"))? as i64
                } else {
                    0
                };
                if oh > 23 || om > 59 {
                    return Err(ParseError("offset out of range"));
                }
                let o = oh * 3_600 + om * 60;
                if sign == b'+' { o } else { -o }
            }
            Some(_) => return Err(ParseError("unexpected character")),
        };
        if p.i != b.len() {
            return Err(ParseError("trailing characters"));
        }
        let local = DateTime::from_utc_fields(year, month, day, hour, min, sec, micros)
            .ok_or(ParseError("field out of range"))?;
        Ok(DateTime {
            micros: local.micros - offset_secs * 1_000_000,
        })
    }
}

impl fmt::Display for DateTime {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let (y, mo, d, h, mi, s, us) = self.to_utc_fields();
        write!(
            f,
            "{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{:03}Z",
            us / 1_000
        )
    }
}

impl fmt::Debug for DateTime {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        fmt::Display::fmt(self, f)
    }
}

impl std::str::FromStr for DateTime {
    type Err = ParseError;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        DateTime::parse(s)
    }
}

impl serde::Serialize for DateTime {
    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        s.collect_str(self)
    }
}

impl<'de> serde::Deserialize<'de> for DateTime {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        struct V;
        impl serde::de::Visitor<'_> for V {
            type Value = DateTime;
            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
                f.write_str("an RFC 3339 date-time string")
            }
            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<DateTime, E> {
                DateTime::parse(v)
                    .map_err(|_| E::invalid_value(serde::de::Unexpected::Str(v), &self))
            }
        }
        d.deserialize_str(V)
    }
}

struct Cursor<'a> {
    b: &'a [u8],
    i: usize,
}

impl Cursor<'_> {
    fn peek(&self) -> Option<u8> {
        self.b.get(self.i).copied()
    }
    fn next(&mut self) -> Option<u8> {
        let c = self.peek()?;
        self.i += 1;
        Some(c)
    }
    fn expect(&mut self, c: u8) -> Option<()> {
        (self.next()? == c).then_some(())
    }
    fn digits(&mut self, n: usize) -> Option<u32> {
        let mut v = 0u32;
        for _ in 0..n {
            let c = self.next()?;
            if !c.is_ascii_digit() {
                return None;
            }
            v = v * 10 + (c - b'0') as u32;
        }
        Some(v)
    }
}

fn is_leap(y: i64) -> bool {
    (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
}

fn days_in_month(y: i64, m: u32) -> u32 {
    match m {
        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
        4 | 6 | 9 | 11 => 30,
        2 if is_leap(y) => 29,
        2 => 28,
        _ => 0,
    }
}

// Howard Hinnant's `days_from_civil` / `civil_from_days` algorithms.
fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
    let y = if m <= 2 { y - 1 } else { y };
    let era = y.div_euclid(400);
    let yoe = y - era * 400;
    let m = m as i64;
    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d as i64 - 1;
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
    era * 146_097 + doe - 719_468
}

fn civil_from_days(z: i64) -> (i64, u32, u32) {
    let z = z + 719_468;
    let era = z.div_euclid(146_097);
    let doe = z - era * 146_097;
    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
    (if m <= 2 { y + 1 } else { y }, m, d)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn roundtrip_and_formats() {
        let t = DateTime::parse("2013-02-01T20:53:32.486Z").unwrap();
        assert_eq!(t.to_string(), "2013-02-01T20:53:32.486Z");
        assert_eq!(t.unix_seconds(), 1_359_752_012);
        let o = DateTime::parse("2013-02-01T22:53:32.486+02:00").unwrap();
        assert_eq!(t, o);
        assert_eq!(
            DateTime::parse("2013-02-01T20:53:32")
                .unwrap()
                .unix_seconds(),
            1_359_752_012
        );
        assert_eq!(
            DateTime::parse("2013-02-01t20:53:32.123456789z")
                .unwrap()
                .unix_micros()
                % 1_000_000,
            123_456
        );
        assert_eq!(
            DateTime::parse("1970-01-01T00:00:00Z").unwrap(),
            DateTime::UNIX_EPOCH
        );
        assert_eq!(
            DateTime::parse("2024-02-29T00:00:00Z").unwrap().to_string(),
            "2024-02-29T00:00:00.000Z"
        );
        assert_eq!(
            DateTime::parse("1969-12-31T23:59:59Z")
                .unwrap()
                .unix_seconds(),
            -1
        );
        assert_eq!(DateTime::parse("2013-02-01 21:53:32.486+01").unwrap(), t);
    }

    #[test]
    fn rejects_garbage() {
        for s in [
            "",
            "2023-13-01T00:00:00Z",
            "2023-02-29T00:00:00Z",
            "2023-01-01",
            "2023-01-01T00:00:00Zjunk",
            "2023-01-01T00:00:00.Z",
        ] {
            assert!(DateTime::parse(s).is_err(), "{s}");
        }
    }

    #[test]
    fn calendar_consistency() {
        for days in (-800_000i64..800_000).step_by(997) {
            let (y, m, d) = civil_from_days(days);
            assert_eq!(days_from_civil(y, m, d), days);
        }
    }
}