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> {
141 let s = s.trim().trim_end_matches('Z').trim_end_matches('z');
142
143 if s.len() == 10 {
144 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 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 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 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 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 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 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 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 pub fn unix_secs(&self) -> i64 {
256 self.micros / 1_000_000
257 }
258
259 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#[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 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 assert_eq!(a.duration_since(&b), Err(NdbDateTimeError::SubOverflow));
404 }
405
406 #[test]
407 fn now_returns_positive() {
408 let dt = NdbDateTime::now();
410 assert!(dt.micros > 0, "now() returned non-positive: {}", dt.micros);
411 }
412}