Skip to main content

nodedb_types/datetime/
timestamp.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Microseconds-precision UTC timestamp type.
4
5use serde::{Deserialize, Serialize};
6
7use super::duration::NdbDuration;
8use super::error::NdbDateTimeError;
9
10/// Microseconds-precision UTC timestamp.
11///
12/// Stores microseconds since Unix epoch as i64. Supports dates from
13/// ~292,000 years BCE to ~292,000 years CE.
14///
15/// String format: ISO 8601 `"2024-03-15T10:30:00.000000Z"`.
16///
17/// `#[non_exhaustive]` — a timezone offset field may be added when
18/// named-timezone support is introduced.
19#[non_exhaustive]
20#[derive(
21    Debug,
22    Clone,
23    Copy,
24    PartialEq,
25    Eq,
26    PartialOrd,
27    Ord,
28    Hash,
29    Serialize,
30    Deserialize,
31    zerompk::ToMessagePack,
32    zerompk::FromMessagePack,
33)]
34pub struct NdbDateTime {
35    /// Microseconds since Unix epoch (1970-01-01T00:00:00Z).
36    pub micros: i64,
37}
38
39impl NdbDateTime {
40    /// Create from microseconds since epoch.
41    pub fn from_micros(micros: i64) -> Self {
42        Self { micros }
43    }
44
45    /// Create from milliseconds since epoch.
46    ///
47    /// Returns `Err` if `millis * 1_000` overflows `i64`.
48    pub fn from_millis(millis: i64) -> Result<Self, NdbDateTimeError> {
49        let micros = millis
50            .checked_mul(1_000)
51            .ok_or(NdbDateTimeError::Overflow {
52                input: millis,
53                unit: "millis",
54            })?;
55        Ok(Self { micros })
56    }
57
58    /// Create from seconds since epoch.
59    ///
60    /// Returns `Err` if `secs * 1_000_000` overflows `i64`.
61    pub fn from_secs(secs: i64) -> Result<Self, NdbDateTimeError> {
62        let micros = secs
63            .checked_mul(1_000_000)
64            .ok_or(NdbDateTimeError::Overflow {
65                input: secs,
66                unit: "secs",
67            })?;
68        Ok(Self { micros })
69    }
70
71    /// Current UTC time.
72    ///
73    /// Converts `SystemTime` microseconds (`u128`) to `i64`. Saturates at
74    /// `i64::MAX` (year ~292,277 CE) rather than wrapping — clocks that far
75    /// in the future simply report the maximum representable timestamp.
76    pub fn now() -> Self {
77        let dur = std::time::SystemTime::now()
78            .duration_since(std::time::UNIX_EPOCH)
79            .unwrap_or_else(|_| {
80                use std::sync::atomic::{AtomicBool, Ordering};
81                static LOGGED: AtomicBool = AtomicBool::new(false);
82                if !LOGGED.swap(true, Ordering::Relaxed) {
83                    tracing::error!(
84                        module = module_path!(),
85                        "system clock is before UNIX_EPOCH; using 0 (epoch) \
86                         — check NTP/RTC configuration"
87                    );
88                }
89                std::time::Duration::ZERO
90            });
91        Self {
92            micros: i64::try_from(dur.as_micros()).unwrap_or(i64::MAX),
93        }
94    }
95
96    /// Extract year, month, day, hour, minute, second components.
97    pub fn components(&self) -> DateTimeComponents {
98        let total_secs = self.micros / 1_000_000;
99        let micros_rem = (self.micros % 1_000_000).unsigned_abs();
100
101        // Civil date from Unix timestamp (algorithm from Howard Hinnant).
102        let mut days = total_secs.div_euclid(86400) as i32;
103        let day_secs = total_secs.rem_euclid(86400) as u32;
104
105        days += 719_468; // shift epoch from 1970-01-01 to 0000-03-01
106        let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
107        let doe = (days - era * 146_097) as u32;
108        let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
109        let y = yoe as i32 + era * 400;
110        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
111        let mp = (5 * doy + 2) / 153;
112        let d = doy - (153 * mp + 2) / 5 + 1;
113        let m = if mp < 10 { mp + 3 } else { mp - 9 };
114        let year = if m <= 2 { y + 1 } else { y };
115
116        DateTimeComponents {
117            year,
118            month: m as u8,
119            day: d as u8,
120            hour: (day_secs / 3600) as u8,
121            minute: ((day_secs % 3600) / 60) as u8,
122            second: (day_secs % 60) as u8,
123            microsecond: micros_rem as u32,
124        }
125    }
126
127    /// Format as ISO 8601 string: `"2024-03-15T10:30:00.000000Z"`.
128    pub fn to_iso8601(&self) -> String {
129        let c = self.components();
130        format!(
131            "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:06}Z",
132            c.year, c.month, c.day, c.hour, c.minute, c.second, c.microsecond
133        )
134    }
135
136    /// Parse from ISO 8601 text.
137    ///
138    /// Accepts `"2024-03-15T10:30:00Z"`, `"2024-03-15 10:30:00"`,
139    /// `"2024-03-15T10:30:00.123456Z"`, `"2024-03-15"` (midnight UTC), and
140    /// a trailing UTC offset in place of `Z` — `+05:30`, `-0800`, `+02` —
141    /// which shifts the result to UTC. Seconds are optional. Every component
142    /// must parse whole: trailing characters after the seconds, the fraction
143    /// or the offset are refused rather than dropped, so an unrecognised
144    /// spelling is `None`, never a silently different instant.
145    pub fn parse(s: &str) -> Option<Self> {
146        let (body, offset_secs) = split_utc_offset(s.trim())?;
147
148        if body.len() == 10 {
149            // Date only: "2024-03-15" → midnight UTC.
150            let (year, month, day) = parse_civil_date(body)?;
151            return Self::from_civil(year, month, day, 0, 0, 0, 0)?.shift_secs(-offset_secs);
152        }
153
154        // Full: "2024-03-15T10:30:00" or "2024-03-15T10:30:00.123456"
155        let (date_part, time_part) = body.split_once('T').or_else(|| body.split_once(' '))?;
156        let (year, month, day) = parse_civil_date(date_part)?;
157
158        let (time_main, frac) = match time_part.split_once('.') {
159            Some((t, f)) => (t, Some(f)),
160            None => (time_part, None),
161        };
162        let time_parts: Vec<&str> = time_main.split(':').collect();
163        if time_parts.len() < 2 || time_parts.len() > 3 {
164            return None;
165        }
166        let hour: u32 = time_parts[0].parse().ok()?;
167        let minute: u32 = time_parts[1].parse().ok()?;
168        let second: u32 = match time_parts.get(2) {
169            Some(text) => text.parse().ok()?,
170            None => 0,
171        };
172
173        // Fractional seconds: one to nine digits, read to microseconds.
174        let micros: u32 = match frac {
175            Some(digits) => {
176                if digits.is_empty()
177                    || digits.len() > 9
178                    || !digits.bytes().all(|b| b.is_ascii_digit())
179                {
180                    return None;
181                }
182                let padded = format!("{digits:0<6}");
183                padded[..6].parse().ok()?
184            }
185            None => 0,
186        };
187
188        Self::from_civil(year, month, day, hour, minute, second, micros)?.shift_secs(-offset_secs)
189    }
190
191    /// Shift by whole seconds, or `None` on overflow.
192    fn shift_secs(self, secs: i64) -> Option<Self> {
193        let micros = self.micros.checked_add(secs.checked_mul(1_000_000)?)?;
194        Some(Self { micros })
195    }
196
197    /// Build from civil date components.
198    ///
199    /// Returns `None` if any intermediate multiplication overflows `i64`.
200    fn from_civil(
201        year: i32,
202        month: u32,
203        day: u32,
204        hour: u32,
205        minute: u32,
206        second: u32,
207        micros: u32,
208    ) -> Option<Self> {
209        // Inverse of the Hinnant algorithm.
210        let y = if month <= 2 { year - 1 } else { year };
211        let m = if month <= 2 { month + 9 } else { month - 3 };
212        let era = if y >= 0 { y } else { y - 399 } / 400;
213        let yoe = (y - era * 400) as u32;
214        let doy = (153 * m + 2) / 5 + day - 1;
215        let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
216        let days = (era as i64)
217            .checked_mul(146_097)?
218            .checked_add(doe as i64)?
219            .checked_sub(719_468)?;
220        let total_secs = days
221            .checked_mul(86400)?
222            .checked_add(hour as i64 * 3600)?
223            .checked_add(minute as i64 * 60)?
224            .checked_add(second as i64)?;
225        let result_micros = total_secs
226            .checked_mul(1_000_000)?
227            .checked_add(micros as i64)?;
228        Some(Self {
229            micros: result_micros,
230        })
231    }
232
233    /// Add a duration.
234    ///
235    /// Returns `Err` if the result overflows `i64`.
236    pub fn add_duration(&self, d: NdbDuration) -> Result<Self, NdbDateTimeError> {
237        let micros = self
238            .micros
239            .checked_add(d.micros)
240            .ok_or(NdbDateTimeError::AddOverflow)?;
241        Ok(Self { micros })
242    }
243
244    /// Subtract a duration.
245    ///
246    /// Returns `Err` if the result overflows `i64`.
247    pub fn sub_duration(&self, d: NdbDuration) -> Result<Self, NdbDateTimeError> {
248        let micros = self
249            .micros
250            .checked_sub(d.micros)
251            .ok_or(NdbDateTimeError::SubOverflow)?;
252        Ok(Self { micros })
253    }
254
255    /// Duration between two timestamps (self - other).
256    ///
257    /// Returns `Err` if the result overflows `i64`.
258    pub fn duration_since(&self, other: &NdbDateTime) -> Result<NdbDuration, NdbDateTimeError> {
259        let micros = self
260            .micros
261            .checked_sub(other.micros)
262            .ok_or(NdbDateTimeError::SubOverflow)?;
263        Ok(NdbDuration { micros })
264    }
265
266    /// Unix epoch seconds.
267    pub fn unix_secs(&self) -> i64 {
268        self.micros / 1_000_000
269    }
270
271    /// Unix epoch milliseconds.
272    pub fn unix_millis(&self) -> i64 {
273        self.micros / 1_000
274    }
275}
276
277/// Split a trailing `Z` or `±HH[:MM]` / `±HHMM` UTC offset off `s`, returning
278/// the remaining text and the offset in seconds east of UTC (`0` when there
279/// is none). A `-` inside the date part is never read as an offset: only a
280/// sign after the last `T` / ` ` time separator counts.
281fn split_utc_offset(s: &str) -> Option<(&str, i64)> {
282    if let Some(body) = s.strip_suffix('Z').or_else(|| s.strip_suffix('z')) {
283        return Some((body, 0));
284    }
285    // The offset can only follow the time part, or a bare `YYYY-MM-DD`.
286    let time_start = match s.rfind(['T', ' ']) {
287        Some(i) => i + 1,
288        None => {
289            let at = s.len().min(10);
290            if !s.is_char_boundary(at) {
291                return None;
292            }
293            at
294        }
295    };
296    let Some(sign_at) = s[time_start..].rfind(['+', '-']).map(|i| time_start + i) else {
297        return Some((s, 0));
298    };
299    let sign: i64 = if s.as_bytes()[sign_at] == b'-' { -1 } else { 1 };
300    let rest = &s[sign_at + 1..];
301    let (hh, mm) = match (rest.len(), rest.as_bytes().get(2)) {
302        (2, None) => (rest, "0"),
303        (4, Some(b)) if b.is_ascii_digit() => (&rest[..2], &rest[2..]),
304        (5, Some(&b':')) => (&rest[..2], &rest[3..]),
305        _ => return None,
306    };
307    if !hh.bytes().all(|b| b.is_ascii_digit()) || !mm.bytes().all(|b| b.is_ascii_digit()) {
308        return None;
309    }
310    let hours: i64 = hh.parse().ok()?;
311    let minutes: i64 = mm.parse().ok()?;
312    if hours > 23 || minutes > 59 {
313        return None;
314    }
315    Some((&s[..sign_at], sign * (hours * 3600 + minutes * 60)))
316}
317
318/// Parse `YYYY-MM-DD` into its three components.
319fn parse_civil_date(s: &str) -> Option<(i32, u32, u32)> {
320    let parts: Vec<&str> = s.split('-').collect();
321    if parts.len() != 3 {
322        return None;
323    }
324    Some((
325        parts[0].parse().ok()?,
326        parts[1].parse().ok()?,
327        parts[2].parse().ok()?,
328    ))
329}
330
331impl std::fmt::Display for NdbDateTime {
332    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333        f.write_str(&self.to_iso8601())
334    }
335}
336
337/// Components of a civil date-time.
338#[derive(Debug, Clone, Copy)]
339pub struct DateTimeComponents {
340    pub year: i32,
341    pub month: u8,
342    pub day: u8,
343    pub hour: u8,
344    pub minute: u8,
345    pub second: u8,
346    pub microsecond: u32,
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn datetime_now_roundtrip() {
355        let dt = NdbDateTime::now();
356        let iso = dt.to_iso8601();
357        let parsed = NdbDateTime::parse(&iso).unwrap();
358        // Allow 1 microsecond rounding difference.
359        assert!(
360            (dt.micros - parsed.micros).abs() <= 1,
361            "dt={}, parsed={}",
362            dt.micros,
363            parsed.micros
364        );
365    }
366
367    #[test]
368    fn datetime_epoch() {
369        let dt = NdbDateTime::from_micros(0);
370        assert_eq!(dt.to_iso8601(), "1970-01-01T00:00:00.000000Z");
371    }
372
373    #[test]
374    fn datetime_known_date() {
375        let dt = NdbDateTime::parse("2024-03-15T10:30:00Z").unwrap();
376        let c = dt.components();
377        assert_eq!(c.year, 2024);
378        assert_eq!(c.month, 3);
379        assert_eq!(c.day, 15);
380        assert_eq!(c.hour, 10);
381        assert_eq!(c.minute, 30);
382        assert_eq!(c.second, 0);
383    }
384
385    #[test]
386    fn datetime_fractional_seconds() {
387        let dt = NdbDateTime::parse("2024-01-01T00:00:00.123456Z").unwrap();
388        let c = dt.components();
389        assert_eq!(c.microsecond, 123456);
390    }
391
392    /// A trailing offset shifts the instant to UTC; a space separator and
393    /// missing seconds are accepted.
394    #[test]
395    fn datetime_parse_applies_utc_offset() {
396        let utc = NdbDateTime::parse("2024-06-15T06:30:00Z").unwrap();
397        assert_eq!(
398            NdbDateTime::parse("2024-06-15 12:00:00+05:30").unwrap(),
399            utc
400        );
401        assert_eq!(NdbDateTime::parse("2024-06-15T12:00:00+0530").unwrap(), utc);
402        assert_eq!(
403            NdbDateTime::parse("2024-06-15T04:30:00-02:00").unwrap(),
404            utc
405        );
406        assert_eq!(NdbDateTime::parse("2024-06-15T04:30-02").unwrap(), utc);
407        assert_eq!(
408            NdbDateTime::parse("2024-06-15T06:30:00.250+00:00").unwrap(),
409            NdbDateTime::from_micros(utc.micros + 250_000)
410        );
411        assert_eq!(
412            NdbDateTime::parse("2024-06-16+05:30").unwrap(),
413            NdbDateTime::parse("2024-06-15T18:30:00Z").unwrap()
414        );
415    }
416
417    /// Text that is not a whole timestamp is refused, never read as a
418    /// different instant.
419    #[test]
420    fn datetime_parse_refuses_partial_spellings() {
421        for text in [
422            "yesterday",
423            "1583402400000000",
424            "2024-06-15T12:00:00+05:30:00",
425            "2024-06-15T12:00:xx",
426            "2024-06-15T12:00:00.abc",
427            "2024-06-15T12:00:00.",
428            "2024-06-15T12:00:00.1234567890",
429            "2024-06-15T12",
430            "2024-06-15T12:00:00+25:00",
431        ] {
432            assert!(
433                NdbDateTime::parse(text).is_none(),
434                "{text:?} must not parse"
435            );
436        }
437    }
438
439    #[test]
440    fn datetime_date_only() {
441        let dt = NdbDateTime::parse("2024-03-15").unwrap();
442        let c = dt.components();
443        assert_eq!(c.year, 2024);
444        assert_eq!(c.month, 3);
445        assert_eq!(c.day, 15);
446        assert_eq!(c.hour, 0);
447    }
448
449    #[test]
450    fn datetime_arithmetic() {
451        let dt = NdbDateTime::parse("2024-01-01T00:00:00Z").unwrap();
452        let later = dt
453            .add_duration(NdbDuration::from_hours(24).expect("24 hours in range"))
454            .expect("add_duration in range");
455        let c = later.components();
456        assert_eq!(c.day, 2);
457    }
458
459    #[test]
460    fn datetime_ordering() {
461        let a = NdbDateTime::parse("2024-01-01T00:00:00Z").unwrap();
462        let b = NdbDateTime::parse("2024-01-02T00:00:00Z").unwrap();
463        assert!(a < b);
464    }
465
466    #[test]
467    fn unix_accessors() {
468        let dt = NdbDateTime::from_secs(1_700_000_000).expect("known unix timestamp in range");
469        assert_eq!(dt.unix_secs(), 1_700_000_000);
470        assert_eq!(dt.unix_millis(), 1_700_000_000_000);
471    }
472
473    #[test]
474    fn datetime_from_millis_overflow() {
475        assert!(NdbDateTime::from_millis(i64::MAX).is_err());
476        assert_eq!(
477            NdbDateTime::from_millis(i64::MAX),
478            Err(NdbDateTimeError::Overflow {
479                input: i64::MAX,
480                unit: "millis"
481            })
482        );
483    }
484
485    #[test]
486    fn datetime_from_secs_overflow() {
487        assert!(NdbDateTime::from_secs(i64::MAX).is_err());
488        assert_eq!(
489            NdbDateTime::from_secs(i64::MAX),
490            Err(NdbDateTimeError::Overflow {
491                input: i64::MAX,
492                unit: "secs"
493            })
494        );
495    }
496
497    #[test]
498    fn add_duration_overflow() {
499        let dt = NdbDateTime::from_micros(i64::MAX);
500        let one_us = NdbDuration::from_micros(1);
501        assert_eq!(dt.add_duration(one_us), Err(NdbDateTimeError::AddOverflow));
502    }
503
504    #[test]
505    fn sub_duration_overflow() {
506        let dt = NdbDateTime::from_micros(i64::MIN);
507        let one_us = NdbDuration::from_micros(1);
508        assert_eq!(dt.sub_duration(one_us), Err(NdbDateTimeError::SubOverflow));
509    }
510
511    #[test]
512    fn duration_since_overflow() {
513        let a = NdbDateTime::from_micros(i64::MIN);
514        let b = NdbDateTime::from_micros(i64::MAX);
515        // i64::MIN - i64::MAX overflows
516        assert_eq!(a.duration_since(&b), Err(NdbDateTimeError::SubOverflow));
517    }
518
519    #[test]
520    fn now_returns_positive() {
521        // Sanity: current time is after epoch.
522        let dt = NdbDateTime::now();
523        assert!(dt.micros > 0, "now() returned non-positive: {}", dt.micros);
524    }
525}