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 string (basic subset).
137    ///
138    /// Supports: `"2024-03-15T10:30:00Z"`, `"2024-03-15T10:30:00.123456Z"`,
139    /// `"2024-03-15"` (midnight UTC).
140    pub fn parse(s: &str) -> Option<Self> {
141        let s = s.trim().trim_end_matches('Z').trim_end_matches('z');
142
143        if s.len() == 10 {
144            // Date only: "2024-03-15" → midnight UTC.
145            let parts: Vec<&str> = s.split('-').collect();
146            if parts.len() != 3 {
147                return None;
148            }
149            let year: i32 = parts[0].parse().ok()?;
150            let month: u32 = parts[1].parse().ok()?;
151            let day: u32 = parts[2].parse().ok()?;
152            return Self::from_civil(year, month, day, 0, 0, 0, 0);
153        }
154
155        // Full: "2024-03-15T10:30:00" or "2024-03-15T10:30:00.123456"
156        let (date_part, time_part) = s.split_once('T').or_else(|| s.split_once(' '))?;
157        let date_parts: Vec<&str> = date_part.split('-').collect();
158        if date_parts.len() != 3 {
159            return None;
160        }
161        let year: i32 = date_parts[0].parse().ok()?;
162        let month: u32 = date_parts[1].parse().ok()?;
163        let day: u32 = date_parts[2].parse().ok()?;
164
165        let (time_main, frac) = if let Some((t, f)) = time_part.split_once('.') {
166            (t, f)
167        } else {
168            (time_part, "0")
169        };
170        let time_parts: Vec<&str> = time_main.split(':').collect();
171        if time_parts.len() < 2 {
172            return None;
173        }
174        let hour: u32 = time_parts[0].parse().ok()?;
175        let minute: u32 = time_parts[1].parse().ok()?;
176        let second: u32 = time_parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
177
178        // Parse fractional seconds (up to microseconds).
179        let frac_padded = format!("{frac:0<6}");
180        let micros: u32 = frac_padded[..6].parse().unwrap_or(0);
181
182        Self::from_civil(year, month, day, hour, minute, second, micros)
183    }
184
185    /// Build from civil date components.
186    ///
187    /// Returns `None` if any intermediate multiplication overflows `i64`.
188    fn from_civil(
189        year: i32,
190        month: u32,
191        day: u32,
192        hour: u32,
193        minute: u32,
194        second: u32,
195        micros: u32,
196    ) -> Option<Self> {
197        // Inverse of the Hinnant algorithm.
198        let y = if month <= 2 { year - 1 } else { year };
199        let m = if month <= 2 { month + 9 } else { month - 3 };
200        let era = if y >= 0 { y } else { y - 399 } / 400;
201        let yoe = (y - era * 400) as u32;
202        let doy = (153 * m + 2) / 5 + day - 1;
203        let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
204        let days = (era as i64)
205            .checked_mul(146_097)?
206            .checked_add(doe as i64)?
207            .checked_sub(719_468)?;
208        let total_secs = days
209            .checked_mul(86400)?
210            .checked_add(hour as i64 * 3600)?
211            .checked_add(minute as i64 * 60)?
212            .checked_add(second as i64)?;
213        let result_micros = total_secs
214            .checked_mul(1_000_000)?
215            .checked_add(micros as i64)?;
216        Some(Self {
217            micros: result_micros,
218        })
219    }
220
221    /// Add a duration.
222    ///
223    /// Returns `Err` if the result overflows `i64`.
224    pub fn add_duration(&self, d: NdbDuration) -> Result<Self, NdbDateTimeError> {
225        let micros = self
226            .micros
227            .checked_add(d.micros)
228            .ok_or(NdbDateTimeError::AddOverflow)?;
229        Ok(Self { micros })
230    }
231
232    /// Subtract a duration.
233    ///
234    /// Returns `Err` if the result overflows `i64`.
235    pub fn sub_duration(&self, d: NdbDuration) -> Result<Self, NdbDateTimeError> {
236        let micros = self
237            .micros
238            .checked_sub(d.micros)
239            .ok_or(NdbDateTimeError::SubOverflow)?;
240        Ok(Self { micros })
241    }
242
243    /// Duration between two timestamps (self - other).
244    ///
245    /// Returns `Err` if the result overflows `i64`.
246    pub fn duration_since(&self, other: &NdbDateTime) -> Result<NdbDuration, NdbDateTimeError> {
247        let micros = self
248            .micros
249            .checked_sub(other.micros)
250            .ok_or(NdbDateTimeError::SubOverflow)?;
251        Ok(NdbDuration { micros })
252    }
253
254    /// Unix epoch seconds.
255    pub fn unix_secs(&self) -> i64 {
256        self.micros / 1_000_000
257    }
258
259    /// Unix epoch milliseconds.
260    pub fn unix_millis(&self) -> i64 {
261        self.micros / 1_000
262    }
263}
264
265impl std::fmt::Display for NdbDateTime {
266    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
267        f.write_str(&self.to_iso8601())
268    }
269}
270
271/// Components of a civil date-time.
272#[derive(Debug, Clone, Copy)]
273pub struct DateTimeComponents {
274    pub year: i32,
275    pub month: u8,
276    pub day: u8,
277    pub hour: u8,
278    pub minute: u8,
279    pub second: u8,
280    pub microsecond: u32,
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn datetime_now_roundtrip() {
289        let dt = NdbDateTime::now();
290        let iso = dt.to_iso8601();
291        let parsed = NdbDateTime::parse(&iso).unwrap();
292        // Allow 1 microsecond rounding difference.
293        assert!(
294            (dt.micros - parsed.micros).abs() <= 1,
295            "dt={}, parsed={}",
296            dt.micros,
297            parsed.micros
298        );
299    }
300
301    #[test]
302    fn datetime_epoch() {
303        let dt = NdbDateTime::from_micros(0);
304        assert_eq!(dt.to_iso8601(), "1970-01-01T00:00:00.000000Z");
305    }
306
307    #[test]
308    fn datetime_known_date() {
309        let dt = NdbDateTime::parse("2024-03-15T10:30:00Z").unwrap();
310        let c = dt.components();
311        assert_eq!(c.year, 2024);
312        assert_eq!(c.month, 3);
313        assert_eq!(c.day, 15);
314        assert_eq!(c.hour, 10);
315        assert_eq!(c.minute, 30);
316        assert_eq!(c.second, 0);
317    }
318
319    #[test]
320    fn datetime_fractional_seconds() {
321        let dt = NdbDateTime::parse("2024-01-01T00:00:00.123456Z").unwrap();
322        let c = dt.components();
323        assert_eq!(c.microsecond, 123456);
324    }
325
326    #[test]
327    fn datetime_date_only() {
328        let dt = NdbDateTime::parse("2024-03-15").unwrap();
329        let c = dt.components();
330        assert_eq!(c.year, 2024);
331        assert_eq!(c.month, 3);
332        assert_eq!(c.day, 15);
333        assert_eq!(c.hour, 0);
334    }
335
336    #[test]
337    fn datetime_arithmetic() {
338        let dt = NdbDateTime::parse("2024-01-01T00:00:00Z").unwrap();
339        let later = dt
340            .add_duration(NdbDuration::from_hours(24).expect("24 hours in range"))
341            .expect("add_duration in range");
342        let c = later.components();
343        assert_eq!(c.day, 2);
344    }
345
346    #[test]
347    fn datetime_ordering() {
348        let a = NdbDateTime::parse("2024-01-01T00:00:00Z").unwrap();
349        let b = NdbDateTime::parse("2024-01-02T00:00:00Z").unwrap();
350        assert!(a < b);
351    }
352
353    #[test]
354    fn unix_accessors() {
355        let dt = NdbDateTime::from_secs(1_700_000_000).expect("known unix timestamp in range");
356        assert_eq!(dt.unix_secs(), 1_700_000_000);
357        assert_eq!(dt.unix_millis(), 1_700_000_000_000);
358    }
359
360    #[test]
361    fn datetime_from_millis_overflow() {
362        assert!(NdbDateTime::from_millis(i64::MAX).is_err());
363        assert_eq!(
364            NdbDateTime::from_millis(i64::MAX),
365            Err(NdbDateTimeError::Overflow {
366                input: i64::MAX,
367                unit: "millis"
368            })
369        );
370    }
371
372    #[test]
373    fn datetime_from_secs_overflow() {
374        assert!(NdbDateTime::from_secs(i64::MAX).is_err());
375        assert_eq!(
376            NdbDateTime::from_secs(i64::MAX),
377            Err(NdbDateTimeError::Overflow {
378                input: i64::MAX,
379                unit: "secs"
380            })
381        );
382    }
383
384    #[test]
385    fn add_duration_overflow() {
386        let dt = NdbDateTime::from_micros(i64::MAX);
387        let one_us = NdbDuration::from_micros(1);
388        assert_eq!(dt.add_duration(one_us), Err(NdbDateTimeError::AddOverflow));
389    }
390
391    #[test]
392    fn sub_duration_overflow() {
393        let dt = NdbDateTime::from_micros(i64::MIN);
394        let one_us = NdbDuration::from_micros(1);
395        assert_eq!(dt.sub_duration(one_us), Err(NdbDateTimeError::SubOverflow));
396    }
397
398    #[test]
399    fn duration_since_overflow() {
400        let a = NdbDateTime::from_micros(i64::MIN);
401        let b = NdbDateTime::from_micros(i64::MAX);
402        // i64::MIN - i64::MAX overflows
403        assert_eq!(a.duration_since(&b), Err(NdbDateTimeError::SubOverflow));
404    }
405
406    #[test]
407    fn now_returns_positive() {
408        // Sanity: current time is after epoch.
409        let dt = NdbDateTime::now();
410        assert!(dt.micros > 0, "now() returned non-positive: {}", dt.micros);
411    }
412}