Skip to main content

reifydb_value/value/
datetime.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use std::{
5	fmt::{self, Display, Formatter},
6	ops::{Add, Rem, Sub},
7	str::FromStr,
8};
9
10use serde::{
11	Deserialize, Deserializer, Serialize, Serializer,
12	de::{self, Visitor},
13};
14
15use crate::{
16	clock::ClockNow,
17	error::{Error, TemporalKind, TypeError},
18	fragment::Fragment,
19	value::{date::Date, duration::Duration, temporal::parse::datetime::parse_datetime, time::Time},
20};
21
22const NANOS_PER_SECOND: u64 = 1_000_000_000;
23const NANOS_PER_MILLI: u64 = 1_000_000;
24const NANOS_PER_DAY: u64 = 86_400 * NANOS_PER_SECOND;
25
26pub static CREATED_AT_COLUMN_NAME: &str = "created_at";
27pub static UPDATED_AT_COLUMN_NAME: &str = "updated_at";
28
29#[repr(transparent)]
30#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
31pub struct DateTime {
32	nanos: u64,
33}
34
35impl DateTime {
36	pub fn new(year: i32, month: u32, day: u32, hour: u32, min: u32, sec: u32, nano: u32) -> Option<Self> {
37		let date = Date::new(year, month, day)?;
38		let time = Time::new(hour, min, sec, nano)?;
39
40		let days = date.to_days_since_epoch();
41		if days < 0 {
42			return None;
43		}
44
45		let nanos = (days as u64).checked_mul(NANOS_PER_DAY)?.checked_add(time.to_nanos_since_midnight())?;
46		Some(Self {
47			nanos,
48		})
49	}
50
51	pub fn from_ymd_hms(
52		year: i32,
53		month: u32,
54		day: u32,
55		hour: u32,
56		min: u32,
57		sec: u32,
58	) -> Result<Self, Box<TypeError>> {
59		Self::new(year, month, day, hour, min, sec, 0).ok_or_else(|| {
60			Box::new(Self::overflow_err(format!(
61				"invalid datetime: {}-{:02}-{:02} {:02}:{:02}:{:02}",
62				year, month, day, hour, min, sec
63			)))
64		})
65	}
66
67	fn overflow_err(message: impl Into<String>) -> TypeError {
68		TypeError::Temporal {
69			kind: TemporalKind::DateTimeOverflow {
70				message: message.into(),
71			},
72			message: "datetime overflow".to_string(),
73			fragment: Fragment::None,
74		}
75	}
76
77	pub fn from_nanos(nanos: u64) -> Self {
78		Self {
79			nanos,
80		}
81	}
82
83	pub fn to_nanos(&self) -> u64 {
84		self.nanos
85	}
86
87	pub fn from_timestamp(timestamp: i64) -> Result<Self, Box<TypeError>> {
88		if timestamp < 0 {
89			return Err(Box::new(Self::overflow_err(format!(
90				"DateTime does not support timestamps before Unix epoch: {}",
91				timestamp
92			))));
93		}
94		let nanos = (timestamp as u64).checked_mul(NANOS_PER_SECOND).ok_or_else(|| {
95			Box::new(Self::overflow_err(format!("timestamp {} overflows DateTime range", timestamp)))
96		})?;
97		Ok(Self {
98			nanos,
99		})
100	}
101
102	pub fn from_timestamp_millis(millis: u64) -> Result<Self, Box<TypeError>> {
103		let nanos = millis.checked_mul(NANOS_PER_MILLI).ok_or_else(|| {
104			Box::new(Self::overflow_err(format!("timestamp_millis {} overflows DateTime range", millis)))
105		})?;
106		Ok(Self {
107			nanos,
108		})
109	}
110
111	pub fn from_timestamp_nanos(nanos: u128) -> Result<Self, Box<TypeError>> {
112		let nanos = u64::try_from(nanos).map_err(|_| {
113			Box::new(Self::overflow_err(format!("timestamp_nanos {} overflows u64 DateTime range", nanos)))
114		})?;
115		Ok(Self {
116			nanos,
117		})
118	}
119
120	pub fn timestamp(&self) -> i64 {
121		(self.nanos / NANOS_PER_SECOND) as i64
122	}
123
124	pub fn timestamp_millis(&self) -> i64 {
125		(self.nanos / NANOS_PER_MILLI) as i64
126	}
127
128	pub fn timestamp_nanos(&self) -> Result<i64, Box<TypeError>> {
129		i64::try_from(self.nanos).map_err(|_| Box::new(Self::overflow_err("DateTime nanos exceeds i64::MAX")))
130	}
131
132	pub fn try_date(&self) -> Result<Date, Box<TypeError>> {
133		let days_u64 = self.nanos / NANOS_PER_DAY;
134		let days = i32::try_from(days_u64)
135			.map_err(|_| Box::new(Self::overflow_err("DateTime nanos too large for date extraction")))?;
136		Date::from_days_since_epoch(days)
137			.ok_or_else(|| Box::new(Self::overflow_err("DateTime days out of range for Date")))
138	}
139
140	pub fn date(&self) -> Date {
141		self.try_date().expect("DateTime nanos too large for date extraction")
142	}
143
144	pub fn time(&self) -> Time {
145		let nanos_in_day = self.nanos % NANOS_PER_DAY;
146		Time::from_nanos_since_midnight(nanos_in_day).unwrap()
147	}
148
149	pub fn to_nanos_since_epoch_u128(&self) -> u128 {
150		self.nanos as u128
151	}
152
153	pub fn year(&self) -> i32 {
154		self.date().year()
155	}
156
157	pub fn month(&self) -> u32 {
158		self.date().month()
159	}
160
161	pub fn day(&self) -> u32 {
162		self.date().day()
163	}
164
165	pub fn hour(&self) -> u32 {
166		self.time().hour()
167	}
168
169	pub fn minute(&self) -> u32 {
170		self.time().minute()
171	}
172
173	pub fn second(&self) -> u32 {
174		self.time().second()
175	}
176
177	pub fn nanosecond(&self) -> u32 {
178		self.time().nanosecond()
179	}
180
181	pub fn add_duration(&self, dur: &Duration) -> Result<Self, Box<TypeError>> {
182		let date = self.date();
183		let time = self.time();
184		let mut year = date.year();
185		let mut month = date.month() as i32;
186		let mut day = date.day();
187
188		let total_months = month + dur.get_months();
189		year += (total_months - 1).div_euclid(12);
190		month = (total_months - 1).rem_euclid(12) + 1;
191
192		let max_day = Date::days_in_month(year, month as u32);
193		if day > max_day {
194			day = max_day;
195		}
196
197		let base_date = Date::new(year, month as u32, day).ok_or_else(|| {
198			Box::new(Self::overflow_err(format!(
199				"invalid date after adding duration: {}-{:02}-{:02}",
200				year, month, day
201			)))
202		})?;
203		let base_days = base_date.to_days_since_epoch() as i64 + dur.get_days() as i64;
204		let time_nanos = time.to_nanos_since_midnight() as i64 + dur.get_nanos();
205
206		let total_nanos = base_days as i128 * 86_400_000_000_000i128 + time_nanos as i128;
207
208		if total_nanos < 0 {
209			return Err(Box::new(Self::overflow_err("result is before Unix epoch")));
210		}
211
212		let nanos = u64::try_from(total_nanos)
213			.map_err(|_| Box::new(Self::overflow_err("result exceeds DateTime range")))?;
214		Ok(Self {
215			nanos,
216		})
217	}
218}
219
220impl DateTime {
221	pub fn saturating_add(self, rhs: Duration) -> DateTime {
222		let total = rhs.as_nanos().unwrap_or(if rhs.is_negative() {
223			i64::MIN
224		} else {
225			i64::MAX
226		});
227		let nanos = (self.to_nanos() as i128 + total as i128).clamp(0, u64::MAX as i128);
228		DateTime::from_nanos(nanos as u64)
229	}
230
231	pub fn saturating_sub(self, rhs: Duration) -> DateTime {
232		let total = rhs.as_nanos().unwrap_or(if rhs.is_negative() {
233			i64::MIN
234		} else {
235			i64::MAX
236		});
237		let nanos = (self.to_nanos() as i128 - total as i128).clamp(0, u64::MAX as i128);
238		DateTime::from_nanos(nanos as u64)
239	}
240
241	pub fn checked_sub(self, rhs: Duration) -> Option<DateTime> {
242		let total = rhs.as_nanos().ok()?;
243		let nanos = self.to_nanos() as i128 - total as i128;
244		if nanos < 0 {
245			None
246		} else {
247			Some(DateTime::from_nanos(nanos as u64))
248		}
249	}
250
251	pub fn saturating_duration_since(self, earlier: DateTime) -> Duration {
252		let diff = (self.to_nanos() as i128 - earlier.to_nanos() as i128)
253			.clamp(i64::MIN as i128, i64::MAX as i128) as i64;
254		Duration::from_nanoseconds(diff).unwrap_or_else(|_| Duration::zero())
255	}
256}
257
258impl Add<Duration> for DateTime {
259	type Output = DateTime;
260
261	#[inline]
262	fn add(self, rhs: Duration) -> DateTime {
263		let total = rhs.as_nanos().expect("duration exceeds i64 nanoseconds");
264		let nanos = self.to_nanos() as i128 + total as i128;
265		DateTime::from_nanos(u64::try_from(nanos).expect("datetime addition out of range"))
266	}
267}
268
269impl Sub<Duration> for DateTime {
270	type Output = DateTime;
271
272	#[inline]
273	fn sub(self, rhs: Duration) -> DateTime {
274		let total = rhs.as_nanos().expect("duration exceeds i64 nanoseconds");
275		let nanos = self.to_nanos() as i128 - total as i128;
276		DateTime::from_nanos(u64::try_from(nanos).expect("datetime subtraction out of range"))
277	}
278}
279
280impl Sub<DateTime> for DateTime {
281	type Output = Duration;
282
283	#[inline]
284	fn sub(self, rhs: DateTime) -> Duration {
285		let diff = self.to_nanos() as i128 - rhs.to_nanos() as i128;
286		Duration::from_nanoseconds(i64::try_from(diff).expect("datetime difference exceeds i64 nanoseconds"))
287			.expect("datetime difference out of duration range")
288	}
289}
290
291impl Rem<Duration> for DateTime {
292	type Output = Duration;
293
294	#[inline]
295	fn rem(self, rhs: Duration) -> Duration {
296		let total = rhs.as_nanos().expect("duration exceeds i64 nanoseconds");
297		let total = u64::try_from(total).expect("duration must be positive for windowing");
298		Duration::from_nanoseconds((self.to_nanos() % total) as i64)
299			.expect("datetime remainder out of duration range")
300	}
301}
302
303impl Display for DateTime {
304	fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
305		let date = self.date();
306		let time = self.time();
307
308		write!(f, "{}T{}Z", date, time)
309	}
310}
311
312impl Serialize for DateTime {
313	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
314	where
315		S: Serializer,
316	{
317		serializer.serialize_u64(self.nanos)
318	}
319}
320
321struct DateTimeVisitor;
322
323impl<'de> Visitor<'de> for DateTimeVisitor {
324	type Value = DateTime;
325
326	fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
327		formatter.write_str("a datetime as nanoseconds since the Unix epoch (u64)")
328	}
329
330	fn visit_u64<E>(self, value: u64) -> Result<DateTime, E>
331	where
332		E: de::Error,
333	{
334		Ok(DateTime::from_nanos(value))
335	}
336}
337
338impl<'de> Deserialize<'de> for DateTime {
339	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
340	where
341		D: Deserializer<'de>,
342	{
343		deserializer.deserialize_u64(DateTimeVisitor)
344	}
345}
346
347impl DateTime {
348	pub fn now<C: ClockNow>(clock: &C) -> Self {
349		Self::from_nanos(clock.now_nanos())
350	}
351}
352
353impl FromStr for DateTime {
354	type Err = Error;
355
356	fn from_str(s: &str) -> Result<Self, Self::Err> {
357		parse_datetime(Fragment::internal(s.trim()))
358	}
359}
360
361#[cfg(test)]
362pub mod tests {
363	use std::fmt::Debug;
364
365	use postcard::{from_bytes, to_allocvec};
366	use serde_json::{from_str, to_string};
367
368	use crate::{
369		error::{TemporalKind, TypeError},
370		value::{datetime::DateTime, duration::Duration},
371	};
372
373	#[test]
374	fn test_datetime_display_standard_format() {
375		let datetime = DateTime::new(2024, 3, 15, 14, 30, 45, 123456789).unwrap();
376		assert_eq!(format!("{}", datetime), "2024-03-15T14:30:45.123456789Z");
377
378		let datetime = DateTime::new(2000, 1, 1, 0, 0, 0, 0).unwrap();
379		assert_eq!(format!("{}", datetime), "2000-01-01T00:00:00.000000000Z");
380
381		let datetime = DateTime::new(1999, 12, 31, 23, 59, 59, 999999999).unwrap();
382		assert_eq!(format!("{}", datetime), "1999-12-31T23:59:59.999999999Z");
383	}
384
385	#[test]
386	fn test_datetime_display_millisecond_precision() {
387		let datetime = DateTime::new(2024, 3, 15, 14, 30, 45, 123000000).unwrap();
388		assert_eq!(format!("{}", datetime), "2024-03-15T14:30:45.123000000Z");
389
390		let datetime = DateTime::new(2024, 3, 15, 14, 30, 45, 001000000).unwrap();
391		assert_eq!(format!("{}", datetime), "2024-03-15T14:30:45.001000000Z");
392
393		let datetime = DateTime::new(2024, 3, 15, 14, 30, 45, 999000000).unwrap();
394		assert_eq!(format!("{}", datetime), "2024-03-15T14:30:45.999000000Z");
395	}
396
397	#[test]
398	fn test_datetime_display_microsecond_precision() {
399		let datetime = DateTime::new(2024, 3, 15, 14, 30, 45, 123456000).unwrap();
400		assert_eq!(format!("{}", datetime), "2024-03-15T14:30:45.123456000Z");
401
402		let datetime = DateTime::new(2024, 3, 15, 14, 30, 45, 000001000).unwrap();
403		assert_eq!(format!("{}", datetime), "2024-03-15T14:30:45.000001000Z");
404
405		let datetime = DateTime::new(2024, 3, 15, 14, 30, 45, 999999000).unwrap();
406		assert_eq!(format!("{}", datetime), "2024-03-15T14:30:45.999999000Z");
407	}
408
409	#[test]
410	fn test_datetime_display_nanosecond_precision() {
411		let datetime = DateTime::new(2024, 3, 15, 14, 30, 45, 123456789).unwrap();
412		assert_eq!(format!("{}", datetime), "2024-03-15T14:30:45.123456789Z");
413
414		let datetime = DateTime::new(2024, 3, 15, 14, 30, 45, 000000001).unwrap();
415		assert_eq!(format!("{}", datetime), "2024-03-15T14:30:45.000000001Z");
416
417		let datetime = DateTime::new(2024, 3, 15, 14, 30, 45, 999999999).unwrap();
418		assert_eq!(format!("{}", datetime), "2024-03-15T14:30:45.999999999Z");
419	}
420
421	#[test]
422	fn test_datetime_display_zero_fractional_seconds() {
423		let datetime = DateTime::new(2024, 3, 15, 14, 30, 45, 0).unwrap();
424		assert_eq!(format!("{}", datetime), "2024-03-15T14:30:45.000000000Z");
425
426		let datetime = DateTime::new(2024, 3, 15, 0, 0, 0, 0).unwrap();
427		assert_eq!(format!("{}", datetime), "2024-03-15T00:00:00.000000000Z");
428	}
429
430	#[test]
431	fn test_datetime_display_edge_times() {
432		// Midnight
433		let datetime = DateTime::new(2024, 3, 15, 0, 0, 0, 0).unwrap();
434		assert_eq!(format!("{}", datetime), "2024-03-15T00:00:00.000000000Z");
435
436		// Almost midnight next day
437		let datetime = DateTime::new(2024, 3, 15, 23, 59, 59, 999999999).unwrap();
438		assert_eq!(format!("{}", datetime), "2024-03-15T23:59:59.999999999Z");
439
440		// Noon
441		let datetime = DateTime::new(2024, 3, 15, 12, 0, 0, 0).unwrap();
442		assert_eq!(format!("{}", datetime), "2024-03-15T12:00:00.000000000Z");
443	}
444
445	#[test]
446	fn test_datetime_display_unix_epoch() {
447		let datetime = DateTime::new(1970, 1, 1, 0, 0, 0, 0).unwrap();
448		assert_eq!(format!("{}", datetime), "1970-01-01T00:00:00.000000000Z");
449
450		let datetime = DateTime::new(1970, 1, 1, 0, 0, 1, 0).unwrap();
451		assert_eq!(format!("{}", datetime), "1970-01-01T00:00:01.000000000Z");
452	}
453
454	#[test]
455	fn test_datetime_display_leap_year() {
456		let datetime = DateTime::new(2024, 2, 29, 12, 30, 45, 123456789).unwrap();
457		assert_eq!(format!("{}", datetime), "2024-02-29T12:30:45.123456789Z");
458
459		let datetime = DateTime::new(2000, 2, 29, 0, 0, 0, 0).unwrap();
460		assert_eq!(format!("{}", datetime), "2000-02-29T00:00:00.000000000Z");
461	}
462
463	#[test]
464	fn test_datetime_display_boundary_dates() {
465		// Century boundaries
466		let datetime = DateTime::new(2000, 1, 1, 0, 0, 0, 0).unwrap();
467		assert_eq!(format!("{}", datetime), "2000-01-01T00:00:00.000000000Z");
468
469		let datetime = DateTime::new(2100, 1, 1, 0, 0, 0, 0).unwrap();
470		assert_eq!(format!("{}", datetime), "2100-01-01T00:00:00.000000000Z");
471
472		// Max representable date (~year 2554 with u64 nanos)
473		let datetime = DateTime::new(2554, 1, 1, 0, 0, 0, 0).unwrap();
474		assert_eq!(format!("{}", datetime), "2554-01-01T00:00:00.000000000Z");
475
476		// Year 9999 exceeds u64 nanos range
477		assert!(DateTime::new(9999, 12, 31, 23, 59, 59, 999999999).is_none());
478	}
479
480	#[test]
481	fn test_datetime_rejects_pre_epoch() {
482		// Year 1 is before epoch
483		assert!(DateTime::new(1, 1, 1, 0, 0, 0, 0).is_none());
484
485		// 1900 is before epoch
486		assert!(DateTime::new(1900, 1, 1, 0, 0, 0, 0).is_none());
487
488		// 1969 is before epoch
489		assert!(DateTime::new(1969, 12, 31, 23, 59, 59, 999999999).is_none());
490
491		// Negative timestamp
492		assert!(DateTime::from_timestamp(-1).is_err());
493	}
494
495	#[test]
496	fn test_datetime_display_default() {
497		let datetime = DateTime::default();
498		assert_eq!(format!("{}", datetime), "1970-01-01T00:00:00.000000000Z");
499	}
500
501	#[test]
502	fn test_datetime_display_all_hours() {
503		for hour in 0..24 {
504			let datetime = DateTime::new(2024, 3, 15, hour, 30, 45, 123456789).unwrap();
505			let expected = format!("2024-03-15T{:02}:30:45.123456789Z", hour);
506			assert_eq!(format!("{}", datetime), expected);
507		}
508	}
509
510	#[test]
511	fn test_datetime_display_all_minutes() {
512		for minute in 0..60 {
513			let datetime = DateTime::new(2024, 3, 15, 14, minute, 45, 123456789).unwrap();
514			let expected = format!("2024-03-15T14:{:02}:45.123456789Z", minute);
515			assert_eq!(format!("{}", datetime), expected);
516		}
517	}
518
519	#[test]
520	fn test_datetime_display_all_seconds() {
521		for second in 0..60 {
522			let datetime = DateTime::new(2024, 3, 15, 14, 30, second, 123456789).unwrap();
523			let expected = format!("2024-03-15T14:30:{:02}.123456789Z", second);
524			assert_eq!(format!("{}", datetime), expected);
525		}
526	}
527
528	#[test]
529	fn test_datetime_display_from_timestamp() {
530		let datetime = DateTime::from_timestamp(0).unwrap();
531		assert_eq!(format!("{}", datetime), "1970-01-01T00:00:00.000000000Z");
532
533		let datetime = DateTime::from_timestamp(1234567890).unwrap();
534		assert_eq!(format!("{}", datetime), "2009-02-13T23:31:30.000000000Z");
535	}
536
537	#[test]
538	fn test_datetime_display_from_timestamp_millis() {
539		let datetime = DateTime::from_timestamp_millis(1234567890123).unwrap();
540		assert_eq!(format!("{}", datetime), "2009-02-13T23:31:30.123000000Z");
541
542		let datetime = DateTime::from_timestamp_millis(0).unwrap();
543		assert_eq!(format!("{}", datetime), "1970-01-01T00:00:00.000000000Z");
544	}
545
546	#[test]
547	fn test_datetime_from_nanos_roundtrip() {
548		let datetime = DateTime::new(2024, 3, 15, 14, 30, 45, 123456789).unwrap();
549		let nanos = datetime.to_nanos();
550		let recovered = DateTime::from_nanos(nanos);
551		assert_eq!(datetime, recovered);
552	}
553
554	#[test]
555	fn test_datetime_roundtrip() {
556		let test_cases = [
557			(1970, 1, 1, 0, 0, 0, 0u32),
558			(2024, 3, 15, 14, 30, 45, 123456789),
559			(2000, 2, 29, 23, 59, 59, 999999999),
560		];
561
562		for (y, m, d, h, min, s, n) in test_cases {
563			let datetime = DateTime::new(y, m, d, h, min, s, n).unwrap();
564			let nanos = datetime.to_nanos();
565			let recovered = DateTime::from_nanos(nanos);
566
567			assert_eq!(datetime.year(), recovered.year());
568			assert_eq!(datetime.month(), recovered.month());
569			assert_eq!(datetime.day(), recovered.day());
570			assert_eq!(datetime.hour(), recovered.hour());
571			assert_eq!(datetime.minute(), recovered.minute());
572			assert_eq!(datetime.second(), recovered.second());
573			assert_eq!(datetime.nanosecond(), recovered.nanosecond());
574		}
575	}
576
577	#[test]
578	fn test_datetime_components() {
579		let datetime = DateTime::new(2024, 3, 15, 14, 30, 45, 123456789).unwrap();
580
581		assert_eq!(datetime.year(), 2024);
582		assert_eq!(datetime.month(), 3);
583		assert_eq!(datetime.day(), 15);
584		assert_eq!(datetime.hour(), 14);
585		assert_eq!(datetime.minute(), 30);
586		assert_eq!(datetime.second(), 45);
587		assert_eq!(datetime.nanosecond(), 123456789);
588	}
589
590	#[test]
591	fn test_serde_roundtrip() {
592		let datetime = DateTime::new(2024, 3, 15, 14, 30, 45, 123456789).unwrap();
593		let json = to_string(&datetime).unwrap();
594		// Wire format is the raw nanos-since-epoch integer, not an ISO-8601 string.
595		assert_eq!(json, datetime.to_nanos().to_string());
596
597		let recovered: DateTime = from_str(&json).unwrap();
598		assert_eq!(datetime, recovered);
599	}
600
601	#[test]
602	fn test_serde_postcard_roundtrip_preserves_all_components() {
603		// Binary (postcard) is the hot CDC path; every date/time component (incl. sub-second nanos)
604		// must survive the integer encoding so CDC consumers reconstruct the exact instant.
605		for (y, mo, d, h, mi, s, n) in [
606			(1970u32 as i32, 1u32, 1u32, 0u32, 0u32, 0u32, 0u32),
607			(2024, 3, 15, 14, 30, 45, 123456789),
608			(1999, 12, 31, 23, 59, 59, 999999999),
609			(2024, 3, 15, 14, 30, 45, 1),
610		] {
611			let dt = DateTime::new(y, mo, d, h, mi, s, n).unwrap();
612			let bytes = to_allocvec(&dt).unwrap();
613			let recovered: DateTime = from_bytes(&bytes).unwrap();
614			assert_eq!(dt, recovered);
615			assert_eq!(recovered.year(), y);
616			assert_eq!(recovered.month(), mo);
617			assert_eq!(recovered.day(), d);
618			assert_eq!(recovered.hour(), h);
619			assert_eq!(recovered.minute(), mi);
620			assert_eq!(recovered.second(), s);
621			assert_eq!(recovered.nanosecond(), n);
622		}
623	}
624
625	fn assert_datetime_overflow<T: Debug>(result: Result<T, Box<TypeError>>) {
626		let err = result.expect_err("expected DateTimeOverflow error");
627		match *err {
628			TypeError::Temporal {
629				kind: TemporalKind::DateTimeOverflow {
630					..
631				},
632				..
633			} => {}
634			other => panic!("expected DateTimeOverflow, got: {:?}", other),
635		}
636	}
637
638	#[test]
639	fn test_from_timestamp_nanos_overflow() {
640		let huge: u128 = u64::MAX as u128 + 1;
641		assert_datetime_overflow(DateTime::from_timestamp_nanos(huge));
642	}
643
644	#[test]
645	fn test_from_timestamp_nanos_max_u64_ok() {
646		let dt = DateTime::from_timestamp_nanos(u64::MAX as u128).unwrap();
647		assert_eq!(dt.to_nanos(), u64::MAX);
648	}
649
650	#[test]
651	fn test_from_timestamp_large_value_overflow() {
652		assert_datetime_overflow(DateTime::from_timestamp(i64::MAX));
653	}
654
655	#[test]
656	fn test_from_timestamp_negative_overflow() {
657		assert_datetime_overflow(DateTime::from_timestamp(-1));
658	}
659
660	#[test]
661	fn test_from_timestamp_millis_overflow() {
662		assert_datetime_overflow(DateTime::from_timestamp_millis(u64::MAX));
663	}
664
665	#[test]
666	fn test_from_timestamp_millis_boundary_ok() {
667		let dt = DateTime::from_timestamp_millis(1_700_000_000_000).unwrap();
668		assert!(dt.to_nanos() > 0);
669	}
670
671	#[test]
672	fn test_timestamp_nanos_large_value_returns_err() {
673		let dt = DateTime::from_nanos(i64::MAX as u64 + 1);
674		assert_datetime_overflow(dt.timestamp_nanos());
675	}
676
677	#[test]
678	fn test_timestamp_nanos_within_range_ok() {
679		let dt = DateTime::from_nanos(i64::MAX as u64);
680		assert_eq!(dt.timestamp_nanos().unwrap(), i64::MAX);
681	}
682
683	#[test]
684	fn test_try_date_max_nanos_ok() {
685		// u64::MAX nanos / NANOS_PER_DAY = 213_503 which fits in i32
686		let dt = DateTime::from_nanos(u64::MAX);
687		let date = dt.try_date().unwrap();
688		assert!(date.year() > 2500);
689	}
690
691	#[test]
692	fn test_add_duration_overflow() {
693		let dt = DateTime::from_nanos(u64::MAX - 1);
694		let dur = Duration::from_days(1).unwrap();
695		assert_datetime_overflow(dt.add_duration(&dur));
696	}
697
698	#[test]
699	fn test_add_duration_before_epoch() {
700		let dt = DateTime::new(1970, 1, 1, 0, 0, 0, 0).unwrap();
701		let dur = Duration::from_seconds(-1).unwrap();
702		assert_datetime_overflow(dt.add_duration(&dur));
703	}
704
705	#[test]
706	fn test_add_duration_negative_nanos_borrows_from_days() {
707		let dt = DateTime::new(2024, 3, 15, 0, 0, 30, 0).unwrap();
708		let dur = Duration::from_seconds(-60).unwrap();
709		let result = dt.add_duration(&dur).unwrap();
710		assert_eq!(result.year(), 2024);
711		assert_eq!(result.month(), 3);
712		assert_eq!(result.day(), 14);
713		assert_eq!(result.hour(), 23);
714		assert_eq!(result.minute(), 59);
715		assert_eq!(result.second(), 30);
716	}
717
718	#[test]
719	fn test_add_duration_nanos_overflow_into_next_day() {
720		let dt = DateTime::new(2024, 3, 15, 23, 59, 30, 0).unwrap();
721		let dur = Duration::from_seconds(60).unwrap();
722		let result = dt.add_duration(&dur).unwrap();
723		assert_eq!(result.year(), 2024);
724		assert_eq!(result.month(), 3);
725		assert_eq!(result.day(), 16);
726		assert_eq!(result.hour(), 0);
727		assert_eq!(result.minute(), 0);
728		assert_eq!(result.second(), 30);
729	}
730
731	#[test]
732	fn add_and_sub_duration_operators() {
733		let dt = DateTime::from_ymd_hms(2024, 1, 15, 10, 30, 25).unwrap();
734		let minute = Duration::from_seconds(60).unwrap();
735		assert_eq!(dt + minute, DateTime::from_ymd_hms(2024, 1, 15, 10, 31, 25).unwrap());
736		assert_eq!(dt - minute, DateTime::from_ymd_hms(2024, 1, 15, 10, 29, 25).unwrap());
737	}
738
739	#[test]
740	fn sub_datetime_yields_duration() {
741		let a = DateTime::from_ymd_hms(2024, 1, 15, 10, 31, 0).unwrap();
742		let b = DateTime::from_ymd_hms(2024, 1, 15, 10, 30, 0).unwrap();
743		assert_eq!(a - b, Duration::from_seconds(60).unwrap());
744	}
745
746	#[test]
747	fn rem_duration_aligns_to_window_boundary() {
748		let dt = DateTime::from_ymd_hms(2024, 1, 15, 10, 30, 25).unwrap();
749		let minute = Duration::from_seconds(60).unwrap();
750		// 25 seconds past the minute boundary.
751		assert_eq!(dt % minute, Duration::from_seconds(25).unwrap());
752		// The bucket-start computation `coord - (coord % dur)` lands on the boundary.
753		assert_eq!(dt - (dt % minute), DateTime::from_ymd_hms(2024, 1, 15, 10, 30, 0).unwrap());
754
755		// A 1s window leaves the second boundary in place (sub-minute correctness).
756		let second = Duration::from_seconds(1).unwrap();
757		assert_eq!(dt % second, Duration::from_seconds(0).unwrap());
758	}
759
760	#[test]
761	fn saturating_sub_below_epoch_clamps_to_epoch() {
762		// A rolling-window cutoff that would fall before 1970 must clamp to the
763		// epoch rather than panic the u64-nanos conversion (the chaos failure mode).
764		let epoch = DateTime::from_nanos(0);
765		assert_eq!(epoch.saturating_sub(Duration::from_seconds(1).unwrap()), epoch);
766
767		let early = DateTime::from_timestamp(5).unwrap();
768		assert_eq!(early.saturating_sub(Duration::from_seconds(10_000).unwrap()), epoch);
769	}
770
771	#[test]
772	fn checked_sub_returns_none_when_window_has_not_elapsed() {
773		// A TTL cutoff is only meaningful once the window has fully elapsed. When the duration
774		// reaches before the start of time (now < ttl), checked_sub must yield None so the GC
775		// scan skips eviction entirely - never clamp to the epoch, which would feed a bogus
776		// cutoff that evicts rows still inside their TTL.
777		let now = DateTime::from_timestamp_millis(1_000).unwrap();
778		assert_eq!(now.checked_sub(Duration::from_seconds(3).unwrap()), None);
779		assert_eq!(DateTime::from_nanos(0).checked_sub(Duration::from_seconds(1).unwrap()), None);
780	}
781
782	#[test]
783	fn checked_sub_matches_subtraction_when_in_range() {
784		let now = DateTime::from_ymd_hms(2024, 1, 15, 10, 30, 25).unwrap();
785		let minute = Duration::from_seconds(60).unwrap();
786		assert_eq!(now.checked_sub(minute), Some(now - minute));
787	}
788
789	#[test]
790	fn saturating_add_above_max_clamps_to_max() {
791		// Overflow past the representable u64-nanos range clamps to the max instant.
792		let near_max = DateTime::from_nanos(u64::MAX - 1);
793		assert_eq!(near_max.saturating_add(Duration::from_days(1).unwrap()), DateTime::from_nanos(u64::MAX));
794	}
795
796	#[test]
797	fn saturating_add_sub_match_operators_in_range() {
798		// In range, the saturating ops agree with the panicking +/- operators.
799		let dt = DateTime::from_ymd_hms(2024, 1, 15, 10, 30, 25).unwrap();
800		let minute = Duration::from_seconds(60).unwrap();
801		assert_eq!(dt.saturating_add(minute), dt + minute);
802		assert_eq!(dt.saturating_sub(minute), dt - minute);
803	}
804
805	#[test]
806	fn saturating_duration_since_normal_and_clamped() {
807		let a = DateTime::from_ymd_hms(2024, 1, 15, 10, 31, 0).unwrap();
808		let b = DateTime::from_ymd_hms(2024, 1, 15, 10, 30, 0).unwrap();
809		assert_eq!(a.saturating_duration_since(b), Duration::from_seconds(60).unwrap());
810		// Reversed order is a negative duration that still fits i64 (not clamped).
811		assert_eq!(b.saturating_duration_since(a), Duration::from_seconds(-60).unwrap());
812		// A gap wider than i64 nanoseconds clamps instead of panicking.
813		let clamped = DateTime::from_nanos(u64::MAX).saturating_duration_since(DateTime::from_nanos(0));
814		assert_eq!(clamped.as_nanos().unwrap(), i64::MAX);
815	}
816}
817
818#[cfg(test)]
819mod now_tests {
820	use super::DateTime;
821	use crate::clock::testing::TestClock;
822
823	#[test]
824	fn now_reads_the_clock() {
825		// Civil "now" is sourced from the (mockable) clock, so DST/tests control it.
826		let clock = TestClock::from_millis(1500);
827		assert_eq!(DateTime::now(&clock), DateTime::from_nanos(1_500_000_000));
828	}
829
830	#[test]
831	fn from_str_round_trips_display() {
832		let dt = DateTime::from_nanos(1_700_000_000_000_000_000);
833		let parsed: DateTime = dt.to_string().parse().unwrap();
834		assert_eq!(parsed, dt);
835	}
836
837	#[test]
838	fn from_str_rejects_garbage() {
839		assert!("not a datetime".parse::<DateTime>().is_err());
840	}
841}