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	error::{Error, TemporalKind, TypeError},
17	fragment::Fragment,
18	value::{date::Date, duration::Duration, temporal::parse::datetime::parse_datetime, time::Time},
19};
20
21const NANOS_PER_SECOND: u64 = 1_000_000_000;
22const NANOS_PER_MILLI: u64 = 1_000_000;
23const NANOS_PER_MICRO: u64 = 1_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";
28pub static TIME_COLUMN_NAME: &str = "time";
29
30#[repr(transparent)]
31#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
32pub struct DateTime {
33	bits: u64,
34}
35
36impl DateTime {
37	pub fn new(year: i32, month: u32, day: u32, hour: u32, min: u32, sec: u32, nano: u32) -> Option<Self> {
38		let date = Date::new(year, month, day)?;
39		let time = Time::new(hour, min, sec, nano)?;
40
41		let days = date.to_days_since_epoch();
42		if days < 0 {
43			return None;
44		}
45
46		let nanos = (days as u64).checked_mul(NANOS_PER_DAY)?.checked_add(time.to_nanos_since_midnight())?;
47		Some(Self {
48			bits: nanos,
49		})
50	}
51
52	pub fn from_ymd_hms(
53		year: i32,
54		month: u32,
55		day: u32,
56		hour: u32,
57		min: u32,
58		sec: u32,
59	) -> Result<Self, Box<TypeError>> {
60		Self::new(year, month, day, hour, min, sec, 0).ok_or_else(|| {
61			Box::new(Self::overflow_err(format!(
62				"invalid datetime: {}-{:02}-{:02} {:02}:{:02}:{:02}",
63				year, month, day, hour, min, sec
64			)))
65		})
66	}
67
68	fn overflow_err(message: impl Into<String>) -> TypeError {
69		TypeError::Temporal {
70			kind: TemporalKind::DateTimeOverflow {
71				message: message.into(),
72			},
73			message: "datetime overflow".to_string(),
74			fragment: Fragment::None,
75		}
76	}
77
78	pub fn from_bits(bits: u64) -> Self {
79		Self {
80			bits,
81		}
82	}
83
84	pub fn to_bits(&self) -> u64 {
85		self.bits
86	}
87
88	pub fn to_order(&self) -> u64 {
89		self.bits
90	}
91
92	pub fn from_nanos(nanos: u64) -> Self {
93		Self {
94			bits: nanos,
95		}
96	}
97
98	pub fn to_nanos(&self) -> u64 {
99		self.bits
100	}
101
102	pub fn from_epoch_secs(secs: i64) -> Result<Self, Box<TypeError>> {
103		if secs < 0 {
104			return Err(Box::new(Self::overflow_err(format!(
105				"{} seconds is before the Unix epoch, which DateTime cannot represent",
106				secs
107			))));
108		}
109		let nanos = (secs as u64).checked_mul(NANOS_PER_SECOND).ok_or_else(|| {
110			Box::new(Self::overflow_err(format!("{} seconds overflows DateTime range", secs)))
111		})?;
112		Ok(Self {
113			bits: nanos,
114		})
115	}
116
117	pub fn from_epoch_millis(millis: u64) -> Result<Self, Box<TypeError>> {
118		let nanos = millis.checked_mul(NANOS_PER_MILLI).ok_or_else(|| {
119			Box::new(Self::overflow_err(format!("{} milliseconds overflows DateTime range", millis)))
120		})?;
121		Ok(Self {
122			bits: nanos,
123		})
124	}
125
126	pub fn from_epoch_nanos(nanos: u128) -> Result<Self, Box<TypeError>> {
127		let nanos = u64::try_from(nanos).map_err(|_| {
128			Box::new(Self::overflow_err(format!("{} nanoseconds overflows DateTime range", nanos)))
129		})?;
130		Ok(Self {
131			bits: nanos,
132		})
133	}
134
135	pub fn to_epoch_secs(&self) -> i64 {
136		(self.bits / NANOS_PER_SECOND) as i64
137	}
138
139	pub fn to_epoch_millis(&self) -> i64 {
140		(self.bits / NANOS_PER_MILLI) as i64
141	}
142
143	pub fn to_epoch_nanos(&self) -> Result<i64, Box<TypeError>> {
144		i64::try_from(self.bits)
145			.map_err(|_| Box::new(Self::overflow_err("DateTime overflows nanosecond range")))
146	}
147
148	pub fn try_date(&self) -> Result<Date, Box<TypeError>> {
149		let days_u64 = self.bits / NANOS_PER_DAY;
150		let days = i32::try_from(days_u64)
151			.map_err(|_| Box::new(Self::overflow_err("DateTime overflows Date range")))?;
152		Date::from_days_since_epoch(days)
153			.ok_or_else(|| Box::new(Self::overflow_err("DateTime overflows Date range")))
154	}
155
156	pub fn date(&self) -> Date {
157		self.try_date().expect("DateTime overflows Date range")
158	}
159
160	pub fn time(&self) -> Time {
161		let nanos_in_day = self.bits % NANOS_PER_DAY;
162		Time::from_nanos_since_midnight(nanos_in_day).unwrap()
163	}
164
165	pub fn to_nanos_since_epoch_u128(&self) -> u128 {
166		self.bits as u128
167	}
168
169	pub fn year(&self) -> i32 {
170		self.date().year()
171	}
172
173	pub fn month(&self) -> u32 {
174		self.date().month()
175	}
176
177	pub fn day(&self) -> u32 {
178		self.date().day()
179	}
180
181	pub fn hour(&self) -> u32 {
182		self.time().hour()
183	}
184
185	pub fn minute(&self) -> u32 {
186		self.time().minute()
187	}
188
189	pub fn second(&self) -> u32 {
190		self.time().second()
191	}
192
193	pub fn nanosecond(&self) -> u32 {
194		self.time().nanosecond()
195	}
196
197	pub fn add_duration(&self, dur: &Duration) -> Result<Self, Box<TypeError>> {
198		let date = self.date();
199		let time = self.time();
200		let mut year = date.year();
201		let mut month = date.month() as i32;
202		let mut day = date.day();
203
204		let total_months = month + dur.get_months();
205		year += (total_months - 1).div_euclid(12);
206		month = (total_months - 1).rem_euclid(12) + 1;
207
208		let max_day = Date::days_in_month(year, month as u32);
209		if day > max_day {
210			day = max_day;
211		}
212
213		let base_date = Date::new(year, month as u32, day).ok_or_else(|| {
214			Box::new(Self::overflow_err(format!(
215				"invalid datetime after adding duration: {}-{:02}-{:02}",
216				year, month, day
217			)))
218		})?;
219		let base_days = base_date.to_days_since_epoch() as i64 + dur.get_days() as i64;
220		let time_nanos = time.to_nanos_since_midnight() as i64 + dur.get_nanos();
221
222		let total_nanos = base_days as i128 * 86_400_000_000_000i128 + time_nanos as i128;
223
224		if total_nanos < 0 {
225			return Err(Box::new(Self::overflow_err(
226				"the result is before the Unix epoch, which DateTime cannot represent",
227			)));
228		}
229
230		let nanos = u64::try_from(total_nanos)
231			.map_err(|_| Box::new(Self::overflow_err("the result overflows DateTime range")))?;
232		Ok(Self {
233			bits: nanos,
234		})
235	}
236}
237
238impl DateTime {
239	pub fn saturating_add(self, rhs: Duration) -> DateTime {
240		let total = rhs.as_nanos().unwrap_or(if rhs.is_negative() {
241			i64::MIN
242		} else {
243			i64::MAX
244		});
245		let nanos = (self.to_nanos() as i128 + total as i128).clamp(0, u64::MAX as i128);
246		DateTime::from_nanos(nanos as u64)
247	}
248
249	pub fn saturating_sub(self, rhs: Duration) -> DateTime {
250		let total = rhs.as_nanos().unwrap_or(if rhs.is_negative() {
251			i64::MIN
252		} else {
253			i64::MAX
254		});
255		let nanos = (self.to_nanos() as i128 - total as i128).clamp(0, u64::MAX as i128);
256		DateTime::from_nanos(nanos as u64)
257	}
258
259	pub fn checked_add(self, rhs: Duration) -> Option<DateTime> {
260		let total = rhs.as_nanos().ok()?;
261		let nanos = self.to_nanos() as i128 + total as i128;
262		if nanos < 0 || nanos > u64::MAX as i128 {
263			None
264		} else {
265			Some(DateTime::from_nanos(nanos as u64))
266		}
267	}
268
269	pub fn checked_sub(self, rhs: Duration) -> Option<DateTime> {
270		let total = rhs.as_nanos().ok()?;
271		let nanos = self.to_nanos() as i128 - total as i128;
272		if nanos < 0 || nanos > u64::MAX as i128 {
273			None
274		} else {
275			Some(DateTime::from_nanos(nanos as u64))
276		}
277	}
278
279	pub fn saturating_duration_since(self, earlier: DateTime) -> Duration {
280		let diff = (self.to_nanos() as i128 - earlier.to_nanos() as i128)
281			.clamp(i64::MIN as i128, i64::MAX as i128) as i64;
282		Duration::from_nanoseconds(diff).unwrap_or_else(|_| Duration::zero())
283	}
284}
285
286impl DateTime {
287	pub const ALIGNMENT: usize = 8;
288
289	pub const EPOCH: DateTime = DateTime {
290		bits: 0,
291	};
292
293	pub const MAX: DateTime = DateTime {
294		bits: u64::MAX,
295	};
296
297	pub fn is_epoch(&self) -> bool {
298		self.bits == 0
299	}
300
301	pub fn from_millis(millis: u64) -> Self {
302		Self {
303			bits: millis.saturating_mul(NANOS_PER_MILLI),
304		}
305	}
306
307	pub fn to_millis(&self) -> u64 {
308		self.bits / NANOS_PER_MILLI
309	}
310
311	pub fn to_micros(&self) -> u64 {
312		self.bits / NANOS_PER_MICRO
313	}
314
315	pub fn to_secs(&self) -> u64 {
316		self.bits / NANOS_PER_SECOND
317	}
318
319	pub fn saturating_add_millis(self, millis: u64) -> DateTime {
320		DateTime::from_nanos(self.bits.saturating_add(millis.saturating_mul(NANOS_PER_MILLI)))
321	}
322
323	pub fn saturating_sub_millis(self, millis: u64) -> DateTime {
324		DateTime::from_nanos(self.bits.saturating_sub(millis.saturating_mul(NANOS_PER_MILLI)))
325	}
326
327	pub fn floor_to_millis(self, millis: u64) -> DateTime {
328		let width = millis.saturating_mul(NANOS_PER_MILLI);
329		if width == 0 {
330			return self;
331		}
332		DateTime::from_nanos(self.bits - self.bits % width)
333	}
334}
335
336impl Add<Duration> for DateTime {
337	type Output = DateTime;
338
339	#[inline]
340	fn add(self, rhs: Duration) -> DateTime {
341		let total = rhs.as_nanos().expect("duration exceeds i64 nanoseconds");
342		let nanos = self.to_nanos() as i128 + total as i128;
343		DateTime::from_nanos(u64::try_from(nanos).expect("datetime addition out of range"))
344	}
345}
346
347impl Sub<Duration> for DateTime {
348	type Output = DateTime;
349
350	#[inline]
351	fn sub(self, rhs: Duration) -> DateTime {
352		let total = rhs.as_nanos().expect("duration exceeds i64 nanoseconds");
353		let nanos = self.to_nanos() as i128 - total as i128;
354		DateTime::from_nanos(u64::try_from(nanos).expect("datetime subtraction out of range"))
355	}
356}
357
358impl Sub<DateTime> for DateTime {
359	type Output = Duration;
360
361	#[inline]
362	fn sub(self, rhs: DateTime) -> Duration {
363		let diff = self.to_nanos() as i128 - rhs.to_nanos() as i128;
364		Duration::from_nanoseconds(i64::try_from(diff).expect("datetime difference exceeds i64 nanoseconds"))
365			.expect("datetime difference out of duration range")
366	}
367}
368
369impl Rem<Duration> for DateTime {
370	type Output = Duration;
371
372	#[inline]
373	fn rem(self, rhs: Duration) -> Duration {
374		let total = rhs.as_nanos().expect("duration exceeds i64 nanoseconds");
375		let total = u64::try_from(total).expect("duration must be positive for windowing");
376		Duration::from_nanoseconds((self.to_nanos() % total) as i64)
377			.expect("datetime remainder out of duration range")
378	}
379}
380
381impl Display for DateTime {
382	fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
383		let date = self.date();
384		let time = self.time();
385
386		write!(f, "{}T{}Z", date, time)
387	}
388}
389
390impl Serialize for DateTime {
391	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
392	where
393		S: Serializer,
394	{
395		serializer.serialize_u64(self.to_bits())
396	}
397}
398
399struct DateTimeVisitor;
400
401impl<'de> Visitor<'de> for DateTimeVisitor {
402	type Value = DateTime;
403
404	fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
405		formatter.write_str("a datetime as its bit representation since the Unix epoch (u64)")
406	}
407
408	fn visit_u64<E>(self, value: u64) -> Result<DateTime, E>
409	where
410		E: de::Error,
411	{
412		Ok(DateTime::from_bits(value))
413	}
414}
415
416impl<'de> Deserialize<'de> for DateTime {
417	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
418	where
419		D: Deserializer<'de>,
420	{
421		deserializer.deserialize_u64(DateTimeVisitor)
422	}
423}
424
425impl FromStr for DateTime {
426	type Err = Error;
427
428	fn from_str(s: &str) -> Result<Self, Self::Err> {
429		parse_datetime(Fragment::internal(s.trim()))
430	}
431}
432
433#[cfg(test)]
434pub mod tests {
435	use std::fmt::Debug;
436
437	use postcard::{from_bytes, to_allocvec};
438	use serde_json::{from_str, to_string};
439
440	use crate::{
441		error::{TemporalKind, TypeError},
442		value::{datetime::DateTime, duration::Duration},
443	};
444
445	#[test]
446	fn test_datetime_display_standard_format() {
447		let datetime = DateTime::new(2024, 3, 15, 14, 30, 45, 123456789).unwrap();
448		assert_eq!(format!("{}", datetime), "2024-03-15T14:30:45.123456789Z");
449
450		let datetime = DateTime::new(2000, 1, 1, 0, 0, 0, 0).unwrap();
451		assert_eq!(format!("{}", datetime), "2000-01-01T00:00:00.000000000Z");
452
453		let datetime = DateTime::new(1999, 12, 31, 23, 59, 59, 999999999).unwrap();
454		assert_eq!(format!("{}", datetime), "1999-12-31T23:59:59.999999999Z");
455	}
456
457	#[test]
458	fn test_datetime_display_millisecond_precision() {
459		let datetime = DateTime::new(2024, 3, 15, 14, 30, 45, 123000000).unwrap();
460		assert_eq!(format!("{}", datetime), "2024-03-15T14:30:45.123000000Z");
461
462		let datetime = DateTime::new(2024, 3, 15, 14, 30, 45, 001000000).unwrap();
463		assert_eq!(format!("{}", datetime), "2024-03-15T14:30:45.001000000Z");
464
465		let datetime = DateTime::new(2024, 3, 15, 14, 30, 45, 999000000).unwrap();
466		assert_eq!(format!("{}", datetime), "2024-03-15T14:30:45.999000000Z");
467	}
468
469	#[test]
470	fn test_datetime_display_microsecond_precision() {
471		let datetime = DateTime::new(2024, 3, 15, 14, 30, 45, 123456000).unwrap();
472		assert_eq!(format!("{}", datetime), "2024-03-15T14:30:45.123456000Z");
473
474		let datetime = DateTime::new(2024, 3, 15, 14, 30, 45, 000001000).unwrap();
475		assert_eq!(format!("{}", datetime), "2024-03-15T14:30:45.000001000Z");
476
477		let datetime = DateTime::new(2024, 3, 15, 14, 30, 45, 999999000).unwrap();
478		assert_eq!(format!("{}", datetime), "2024-03-15T14:30:45.999999000Z");
479	}
480
481	#[test]
482	fn test_datetime_display_nanosecond_precision() {
483		let datetime = DateTime::new(2024, 3, 15, 14, 30, 45, 123456789).unwrap();
484		assert_eq!(format!("{}", datetime), "2024-03-15T14:30:45.123456789Z");
485
486		let datetime = DateTime::new(2024, 3, 15, 14, 30, 45, 000000001).unwrap();
487		assert_eq!(format!("{}", datetime), "2024-03-15T14:30:45.000000001Z");
488
489		let datetime = DateTime::new(2024, 3, 15, 14, 30, 45, 999999999).unwrap();
490		assert_eq!(format!("{}", datetime), "2024-03-15T14:30:45.999999999Z");
491	}
492
493	#[test]
494	fn test_datetime_display_zero_fractional_seconds() {
495		let datetime = DateTime::new(2024, 3, 15, 14, 30, 45, 0).unwrap();
496		assert_eq!(format!("{}", datetime), "2024-03-15T14:30:45.000000000Z");
497
498		let datetime = DateTime::new(2024, 3, 15, 0, 0, 0, 0).unwrap();
499		assert_eq!(format!("{}", datetime), "2024-03-15T00:00:00.000000000Z");
500	}
501
502	#[test]
503	fn test_datetime_display_edge_times() {
504		let datetime = DateTime::new(2024, 3, 15, 0, 0, 0, 0).unwrap();
505		assert_eq!(format!("{}", datetime), "2024-03-15T00:00:00.000000000Z");
506
507		let datetime = DateTime::new(2024, 3, 15, 23, 59, 59, 999999999).unwrap();
508		assert_eq!(format!("{}", datetime), "2024-03-15T23:59:59.999999999Z");
509
510		let datetime = DateTime::new(2024, 3, 15, 12, 0, 0, 0).unwrap();
511		assert_eq!(format!("{}", datetime), "2024-03-15T12:00:00.000000000Z");
512	}
513
514	#[test]
515	fn test_datetime_display_unix_epoch() {
516		let datetime = DateTime::new(1970, 1, 1, 0, 0, 0, 0).unwrap();
517		assert_eq!(format!("{}", datetime), "1970-01-01T00:00:00.000000000Z");
518
519		let datetime = DateTime::new(1970, 1, 1, 0, 0, 1, 0).unwrap();
520		assert_eq!(format!("{}", datetime), "1970-01-01T00:00:01.000000000Z");
521	}
522
523	#[test]
524	fn test_datetime_display_leap_year() {
525		let datetime = DateTime::new(2024, 2, 29, 12, 30, 45, 123456789).unwrap();
526		assert_eq!(format!("{}", datetime), "2024-02-29T12:30:45.123456789Z");
527
528		let datetime = DateTime::new(2000, 2, 29, 0, 0, 0, 0).unwrap();
529		assert_eq!(format!("{}", datetime), "2000-02-29T00:00:00.000000000Z");
530	}
531
532	#[test]
533	fn test_datetime_display_boundary_dates() {
534		let datetime = DateTime::new(2000, 1, 1, 0, 0, 0, 0).unwrap();
535		assert_eq!(format!("{}", datetime), "2000-01-01T00:00:00.000000000Z");
536
537		let datetime = DateTime::new(2100, 1, 1, 0, 0, 0, 0).unwrap();
538		assert_eq!(format!("{}", datetime), "2100-01-01T00:00:00.000000000Z");
539
540		// u64 nanos since the epoch runs out around year 2554.
541		let datetime = DateTime::new(2554, 1, 1, 0, 0, 0, 0).unwrap();
542		assert_eq!(format!("{}", datetime), "2554-01-01T00:00:00.000000000Z");
543
544		assert!(DateTime::new(9999, 12, 31, 23, 59, 59, 999999999).is_none());
545	}
546
547	#[test]
548	fn test_datetime_rejects_pre_epoch() {
549		// u64 nanos cannot represent anything before 1970.
550		assert!(DateTime::new(1, 1, 1, 0, 0, 0, 0).is_none());
551
552		assert!(DateTime::new(1900, 1, 1, 0, 0, 0, 0).is_none());
553
554		assert!(DateTime::new(1969, 12, 31, 23, 59, 59, 999999999).is_none());
555
556		assert!(DateTime::from_epoch_secs(-1).is_err());
557	}
558
559	#[test]
560	fn test_datetime_display_default() {
561		let datetime = DateTime::default();
562		assert_eq!(format!("{}", datetime), "1970-01-01T00:00:00.000000000Z");
563	}
564
565	#[test]
566	fn test_datetime_display_all_hours() {
567		for hour in 0..24 {
568			let datetime = DateTime::new(2024, 3, 15, hour, 30, 45, 123456789).unwrap();
569			let expected = format!("2024-03-15T{:02}:30:45.123456789Z", hour);
570			assert_eq!(format!("{}", datetime), expected);
571		}
572	}
573
574	#[test]
575	fn test_datetime_display_all_minutes() {
576		for minute in 0..60 {
577			let datetime = DateTime::new(2024, 3, 15, 14, minute, 45, 123456789).unwrap();
578			let expected = format!("2024-03-15T14:{:02}:45.123456789Z", minute);
579			assert_eq!(format!("{}", datetime), expected);
580		}
581	}
582
583	#[test]
584	fn test_datetime_display_all_seconds() {
585		for second in 0..60 {
586			let datetime = DateTime::new(2024, 3, 15, 14, 30, second, 123456789).unwrap();
587			let expected = format!("2024-03-15T14:30:{:02}.123456789Z", second);
588			assert_eq!(format!("{}", datetime), expected);
589		}
590	}
591
592	#[test]
593	fn test_datetime_display_from_epoch_secs() {
594		let datetime = DateTime::from_epoch_secs(0).unwrap();
595		assert_eq!(format!("{}", datetime), "1970-01-01T00:00:00.000000000Z");
596
597		let datetime = DateTime::from_epoch_secs(1234567890).unwrap();
598		assert_eq!(format!("{}", datetime), "2009-02-13T23:31:30.000000000Z");
599	}
600
601	#[test]
602	fn test_datetime_display_from_epoch_millis() {
603		let datetime = DateTime::from_epoch_millis(1234567890123).unwrap();
604		assert_eq!(format!("{}", datetime), "2009-02-13T23:31:30.123000000Z");
605
606		let datetime = DateTime::from_epoch_millis(0).unwrap();
607		assert_eq!(format!("{}", datetime), "1970-01-01T00:00:00.000000000Z");
608	}
609
610	#[test]
611	fn test_datetime_bits_roundtrip_preserves_every_component() {
612		// every key encoding and the serde impl go through to_bits, so a lossy leg moves stored instants unseen
613		let cases = [
614			DateTime::new(1970, 1, 1, 0, 0, 0, 0).unwrap(),
615			DateTime::new(2024, 3, 15, 14, 30, 45, 123456789).unwrap(),
616			DateTime::new(2000, 2, 29, 23, 59, 59, 999999999).unwrap(),
617			DateTime::MAX,
618		];
619
620		for datetime in cases {
621			let recovered = DateTime::from_bits(datetime.to_bits());
622
623			assert_eq!(datetime, recovered);
624			assert_eq!(datetime.nanosecond(), recovered.nanosecond(), "sub-second precision must survive");
625		}
626	}
627
628	#[test]
629	fn test_datetime_bits_are_monotonic_in_instant_order() {
630		// key encodings sort on the raw bits, so a disagreeing order would fire timers out of sequence
631		let ordered = [
632			DateTime::new(1970, 1, 1, 0, 0, 0, 0).unwrap(),
633			DateTime::new(1970, 1, 1, 0, 0, 0, 1).unwrap(),
634			DateTime::new(2024, 3, 15, 14, 30, 45, 123456789).unwrap(),
635			DateTime::new(2024, 3, 15, 14, 30, 45, 123456790).unwrap(),
636			DateTime::MAX,
637		];
638
639		for pair in ordered.windows(2) {
640			let (lo, hi) = (pair[0], pair[1]);
641			assert!(lo < hi, "fixture must be ordered");
642			assert!(lo.to_bits() < hi.to_bits(), "bit order must follow instant order");
643		}
644	}
645
646	#[test]
647	fn test_datetime_from_nanos_roundtrip() {
648		let datetime = DateTime::new(2024, 3, 15, 14, 30, 45, 123456789).unwrap();
649		let nanos = datetime.to_nanos();
650		let recovered = DateTime::from_nanos(nanos);
651		assert_eq!(datetime, recovered);
652	}
653
654	#[test]
655	fn test_datetime_roundtrip() {
656		let test_cases = [
657			(1970, 1, 1, 0, 0, 0, 0u32),
658			(2024, 3, 15, 14, 30, 45, 123456789),
659			(2000, 2, 29, 23, 59, 59, 999999999),
660		];
661
662		for (y, m, d, h, min, s, n) in test_cases {
663			let datetime = DateTime::new(y, m, d, h, min, s, n).unwrap();
664			let nanos = datetime.to_nanos();
665			let recovered = DateTime::from_nanos(nanos);
666
667			assert_eq!(datetime.year(), recovered.year());
668			assert_eq!(datetime.month(), recovered.month());
669			assert_eq!(datetime.day(), recovered.day());
670			assert_eq!(datetime.hour(), recovered.hour());
671			assert_eq!(datetime.minute(), recovered.minute());
672			assert_eq!(datetime.second(), recovered.second());
673			assert_eq!(datetime.nanosecond(), recovered.nanosecond());
674		}
675	}
676
677	#[test]
678	fn test_datetime_components() {
679		let datetime = DateTime::new(2024, 3, 15, 14, 30, 45, 123456789).unwrap();
680
681		assert_eq!(datetime.year(), 2024);
682		assert_eq!(datetime.month(), 3);
683		assert_eq!(datetime.day(), 15);
684		assert_eq!(datetime.hour(), 14);
685		assert_eq!(datetime.minute(), 30);
686		assert_eq!(datetime.second(), 45);
687		assert_eq!(datetime.nanosecond(), 123456789);
688	}
689
690	#[test]
691	fn test_serde_roundtrip() {
692		let datetime = DateTime::new(2024, 3, 15, 14, 30, 45, 123456789).unwrap();
693		let json = to_string(&datetime).unwrap();
694		// Wire format is the raw nanos-since-epoch integer, not an ISO-8601 string.
695		assert_eq!(json, datetime.to_nanos().to_string());
696
697		let recovered: DateTime = from_str(&json).unwrap();
698		assert_eq!(datetime, recovered);
699	}
700
701	#[test]
702	fn test_serde_postcard_roundtrip_preserves_all_components() {
703		// Postcard is the CDC wire format; sub-second nanos must survive it or consumers
704		// reconstruct the wrong instant.
705		for (y, mo, d, h, mi, s, n) in [
706			(1970u32 as i32, 1u32, 1u32, 0u32, 0u32, 0u32, 0u32),
707			(2024, 3, 15, 14, 30, 45, 123456789),
708			(1999, 12, 31, 23, 59, 59, 999999999),
709			(2024, 3, 15, 14, 30, 45, 1),
710		] {
711			let dt = DateTime::new(y, mo, d, h, mi, s, n).unwrap();
712			let bytes = to_allocvec(&dt).unwrap();
713			let recovered: DateTime = from_bytes(&bytes).unwrap();
714			assert_eq!(dt, recovered);
715			assert_eq!(recovered.year(), y);
716			assert_eq!(recovered.month(), mo);
717			assert_eq!(recovered.day(), d);
718			assert_eq!(recovered.hour(), h);
719			assert_eq!(recovered.minute(), mi);
720			assert_eq!(recovered.second(), s);
721			assert_eq!(recovered.nanosecond(), n);
722		}
723	}
724
725	fn assert_datetime_overflow<T: Debug>(result: Result<T, Box<TypeError>>) {
726		let err = result.expect_err("expected DateTimeOverflow error");
727		match *err {
728			TypeError::Temporal {
729				kind: TemporalKind::DateTimeOverflow {
730					..
731				},
732				..
733			} => {}
734			other => panic!("expected DateTimeOverflow, got: {:?}", other),
735		}
736	}
737
738	#[test]
739	fn test_from_epoch_nanos_overflow() {
740		let huge: u128 = u64::MAX as u128 + 1;
741		assert_datetime_overflow(DateTime::from_epoch_nanos(huge));
742	}
743
744	#[test]
745	fn test_from_epoch_nanos_max_u64_ok() {
746		let dt = DateTime::from_epoch_nanos(u64::MAX as u128).unwrap();
747		assert_eq!(dt.to_nanos(), u64::MAX);
748	}
749
750	#[test]
751	fn test_from_epoch_secs_large_value_overflow() {
752		assert_datetime_overflow(DateTime::from_epoch_secs(i64::MAX));
753	}
754
755	#[test]
756	fn test_from_epoch_secs_negative_overflow() {
757		assert_datetime_overflow(DateTime::from_epoch_secs(-1));
758	}
759
760	#[test]
761	fn test_from_epoch_millis_overflow() {
762		assert_datetime_overflow(DateTime::from_epoch_millis(u64::MAX));
763	}
764
765	#[test]
766	fn test_from_epoch_millis_boundary_ok() {
767		let dt = DateTime::from_epoch_millis(1_700_000_000_000).unwrap();
768		assert!(dt.to_nanos() > 0);
769	}
770
771	#[test]
772	fn test_to_epoch_nanos_large_value_returns_err() {
773		let dt = DateTime::from_nanos(i64::MAX as u64 + 1);
774		assert_datetime_overflow(dt.to_epoch_nanos());
775	}
776
777	#[test]
778	fn test_to_epoch_nanos_within_range_ok() {
779		let dt = DateTime::from_nanos(i64::MAX as u64);
780		assert_eq!(dt.to_epoch_nanos().unwrap(), i64::MAX);
781	}
782
783	#[test]
784	fn test_try_date_max_nanos_ok() {
785		// u64::MAX nanos / NANOS_PER_DAY = 213_503 which fits in i32
786		let dt = DateTime::from_nanos(u64::MAX);
787		let date = dt.try_date().unwrap();
788		assert!(date.year() > 2500);
789	}
790
791	#[test]
792	fn test_add_duration_overflow() {
793		let dt = DateTime::from_nanos(u64::MAX - 1);
794		let dur = Duration::from_days(1).unwrap();
795		assert_datetime_overflow(dt.add_duration(&dur));
796	}
797
798	#[test]
799	fn test_add_duration_before_epoch() {
800		let dt = DateTime::new(1970, 1, 1, 0, 0, 0, 0).unwrap();
801		let dur = Duration::from_seconds(-1).unwrap();
802		assert_datetime_overflow(dt.add_duration(&dur));
803	}
804
805	#[test]
806	fn test_add_duration_negative_nanos_borrows_from_days() {
807		let dt = DateTime::new(2024, 3, 15, 0, 0, 30, 0).unwrap();
808		let dur = Duration::from_seconds(-60).unwrap();
809		let result = dt.add_duration(&dur).unwrap();
810		assert_eq!(result.year(), 2024);
811		assert_eq!(result.month(), 3);
812		assert_eq!(result.day(), 14);
813		assert_eq!(result.hour(), 23);
814		assert_eq!(result.minute(), 59);
815		assert_eq!(result.second(), 30);
816	}
817
818	#[test]
819	fn test_add_duration_nanos_overflow_into_next_day() {
820		let dt = DateTime::new(2024, 3, 15, 23, 59, 30, 0).unwrap();
821		let dur = Duration::from_seconds(60).unwrap();
822		let result = dt.add_duration(&dur).unwrap();
823		assert_eq!(result.year(), 2024);
824		assert_eq!(result.month(), 3);
825		assert_eq!(result.day(), 16);
826		assert_eq!(result.hour(), 0);
827		assert_eq!(result.minute(), 0);
828		assert_eq!(result.second(), 30);
829	}
830
831	#[test]
832	fn add_and_sub_duration_operators() {
833		let dt = DateTime::from_ymd_hms(2024, 1, 15, 10, 30, 25).unwrap();
834		let minute = Duration::from_seconds(60).unwrap();
835		assert_eq!(dt + minute, DateTime::from_ymd_hms(2024, 1, 15, 10, 31, 25).unwrap());
836		assert_eq!(dt - minute, DateTime::from_ymd_hms(2024, 1, 15, 10, 29, 25).unwrap());
837	}
838
839	#[test]
840	fn sub_datetime_yields_duration() {
841		let a = DateTime::from_ymd_hms(2024, 1, 15, 10, 31, 0).unwrap();
842		let b = DateTime::from_ymd_hms(2024, 1, 15, 10, 30, 0).unwrap();
843		assert_eq!(a - b, Duration::from_seconds(60).unwrap());
844	}
845
846	#[test]
847	fn rem_duration_aligns_to_window_boundary() {
848		// Window bucket starts are computed as `coord - (coord % width)`.
849		let dt = DateTime::from_ymd_hms(2024, 1, 15, 10, 30, 25).unwrap();
850		let minute = Duration::from_seconds(60).unwrap();
851		assert_eq!(dt % minute, Duration::from_seconds(25).unwrap());
852		assert_eq!(dt - (dt % minute), DateTime::from_ymd_hms(2024, 1, 15, 10, 30, 0).unwrap());
853
854		let second = Duration::from_seconds(1).unwrap();
855		assert_eq!(dt % second, Duration::from_seconds(0).unwrap());
856	}
857
858	#[test]
859	fn saturating_sub_below_epoch_clamps_to_epoch() {
860		// A cutoff falling before 1970 must clamp to the epoch, not panic the u64-nanos
861		// conversion.
862		let epoch = DateTime::from_nanos(0);
863		assert_eq!(epoch.saturating_sub(Duration::from_seconds(1).unwrap()), epoch);
864
865		let early = DateTime::from_epoch_secs(5).unwrap();
866		assert_eq!(early.saturating_sub(Duration::from_seconds(10_000).unwrap()), epoch);
867	}
868
869	#[test]
870	fn checked_sub_returns_none_when_window_has_not_elapsed() {
871		// When now < ttl the cutoff must be None so the GC scan skips eviction; clamping to
872		// the epoch would evict rows still inside their TTL.
873		let now = DateTime::from_epoch_millis(1_000).unwrap();
874		assert_eq!(now.checked_sub(Duration::from_seconds(3).unwrap()), None);
875		assert_eq!(DateTime::from_nanos(0).checked_sub(Duration::from_seconds(1).unwrap()), None);
876	}
877
878	#[test]
879	fn checked_sub_matches_subtraction_when_in_range() {
880		let now = DateTime::from_ymd_hms(2024, 1, 15, 10, 30, 25).unwrap();
881		let minute = Duration::from_seconds(60).unwrap();
882		assert_eq!(now.checked_sub(minute), Some(now - minute));
883	}
884
885	#[test]
886	fn saturating_add_above_max_clamps_to_max() {
887		// Overflow past the representable u64-nanos range clamps to the max instant.
888		let near_max = DateTime::from_nanos(u64::MAX - 1);
889		assert_eq!(near_max.saturating_add(Duration::from_days(1).unwrap()), DateTime::from_nanos(u64::MAX));
890	}
891
892	#[test]
893	fn saturating_add_sub_match_operators_in_range() {
894		// In range, the saturating ops agree with the panicking +/- operators.
895		let dt = DateTime::from_ymd_hms(2024, 1, 15, 10, 30, 25).unwrap();
896		let minute = Duration::from_seconds(60).unwrap();
897		assert_eq!(dt.saturating_add(minute), dt + minute);
898		assert_eq!(dt.saturating_sub(minute), dt - minute);
899	}
900
901	#[test]
902	fn saturating_duration_since_normal_and_clamped() {
903		// A gap wider than i64 nanoseconds must clamp rather than panic; a reversed pair is a
904		// negative duration, not a clamp.
905		let a = DateTime::from_ymd_hms(2024, 1, 15, 10, 31, 0).unwrap();
906		let b = DateTime::from_ymd_hms(2024, 1, 15, 10, 30, 0).unwrap();
907		assert_eq!(a.saturating_duration_since(b), Duration::from_seconds(60).unwrap());
908		assert_eq!(b.saturating_duration_since(a), Duration::from_seconds(-60).unwrap());
909		let clamped = DateTime::from_nanos(u64::MAX).saturating_duration_since(DateTime::from_nanos(0));
910		assert_eq!(clamped.as_nanos().unwrap(), i64::MAX);
911	}
912
913	#[test]
914	fn checked_add_matches_addition_when_in_range() {
915		let now = DateTime::from_ymd_hms(2024, 1, 15, 10, 30, 25).unwrap();
916		let minute = Duration::from_seconds(60).unwrap();
917		assert_eq!(now.checked_add(minute), Some(now + minute));
918	}
919
920	#[test]
921	fn checked_add_returns_none_past_the_representable_range() {
922		// An expiry past the end of the range must be None; wrapping yields a small instant
923		// that reads as already expired.
924		let near_max = DateTime::from_nanos(u64::MAX - 1);
925		assert_eq!(near_max.checked_add(Duration::from_days(1).unwrap()), None);
926	}
927
928	#[test]
929	fn checked_sub_of_a_negative_duration_cannot_wrap_past_the_range() {
930		// Subtracting a negative duration moves forward, so checked_sub must check the upper
931		// bound too.
932		let near_max = DateTime::from_nanos(u64::MAX - 1);
933		assert_eq!(near_max.checked_sub(Duration::from_days(-1).unwrap()), None);
934	}
935
936	#[test]
937	fn floor_to_millis_reproduces_the_millis_truncated_bucket_boundary() {
938		// THE test for the nanos migration. Bucket starts are computed today by truncating the
939		// instant to millis, taking the remainder against a millis-wide window, and multiplying
940		// back up. Doing the same arithmetic in nanos must land on the identical boundary, or a
941		// row near an edge silently changes bucket and every downstream aggregate moves with it.
942		// The instant deliberately carries sub-millisecond digits, which is where the two paths
943		// would diverge if the flooring were done in the wrong order.
944		// Mutation: round instead of floor, or truncate the instant to millis first, and the two
945		// paths disagree.
946		let nanos = 1_700_000_123_456_789u64;
947		let window_ms = 1_000u64;
948
949		let ts_ms = nanos / 1_000_000;
950		let legacy_bucket_start_nanos = (ts_ms - ts_ms % window_ms) * 1_000_000;
951
952		assert_eq!(
953			DateTime::from_nanos(nanos).floor_to_millis(window_ms),
954			DateTime::from_nanos(legacy_bucket_start_nanos)
955		);
956	}
957
958	#[test]
959	fn floor_to_millis_agrees_with_the_rem_duration_operator() {
960		// floor_to_millis replaces `coord - (coord % width)` at the window call sites, so the
961		// two forms must agree exactly.
962		let dt = DateTime::from_nanos(1_700_000_123_456_789);
963		let width_ms = 60_000u64;
964		let width = Duration::from_milliseconds(width_ms as i64).unwrap();
965
966		assert_eq!(dt.floor_to_millis(width_ms), dt - (dt % width));
967	}
968
969	#[test]
970	fn floor_to_millis_keeps_a_boundary_instant_where_it_is() {
971		// An instant exactly on a boundary belongs to the bucket it opens, not the one before.
972		let width_ms = 1_000u64;
973		let boundary = DateTime::from_nanos(2_000_000_000);
974
975		assert_eq!(
976			DateTime::from_nanos(1_999_999_999).floor_to_millis(width_ms),
977			DateTime::from_nanos(1_000_000_000)
978		);
979		assert_eq!(boundary.floor_to_millis(width_ms), boundary);
980		assert_eq!(DateTime::from_nanos(2_000_000_001).floor_to_millis(width_ms), boundary);
981	}
982
983	#[test]
984	fn floor_to_millis_of_a_zero_width_grid_is_the_instant_itself() {
985		// Zero width is rejected where windows are defined; this helper must still stay total
986		// rather than dividing by zero inside a flow tick, and identity cannot fabricate a bucket.
987		let dt = DateTime::from_nanos(1_700_000_123_456_789);
988		assert_eq!(dt.floor_to_millis(0), dt);
989	}
990
991	#[test]
992	fn saturating_sub_millis_clamps_at_the_epoch() {
993		// Cold start: an unadvanced watermark minus a TTL must mean "nothing is due", not an
994		// underflowed instant near u64::MAX that would evict everything.
995		assert_eq!(DateTime::EPOCH.saturating_sub_millis(30_000), DateTime::EPOCH);
996		assert_eq!(DateTime::from_nanos(1_000_000).saturating_sub_millis(30_000), DateTime::EPOCH);
997	}
998
999	#[test]
1000	fn saturating_add_millis_clamps_at_the_maximum() {
1001		// Wrapping addition would turn an expiry past the range into a past instant, which
1002		// reads as already expired.
1003		assert_eq!(DateTime::MAX.saturating_add_millis(1), DateTime::MAX);
1004		assert_eq!(DateTime::from_nanos(1_000_000).saturating_add_millis(1), DateTime::from_nanos(2_000_000));
1005	}
1006
1007	#[test]
1008	fn millis_conversions_round_trip_and_truncate_in_one_direction_only() {
1009		// Widening millis to nanos is lossless, narrowing truncates, so converting sites must
1010		// converge on nanos - a round trip through millis drops sub-millisecond digits for good.
1011		assert_eq!(DateTime::from_millis(1_500).to_millis(), 1_500);
1012		assert_eq!(DateTime::from_millis(1_500), DateTime::from_nanos(1_500_000_000));
1013
1014		let precise = DateTime::from_nanos(1_500_000_999);
1015		assert_eq!(precise.to_millis(), 1_500);
1016		assert_eq!(DateTime::from_millis(precise.to_millis()), DateTime::from_nanos(1_500_000_000));
1017	}
1018
1019	#[test]
1020	fn coarser_unit_accessors_truncate_toward_the_epoch() {
1021		// These replace hand-written `to_nanos() / 1_000_000_000` divisions, where a wrong count
1022		// of zeros reads as plausible. They must match that division including its truncation;
1023		// rounding would shift a boundary instant into the next unit and retire state a tick early.
1024		let dt = DateTime::from_nanos(1_700_000_123_456_789);
1025
1026		assert_eq!(dt.to_secs(), 1_700_000);
1027		assert_eq!(dt.to_micros(), 1_700_000_123_456);
1028		assert_eq!(dt.to_millis(), 1_700_000_123);
1029
1030		assert_eq!(dt.to_secs(), dt.to_nanos() / 1_000_000_000);
1031		assert_eq!(dt.to_micros(), dt.to_nanos() / 1_000);
1032
1033		assert_eq!(DateTime::from_nanos(1_999_999_999).to_secs(), 1);
1034		assert_eq!(DateTime::from_nanos(1_999).to_micros(), 1);
1035		assert_eq!(DateTime::EPOCH.to_secs(), 0);
1036		assert_eq!(DateTime::EPOCH.to_micros(), 0);
1037	}
1038
1039	#[test]
1040	fn from_millis_saturates_where_from_epoch_millis_errors() {
1041		// from_millis is the infallible form callers want; an input large enough to overflow is
1042		// roughly 584 million years, so clamping is safe and saves an unwrap at every call site.
1043		assert_eq!(DateTime::from_millis(1_500), DateTime::from_epoch_millis(1_500).unwrap());
1044		assert!(DateTime::from_epoch_millis(u64::MAX).is_err());
1045		assert_eq!(DateTime::from_millis(u64::MAX), DateTime::MAX);
1046	}
1047
1048	#[test]
1049	fn from_epoch_secs_reads_its_argument_as_whole_seconds() {
1050		// Chain timestamps arrive in seconds; a millis-scaled constructor would be off by 1000x silently.
1051		assert_eq!(DateTime::from_epoch_secs(1).unwrap().to_nanos(), 1_000_000_000);
1052		assert_eq!(DateTime::from_epoch_secs(0).unwrap(), DateTime::EPOCH);
1053		assert_eq!(
1054			DateTime::from_epoch_secs(1_234_567_890).unwrap(),
1055			DateTime::from_epoch_millis(1_234_567_890_000).unwrap()
1056		);
1057	}
1058
1059	#[test]
1060	fn the_epoch_constant_is_the_zero_instant() {
1061		// Watermarks hydrate to the epoch to mean "nothing seen yet".
1062		assert_eq!(DateTime::EPOCH, DateTime::from_nanos(0));
1063		assert_eq!(DateTime::EPOCH, DateTime::default());
1064		assert!(DateTime::EPOCH.is_epoch());
1065		assert!(!DateTime::from_nanos(1).is_epoch());
1066	}
1067}
1068
1069#[cfg(test)]
1070mod now_tests {
1071	use super::DateTime;
1072	use crate::clock::{ClockNow, testing::TestClock};
1073
1074	#[test]
1075	fn now_reads_the_clock() {
1076		// "now" comes from the injected clock so tests stay deterministic.
1077		let clock = TestClock::from_millis(1500);
1078		assert_eq!(clock.now(), DateTime::from_nanos(1_500_000_000));
1079	}
1080
1081	#[test]
1082	fn from_str_round_trips_display() {
1083		let dt = DateTime::from_nanos(1_700_000_000_000_000_000);
1084		let parsed: DateTime = dt.to_string().parse().unwrap();
1085		assert_eq!(parsed, dt);
1086	}
1087
1088	#[test]
1089	fn from_str_rejects_garbage() {
1090		assert!("not a datetime".parse::<DateTime>().is_err());
1091	}
1092}