Skip to main content

reifydb_value/value/
date.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use std::fmt::{self, Display, Formatter};
5
6use serde::{
7	Deserialize, Deserializer, Serialize, Serializer,
8	de::{self, Visitor},
9};
10
11use crate::{
12	error::{TemporalKind, TypeError},
13	fragment::Fragment,
14	value::duration::Duration,
15};
16
17#[repr(transparent)]
18#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
19pub struct Date {
20	days_since_epoch: i32,
21}
22
23impl Date {
24	#[inline]
25	pub fn is_leap_year(year: i32) -> bool {
26		(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
27	}
28
29	#[inline]
30	pub fn days_in_month(year: i32, month: u32) -> u32 {
31		match month {
32			1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
33			4 | 6 | 9 | 11 => 30,
34			2 => {
35				if Self::is_leap_year(year) {
36					29
37				} else {
38					28
39				}
40			}
41			_ => 0,
42		}
43	}
44
45	fn ymd_to_days_since_epoch(year: i32, month: u32, day: u32) -> Option<i32> {
46		if !(1..=12).contains(&month) || day < 1 || day > Self::days_in_month(year, month) {
47			return None;
48		}
49
50		let (y, m) = if month <= 2 {
51			(year - 1, month as i32 + 9)
52		} else {
53			(year, month as i32 - 3)
54		};
55
56		let era = if y >= 0 {
57			y
58		} else {
59			y - 399
60		} / 400;
61		let yoe = y - era * 400;
62		let doy = (153 * m + 2) / 5 + day as i32 - 1;
63		let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
64		let days = era * 146097 + doe - 719468;
65
66		Some(days)
67	}
68
69	fn days_since_epoch_to_ymd(days: i32) -> (i32, u32, u32) {
70		let days_since_ce = days + 719468;
71
72		let era = if days_since_ce >= 0 {
73			days_since_ce
74		} else {
75			days_since_ce - 146096
76		} / 146097;
77		let doe = days_since_ce - era * 146097;
78		let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
79		let y = yoe + era * 400;
80		let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
81		let mp = (5 * doy + 2) / 153;
82		let d = doy - (153 * mp + 2) / 5 + 1;
83		let m = if mp < 10 {
84			mp + 3
85		} else {
86			mp - 9
87		};
88		let year = if m <= 2 {
89			y + 1
90		} else {
91			y
92		};
93
94		(year, m as u32, d as u32)
95	}
96}
97
98impl Date {
99	fn overflow_err(message: impl Into<String>) -> TypeError {
100		TypeError::Temporal {
101			kind: TemporalKind::DateOverflow {
102				message: message.into(),
103			},
104			message: "date overflow".to_string(),
105			fragment: Fragment::None,
106		}
107	}
108
109	pub fn new(year: i32, month: u32, day: u32) -> Option<Self> {
110		Self::ymd_to_days_since_epoch(year, month, day).map(|days_since_epoch| Self {
111			days_since_epoch,
112		})
113	}
114
115	pub fn from_ymd(year: i32, month: u32, day: u32) -> Result<Self, Box<TypeError>> {
116		Self::new(year, month, day).ok_or_else(|| {
117			Box::new(Self::overflow_err(format!("invalid date: {}-{:02}-{:02}", year, month, day)))
118		})
119	}
120
121	pub fn year(&self) -> i32 {
122		Self::days_since_epoch_to_ymd(self.days_since_epoch).0
123	}
124
125	pub fn month(&self) -> u32 {
126		Self::days_since_epoch_to_ymd(self.days_since_epoch).1
127	}
128
129	pub fn day(&self) -> u32 {
130		Self::days_since_epoch_to_ymd(self.days_since_epoch).2
131	}
132
133	pub fn to_days_since_epoch(&self) -> i32 {
134		self.days_since_epoch
135	}
136
137	pub fn from_days_since_epoch(days: i32) -> Option<Self> {
138		if !(-365_250_000..=365_250_000).contains(&days) {
139			return None;
140		}
141		Some(Self {
142			days_since_epoch: days,
143		})
144	}
145
146	pub fn saturating_add(self, rhs: Duration) -> Date {
147		const NANOS_PER_DAY: i128 = 86_400_000_000_000;
148		let total = rhs.as_nanos().unwrap_or(if rhs.is_negative() {
149			i64::MIN
150		} else {
151			i64::MAX
152		});
153		let days = (self.days_since_epoch as i128 + total as i128 / NANOS_PER_DAY)
154			.clamp(-365_250_000, 365_250_000);
155		Self {
156			days_since_epoch: days as i32,
157		}
158	}
159
160	pub fn saturating_sub(self, rhs: Duration) -> Date {
161		const NANOS_PER_DAY: i128 = 86_400_000_000_000;
162		let total = rhs.as_nanos().unwrap_or(if rhs.is_negative() {
163			i64::MIN
164		} else {
165			i64::MAX
166		});
167		let days = (self.days_since_epoch as i128 - total as i128 / NANOS_PER_DAY)
168			.clamp(-365_250_000, 365_250_000);
169		Self {
170			days_since_epoch: days as i32,
171		}
172	}
173}
174
175impl Display for Date {
176	fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
177		let (year, month, day) = Self::days_since_epoch_to_ymd(self.days_since_epoch);
178		if year < 0 {
179			write!(f, "-{:04}-{:02}-{:02}", -year, month, day)
180		} else {
181			write!(f, "{:04}-{:02}-{:02}", year, month, day)
182		}
183	}
184}
185
186impl Serialize for Date {
187	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
188	where
189		S: Serializer,
190	{
191		serializer.serialize_i32(self.days_since_epoch)
192	}
193}
194
195struct DateVisitor;
196
197impl<'de> Visitor<'de> for DateVisitor {
198	type Value = Date;
199
200	fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
201		formatter.write_str("a date as days since the Unix epoch (i32)")
202	}
203
204	fn visit_i32<E>(self, value: i32) -> Result<Date, E>
205	where
206		E: de::Error,
207	{
208		Date::from_days_since_epoch(value)
209			.ok_or_else(|| E::custom(format!("date days out of range: {}", value)))
210	}
211
212	fn visit_i64<E>(self, value: i64) -> Result<Date, E>
213	where
214		E: de::Error,
215	{
216		let days = i32::try_from(value).map_err(|_| E::custom(format!("date days out of range: {}", value)))?;
217		self.visit_i32(days)
218	}
219
220	fn visit_u64<E>(self, value: u64) -> Result<Date, E>
221	where
222		E: de::Error,
223	{
224		let days = i32::try_from(value).map_err(|_| E::custom(format!("date days out of range: {}", value)))?;
225		self.visit_i32(days)
226	}
227}
228
229impl<'de> Deserialize<'de> for Date {
230	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
231	where
232		D: Deserializer<'de>,
233	{
234		deserializer.deserialize_i32(DateVisitor)
235	}
236}
237
238#[cfg(test)]
239pub mod tests {
240	use std::fmt::Debug;
241
242	use postcard::{from_bytes, to_allocvec};
243	use serde_json::{from_str, to_string};
244
245	use super::*;
246	use crate::{
247		error::{TemporalKind, TypeError},
248		value::duration::Duration,
249	};
250
251	#[test]
252	fn test_date_display_standard_dates() {
253		let date = Date::new(2024, 3, 15).unwrap();
254		assert_eq!(format!("{}", date), "2024-03-15");
255
256		let date = Date::new(2000, 1, 1).unwrap();
257		assert_eq!(format!("{}", date), "2000-01-01");
258
259		let date = Date::new(1999, 12, 31).unwrap();
260		assert_eq!(format!("{}", date), "1999-12-31");
261	}
262
263	#[test]
264	fn test_date_display_edge_cases() {
265		let date = Date::new(1970, 1, 1).unwrap();
266		assert_eq!(format!("{}", date), "1970-01-01");
267
268		let date = Date::new(2024, 2, 29).unwrap();
269		assert_eq!(format!("{}", date), "2024-02-29");
270
271		// Single-digit month and day must be zero-padded to keep the width fixed.
272		let date = Date::new(2024, 1, 9).unwrap();
273		assert_eq!(format!("{}", date), "2024-01-09");
274
275		let date = Date::new(2024, 9, 1).unwrap();
276		assert_eq!(format!("{}", date), "2024-09-01");
277	}
278
279	#[test]
280	fn test_date_display_boundary_dates() {
281		let date = Date::new(1, 1, 1).unwrap();
282		assert_eq!(format!("{}", date), "0001-01-01");
283
284		let date = Date::new(9999, 12, 31).unwrap();
285		assert_eq!(format!("{}", date), "9999-12-31");
286
287		let date = Date::new(1900, 1, 1).unwrap();
288		assert_eq!(format!("{}", date), "1900-01-01");
289
290		let date = Date::new(2000, 1, 1).unwrap();
291		assert_eq!(format!("{}", date), "2000-01-01");
292
293		let date = Date::new(2100, 1, 1).unwrap();
294		assert_eq!(format!("{}", date), "2100-01-01");
295	}
296
297	#[test]
298	fn test_date_display_negative_years() {
299		// Year 0 is 1 BC in the proleptic calendar; BC years render with a leading minus.
300		let date = Date::new(0, 1, 1).unwrap();
301		assert_eq!(format!("{}", date), "0000-01-01");
302
303		let date = Date::new(-1, 1, 1).unwrap();
304		assert_eq!(format!("{}", date), "-0001-01-01");
305
306		let date = Date::new(-100, 12, 31).unwrap();
307		assert_eq!(format!("{}", date), "-0100-12-31");
308	}
309
310	#[test]
311	fn test_date_display_default() {
312		let date = Date::default();
313		assert_eq!(format!("{}", date), "1970-01-01");
314	}
315
316	#[test]
317	fn test_date_display_all_months() {
318		let months = [
319			(1, "01"),
320			(2, "02"),
321			(3, "03"),
322			(4, "04"),
323			(5, "05"),
324			(6, "06"),
325			(7, "07"),
326			(8, "08"),
327			(9, "09"),
328			(10, "10"),
329			(11, "11"),
330			(12, "12"),
331		];
332
333		for (month, expected) in months {
334			let date = Date::new(2024, month, 15).unwrap();
335			assert_eq!(format!("{}", date), format!("2024-{}-15", expected));
336		}
337	}
338
339	#[test]
340	fn test_date_display_days_in_month() {
341		let test_cases = [
342			(2024, 1, 1, "2024-01-01"),
343			(2024, 1, 31, "2024-01-31"),
344			(2024, 2, 1, "2024-02-01"),
345			(2024, 2, 29, "2024-02-29"), // Leap year
346			(2024, 4, 1, "2024-04-01"),
347			(2024, 4, 30, "2024-04-30"),
348			(2024, 12, 1, "2024-12-01"),
349			(2024, 12, 31, "2024-12-31"),
350		];
351
352		for (year, month, day, expected) in test_cases {
353			let date = Date::new(year, month, day).unwrap();
354			assert_eq!(format!("{}", date), expected);
355		}
356	}
357
358	#[test]
359	fn test_date_roundtrip() {
360		let test_dates = [
361			(1900, 1, 1),
362			(1970, 1, 1),
363			(2000, 2, 29), // Leap year
364			(2024, 12, 31),
365			(2100, 6, 15),
366		];
367
368		for (year, month, day) in test_dates {
369			let date = Date::new(year, month, day).unwrap();
370			let days = date.to_days_since_epoch();
371			let recovered = Date::from_days_since_epoch(days).unwrap();
372
373			assert_eq!(date.year(), recovered.year());
374			assert_eq!(date.month(), recovered.month());
375			assert_eq!(date.day(), recovered.day());
376		}
377	}
378
379	#[test]
380	fn test_leap_year_detection() {
381		assert!(Date::is_leap_year(2000)); // Divisible by 400
382		assert!(Date::is_leap_year(2024)); // Divisible by 4, not by 100
383		assert!(!Date::is_leap_year(1900)); // Divisible by 100, not by 400
384		assert!(!Date::is_leap_year(2023)); // Not divisible by 4
385	}
386
387	#[test]
388	fn test_invalid_dates() {
389		assert!(Date::new(2024, 0, 1).is_none()); // Invalid month
390		assert!(Date::new(2024, 13, 1).is_none()); // Invalid month
391		assert!(Date::new(2024, 1, 0).is_none()); // Invalid day
392		assert!(Date::new(2024, 1, 32).is_none()); // Invalid day
393		assert!(Date::new(2023, 2, 29).is_none()); // Not a leap year
394		assert!(Date::new(2024, 4, 31).is_none()); // April has 30 days
395	}
396
397	#[test]
398	fn test_serde_roundtrip() {
399		let date = Date::new(2024, 3, 15).unwrap();
400		let json = to_string(&date).unwrap();
401		// Wire format is the raw days-since-epoch integer, not an ISO-8601 string.
402		assert_eq!(json, date.to_days_since_epoch().to_string());
403
404		let recovered: Date = from_str(&json).unwrap();
405		assert_eq!(date, recovered);
406	}
407
408	#[test]
409	fn test_serde_postcard_roundtrip_negative_years() {
410		// Postcard is the CDC wire format; pre-epoch dates are negative and must survive it.
411		for (y, m, d) in [(-100, 12, 31), (0, 1, 1), (1970, 1, 1), (2024, 3, 15), (9999, 12, 31)] {
412			let date = Date::new(y, m, d).unwrap();
413			let bytes = to_allocvec(&date).unwrap();
414			let recovered: Date = from_bytes(&bytes).unwrap();
415			assert_eq!(date, recovered);
416			assert_eq!(recovered.year(), y);
417			assert_eq!(recovered.month(), m);
418			assert_eq!(recovered.day(), d);
419		}
420	}
421
422	#[test]
423	fn test_deserialize_rejects_out_of_range_days() {
424		// Days beyond the supported Date range must not decode.
425		let json = 400_000_000i64.to_string();
426		assert!(from_str::<Date>(&json).is_err());
427	}
428
429	fn assert_date_overflow<T: Debug>(result: Result<T, Box<TypeError>>) {
430		let err = result.expect_err("expected DateOverflow error");
431		match *err {
432			TypeError::Temporal {
433				kind: TemporalKind::DateOverflow {
434					..
435				},
436				..
437			} => {}
438			other => panic!("expected DateOverflow, got: {:?}", other),
439		}
440	}
441
442	#[test]
443	fn test_from_ymd_invalid_month() {
444		assert_date_overflow(Date::from_ymd(2024, 0, 1));
445		assert_date_overflow(Date::from_ymd(2024, 13, 1));
446	}
447
448	#[test]
449	fn test_from_ymd_invalid_day() {
450		assert_date_overflow(Date::from_ymd(2024, 1, 0));
451		assert_date_overflow(Date::from_ymd(2024, 1, 32));
452	}
453
454	#[test]
455	fn test_from_ymd_non_leap_year() {
456		assert_date_overflow(Date::from_ymd(2023, 2, 29));
457	}
458
459	#[test]
460	fn saturating_add_sub_whole_days() {
461		// Adding/subtracting a whole-day Duration shifts the date by exactly that many days.
462		let base = Date::from_ymd(2024, 1, 15).unwrap();
463
464		let forward = base.saturating_add(Duration::from_days(2).unwrap());
465		assert_eq!(forward, Date::from_ymd(2024, 1, 17).unwrap());
466
467		let backward = base.saturating_sub(Duration::from_days(2).unwrap());
468		assert_eq!(backward, Date::from_ymd(2024, 1, 13).unwrap());
469	}
470
471	#[test]
472	fn saturating_sub_day_truncates() {
473		// Date has day resolution, so a duration truncates to whole days rather than rounding.
474		let base = Date::from_ymd(2024, 1, 15).unwrap();
475
476		let half_day = base.saturating_add(Duration::from_seconds(12 * 3600).unwrap());
477		assert_eq!(half_day, base, "12h is sub-day and must not change the date");
478
479		let day_and_half = base.saturating_add(Duration::from_seconds(36 * 3600).unwrap());
480		assert_eq!(day_and_half, Date::from_ymd(2024, 1, 16).unwrap(), "36h truncates to 1 whole day");
481	}
482
483	#[test]
484	fn saturating_add_clamps_at_max() {
485		// Adding past the valid upper bound saturates rather than overflowing the i32.
486		let max = Date::from_days_since_epoch(365_250_000).unwrap();
487		let clamped = max.saturating_add(Duration::from_days(10).unwrap());
488		assert_eq!(clamped.to_days_since_epoch(), 365_250_000);
489	}
490
491	#[test]
492	fn saturating_sub_clamps_at_min() {
493		// Subtracting past the valid lower bound saturates rather than underflowing the i32.
494		let min = Date::from_days_since_epoch(-365_250_000).unwrap();
495		let clamped = min.saturating_sub(Duration::from_days(10).unwrap());
496		assert_eq!(clamped.to_days_since_epoch(), -365_250_000);
497	}
498}