1use serde::{Deserialize, Serialize};
6
7use super::duration::NdbDuration;
8use super::error::NdbDateTimeError;
9
10#[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 pub micros: i64,
37}
38
39impl NdbDateTime {
40 pub fn from_micros(micros: i64) -> Self {
42 Self { micros }
43 }
44
45 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 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 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 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 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; 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 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 pub fn parse(s: &str) -> Option<Self> {
146 let (body, offset_secs) = split_utc_offset(s.trim())?;
147
148 if body.len() == 10 {
149 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 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 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 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 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 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 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 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 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 pub fn unix_secs(&self) -> i64 {
268 self.micros / 1_000_000
269 }
270
271 pub fn unix_millis(&self) -> i64 {
273 self.micros / 1_000
274 }
275}
276
277fn 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 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
318fn 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#[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 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 #[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 #[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 assert_eq!(a.duration_since(&b), Err(NdbDateTimeError::SubOverflow));
517 }
518
519 #[test]
520 fn now_returns_positive() {
521 let dt = NdbDateTime::now();
523 assert!(dt.micros > 0, "now() returned non-positive: {}", dt.micros);
524 }
525}