Skip to main content

nodedb_types/
datetime.rs

1//! First-class DateTime and Duration types.
2//!
3//! `NdbDateTime` stores microseconds since Unix epoch (1970-01-01T00:00:00Z).
4//! `NdbDuration` stores microseconds as a signed i64.
5//!
6//! Both serialize as strings (ISO 8601 for DateTime, human-readable for Duration)
7//! for JSON compatibility. Internal representation is i64 for efficient comparison
8//! and arithmetic.
9
10use serde::{Deserialize, Serialize};
11
12/// Microseconds-precision UTC timestamp.
13///
14/// Stores microseconds since Unix epoch as i64. Supports dates from
15/// ~292,000 years BCE to ~292,000 years CE.
16///
17/// String format: ISO 8601 `"2024-03-15T10:30:00.000000Z"`.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
19pub struct NdbDateTime {
20    /// Microseconds since Unix epoch (1970-01-01T00:00:00Z).
21    pub micros: i64,
22}
23
24impl NdbDateTime {
25    /// Create from microseconds since epoch.
26    pub fn from_micros(micros: i64) -> Self {
27        Self { micros }
28    }
29
30    /// Create from milliseconds since epoch.
31    pub fn from_millis(millis: i64) -> Self {
32        Self {
33            micros: millis * 1000,
34        }
35    }
36
37    /// Create from seconds since epoch.
38    pub fn from_secs(secs: i64) -> Self {
39        Self {
40            micros: secs * 1_000_000,
41        }
42    }
43
44    /// Current UTC time.
45    pub fn now() -> Self {
46        let dur = std::time::SystemTime::now()
47            .duration_since(std::time::UNIX_EPOCH)
48            .unwrap_or_default();
49        Self {
50            micros: dur.as_micros() as i64,
51        }
52    }
53
54    /// Extract year, month, day, hour, minute, second components.
55    pub fn components(&self) -> DateTimeComponents {
56        let total_secs = self.micros / 1_000_000;
57        let micros_rem = (self.micros % 1_000_000).unsigned_abs();
58
59        // Civil date from Unix timestamp (algorithm from Howard Hinnant).
60        let mut days = total_secs.div_euclid(86400) as i32;
61        let day_secs = total_secs.rem_euclid(86400) as u32;
62
63        days += 719_468; // shift epoch from 1970-01-01 to 0000-03-01
64        let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
65        let doe = (days - era * 146_097) as u32;
66        let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
67        let y = yoe as i32 + era * 400;
68        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
69        let mp = (5 * doy + 2) / 153;
70        let d = doy - (153 * mp + 2) / 5 + 1;
71        let m = if mp < 10 { mp + 3 } else { mp - 9 };
72        let year = if m <= 2 { y + 1 } else { y };
73
74        DateTimeComponents {
75            year,
76            month: m as u8,
77            day: d as u8,
78            hour: (day_secs / 3600) as u8,
79            minute: ((day_secs % 3600) / 60) as u8,
80            second: (day_secs % 60) as u8,
81            microsecond: micros_rem as u32,
82        }
83    }
84
85    /// Format as ISO 8601 string: `"2024-03-15T10:30:00.000000Z"`.
86    pub fn to_iso8601(&self) -> String {
87        let c = self.components();
88        format!(
89            "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:06}Z",
90            c.year, c.month, c.day, c.hour, c.minute, c.second, c.microsecond
91        )
92    }
93
94    /// Parse from ISO 8601 string (basic subset).
95    ///
96    /// Supports: `"2024-03-15T10:30:00Z"`, `"2024-03-15T10:30:00.123456Z"`,
97    /// `"2024-03-15"` (midnight UTC).
98    pub fn parse(s: &str) -> Option<Self> {
99        let s = s.trim().trim_end_matches('Z').trim_end_matches('z');
100
101        if s.len() == 10 {
102            // Date only: "2024-03-15" → midnight UTC.
103            let parts: Vec<&str> = s.split('-').collect();
104            if parts.len() != 3 {
105                return None;
106            }
107            let year: i32 = parts[0].parse().ok()?;
108            let month: u32 = parts[1].parse().ok()?;
109            let day: u32 = parts[2].parse().ok()?;
110            return Some(Self::from_civil(year, month, day, 0, 0, 0, 0));
111        }
112
113        // Full: "2024-03-15T10:30:00" or "2024-03-15T10:30:00.123456"
114        let (date_part, time_part) = s.split_once('T').or_else(|| s.split_once(' '))?;
115        let date_parts: Vec<&str> = date_part.split('-').collect();
116        if date_parts.len() != 3 {
117            return None;
118        }
119        let year: i32 = date_parts[0].parse().ok()?;
120        let month: u32 = date_parts[1].parse().ok()?;
121        let day: u32 = date_parts[2].parse().ok()?;
122
123        let (time_main, frac) = if let Some((t, f)) = time_part.split_once('.') {
124            (t, f)
125        } else {
126            (time_part, "0")
127        };
128        let time_parts: Vec<&str> = time_main.split(':').collect();
129        if time_parts.len() < 2 {
130            return None;
131        }
132        let hour: u32 = time_parts[0].parse().ok()?;
133        let minute: u32 = time_parts[1].parse().ok()?;
134        let second: u32 = time_parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
135
136        // Parse fractional seconds (up to microseconds).
137        let frac_padded = format!("{frac:0<6}");
138        let micros: u32 = frac_padded[..6].parse().unwrap_or(0);
139
140        Some(Self::from_civil(
141            year, month, day, hour, minute, second, micros,
142        ))
143    }
144
145    /// Build from civil date components.
146    fn from_civil(
147        year: i32,
148        month: u32,
149        day: u32,
150        hour: u32,
151        minute: u32,
152        second: u32,
153        micros: u32,
154    ) -> Self {
155        // Inverse of the Hinnant algorithm.
156        let y = if month <= 2 { year - 1 } else { year };
157        let m = if month <= 2 { month + 9 } else { month - 3 };
158        let era = if y >= 0 { y } else { y - 399 } / 400;
159        let yoe = (y - era * 400) as u32;
160        let doy = (153 * m + 2) / 5 + day - 1;
161        let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
162        let days = era as i64 * 146_097 + doe as i64 - 719_468;
163        let total_secs = days * 86400 + hour as i64 * 3600 + minute as i64 * 60 + second as i64;
164        Self {
165            micros: total_secs * 1_000_000 + micros as i64,
166        }
167    }
168
169    /// Add a duration.
170    pub fn add_duration(&self, d: NdbDuration) -> Self {
171        Self {
172            micros: self.micros + d.micros,
173        }
174    }
175
176    /// Subtract a duration.
177    pub fn sub_duration(&self, d: NdbDuration) -> Self {
178        Self {
179            micros: self.micros - d.micros,
180        }
181    }
182
183    /// Duration between two timestamps.
184    pub fn duration_since(&self, other: &NdbDateTime) -> NdbDuration {
185        NdbDuration {
186            micros: self.micros - other.micros,
187        }
188    }
189
190    /// Unix epoch seconds.
191    pub fn unix_secs(&self) -> i64 {
192        self.micros / 1_000_000
193    }
194
195    /// Unix epoch milliseconds.
196    pub fn unix_millis(&self) -> i64 {
197        self.micros / 1_000
198    }
199}
200
201impl std::fmt::Display for NdbDateTime {
202    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203        f.write_str(&self.to_iso8601())
204    }
205}
206
207/// Components of a civil date-time.
208#[derive(Debug, Clone, Copy)]
209pub struct DateTimeComponents {
210    pub year: i32,
211    pub month: u8,
212    pub day: u8,
213    pub hour: u8,
214    pub minute: u8,
215    pub second: u8,
216    pub microsecond: u32,
217}
218
219/// Microseconds-precision duration (signed).
220///
221/// String format: human-readable `"1h30m15s"` or `"500ms"`.
222#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
223pub struct NdbDuration {
224    /// Microseconds (signed: negative = past).
225    pub micros: i64,
226}
227
228impl NdbDuration {
229    pub fn from_micros(micros: i64) -> Self {
230        Self { micros }
231    }
232
233    pub fn from_millis(millis: i64) -> Self {
234        Self {
235            micros: millis * 1_000,
236        }
237    }
238
239    pub fn from_secs(secs: i64) -> Self {
240        Self {
241            micros: secs * 1_000_000,
242        }
243    }
244
245    pub fn from_minutes(mins: i64) -> Self {
246        Self {
247            micros: mins * 60 * 1_000_000,
248        }
249    }
250
251    pub fn from_hours(hours: i64) -> Self {
252        Self {
253            micros: hours * 3600 * 1_000_000,
254        }
255    }
256
257    pub fn from_days(days: i64) -> Self {
258        Self {
259            micros: days * 86400 * 1_000_000,
260        }
261    }
262
263    pub fn as_secs_f64(&self) -> f64 {
264        self.micros as f64 / 1_000_000.0
265    }
266
267    pub fn as_millis(&self) -> i64 {
268        self.micros / 1_000
269    }
270
271    /// Format as human-readable string.
272    pub fn to_human(&self) -> String {
273        let abs = self.micros.unsigned_abs();
274        let sign = if self.micros < 0 { "-" } else { "" };
275
276        if abs < 1_000 {
277            return format!("{sign}{abs}us");
278        }
279        if abs < 1_000_000 {
280            return format!("{sign}{}ms", abs / 1_000);
281        }
282
283        let total_secs = abs / 1_000_000;
284        let hours = total_secs / 3600;
285        let mins = (total_secs % 3600) / 60;
286        let secs = total_secs % 60;
287
288        if hours > 0 {
289            if mins > 0 || secs > 0 {
290                format!("{sign}{hours}h{mins}m{secs}s")
291            } else {
292                format!("{sign}{hours}h")
293            }
294        } else if mins > 0 {
295            if secs > 0 {
296                format!("{sign}{mins}m{secs}s")
297            } else {
298                format!("{sign}{mins}m")
299            }
300        } else {
301            format!("{sign}{secs}s")
302        }
303    }
304
305    /// Parse from human-readable string: "1h30m", "500ms", "30s", "2d".
306    pub fn parse(s: &str) -> Option<Self> {
307        let s = s.trim();
308        if s.is_empty() {
309            return None;
310        }
311
312        let (neg, s) = if let Some(rest) = s.strip_prefix('-') {
313            (true, rest)
314        } else {
315            (false, s)
316        };
317
318        // Simple suffix parsing.
319        if let Some(n) = s.strip_suffix("us") {
320            let v: i64 = n.trim().parse().ok()?;
321            return Some(Self::from_micros(if neg { -v } else { v }));
322        }
323        if let Some(n) = s.strip_suffix("ms") {
324            let v: i64 = n.trim().parse().ok()?;
325            return Some(Self::from_millis(if neg { -v } else { v }));
326        }
327        if let Some(n) = s.strip_suffix('d') {
328            let v: i64 = n.trim().parse().ok()?;
329            return Some(Self::from_days(if neg { -v } else { v }));
330        }
331
332        // Compound: "1h30m15s"
333        let mut total_micros: i64 = 0;
334        let mut num_buf = String::new();
335        for c in s.chars() {
336            if c.is_ascii_digit() {
337                num_buf.push(c);
338            } else {
339                let n: i64 = num_buf.parse().ok()?;
340                num_buf.clear();
341                match c {
342                    'h' => total_micros += n * 3_600_000_000,
343                    'm' => total_micros += n * 60_000_000,
344                    's' => total_micros += n * 1_000_000,
345                    _ => return None,
346                }
347            }
348        }
349        // Trailing number without suffix = seconds.
350        if !num_buf.is_empty() {
351            let n: i64 = num_buf.parse().ok()?;
352            total_micros += n * 1_000_000;
353        }
354
355        if total_micros == 0 {
356            return None;
357        }
358
359        Some(Self::from_micros(if neg {
360            -total_micros
361        } else {
362            total_micros
363        }))
364    }
365}
366
367impl std::fmt::Display for NdbDuration {
368    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
369        f.write_str(&self.to_human())
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    #[test]
378    fn datetime_now_roundtrip() {
379        let dt = NdbDateTime::now();
380        let iso = dt.to_iso8601();
381        let parsed = NdbDateTime::parse(&iso).unwrap();
382        // Allow 1 microsecond rounding difference.
383        assert!(
384            (dt.micros - parsed.micros).abs() <= 1,
385            "dt={}, parsed={}",
386            dt.micros,
387            parsed.micros
388        );
389    }
390
391    #[test]
392    fn datetime_epoch() {
393        let dt = NdbDateTime::from_micros(0);
394        assert_eq!(dt.to_iso8601(), "1970-01-01T00:00:00.000000Z");
395    }
396
397    #[test]
398    fn datetime_known_date() {
399        let dt = NdbDateTime::parse("2024-03-15T10:30:00Z").unwrap();
400        let c = dt.components();
401        assert_eq!(c.year, 2024);
402        assert_eq!(c.month, 3);
403        assert_eq!(c.day, 15);
404        assert_eq!(c.hour, 10);
405        assert_eq!(c.minute, 30);
406        assert_eq!(c.second, 0);
407    }
408
409    #[test]
410    fn datetime_fractional_seconds() {
411        let dt = NdbDateTime::parse("2024-01-01T00:00:00.123456Z").unwrap();
412        let c = dt.components();
413        assert_eq!(c.microsecond, 123456);
414    }
415
416    #[test]
417    fn datetime_date_only() {
418        let dt = NdbDateTime::parse("2024-03-15").unwrap();
419        let c = dt.components();
420        assert_eq!(c.year, 2024);
421        assert_eq!(c.month, 3);
422        assert_eq!(c.day, 15);
423        assert_eq!(c.hour, 0);
424    }
425
426    #[test]
427    fn datetime_arithmetic() {
428        let dt = NdbDateTime::parse("2024-01-01T00:00:00Z").unwrap();
429        let later = dt.add_duration(NdbDuration::from_hours(24));
430        let c = later.components();
431        assert_eq!(c.day, 2);
432    }
433
434    #[test]
435    fn datetime_ordering() {
436        let a = NdbDateTime::parse("2024-01-01T00:00:00Z").unwrap();
437        let b = NdbDateTime::parse("2024-01-02T00:00:00Z").unwrap();
438        assert!(a < b);
439    }
440
441    #[test]
442    fn duration_human_format() {
443        assert_eq!(NdbDuration::from_secs(90).to_human(), "1m30s");
444        assert_eq!(NdbDuration::from_hours(2).to_human(), "2h");
445        assert_eq!(NdbDuration::from_millis(500).to_human(), "500ms");
446        assert_eq!(NdbDuration::from_micros(42).to_human(), "42us");
447        assert_eq!(NdbDuration::from_secs(3661).to_human(), "1h1m1s");
448    }
449
450    #[test]
451    fn duration_parse() {
452        assert_eq!(NdbDuration::parse("30s").unwrap().micros, 30_000_000);
453        assert_eq!(NdbDuration::parse("1h30m").unwrap().micros, 5_400_000_000);
454        assert_eq!(NdbDuration::parse("500ms").unwrap().micros, 500_000);
455        assert_eq!(NdbDuration::parse("2d").unwrap().micros, 172_800_000_000);
456        assert_eq!(NdbDuration::parse("-5s").unwrap().micros, -5_000_000);
457    }
458
459    #[test]
460    fn duration_roundtrip() {
461        let d = NdbDuration::from_secs(3661);
462        let s = d.to_human();
463        let parsed = NdbDuration::parse(&s).unwrap();
464        assert_eq!(d.micros, parsed.micros);
465    }
466
467    #[test]
468    fn unix_accessors() {
469        let dt = NdbDateTime::from_secs(1_700_000_000);
470        assert_eq!(dt.unix_secs(), 1_700_000_000);
471        assert_eq!(dt.unix_millis(), 1_700_000_000_000);
472    }
473}