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		// Standard dates
254		let date = Date::new(2024, 3, 15).unwrap();
255		assert_eq!(format!("{}", date), "2024-03-15");
256
257		let date = Date::new(2000, 1, 1).unwrap();
258		assert_eq!(format!("{}", date), "2000-01-01");
259
260		let date = Date::new(1999, 12, 31).unwrap();
261		assert_eq!(format!("{}", date), "1999-12-31");
262	}
263
264	#[test]
265	fn test_date_display_edge_cases() {
266		// Unix epoch
267		let date = Date::new(1970, 1, 1).unwrap();
268		assert_eq!(format!("{}", date), "1970-01-01");
269
270		// Leap year
271		let date = Date::new(2024, 2, 29).unwrap();
272		assert_eq!(format!("{}", date), "2024-02-29");
273
274		// Single digit day/month
275		let date = Date::new(2024, 1, 9).unwrap();
276		assert_eq!(format!("{}", date), "2024-01-09");
277
278		let date = Date::new(2024, 9, 1).unwrap();
279		assert_eq!(format!("{}", date), "2024-09-01");
280	}
281
282	#[test]
283	fn test_date_display_boundary_dates() {
284		// Very early date
285		let date = Date::new(1, 1, 1).unwrap();
286		assert_eq!(format!("{}", date), "0001-01-01");
287
288		// Far future date
289		let date = Date::new(9999, 12, 31).unwrap();
290		assert_eq!(format!("{}", date), "9999-12-31");
291
292		// Century boundaries
293		let date = Date::new(1900, 1, 1).unwrap();
294		assert_eq!(format!("{}", date), "1900-01-01");
295
296		let date = Date::new(2000, 1, 1).unwrap();
297		assert_eq!(format!("{}", date), "2000-01-01");
298
299		let date = Date::new(2100, 1, 1).unwrap();
300		assert_eq!(format!("{}", date), "2100-01-01");
301	}
302
303	#[test]
304	fn test_date_display_negative_years() {
305		// Year 0 (1 BC)
306		let date = Date::new(0, 1, 1).unwrap();
307		assert_eq!(format!("{}", date), "0000-01-01");
308
309		// Negative years (BC)
310		let date = Date::new(-1, 1, 1).unwrap();
311		assert_eq!(format!("{}", date), "-0001-01-01");
312
313		let date = Date::new(-100, 12, 31).unwrap();
314		assert_eq!(format!("{}", date), "-0100-12-31");
315	}
316
317	#[test]
318	fn test_date_display_default() {
319		let date = Date::default();
320		assert_eq!(format!("{}", date), "1970-01-01");
321	}
322
323	#[test]
324	fn test_date_display_all_months() {
325		let months = [
326			(1, "01"),
327			(2, "02"),
328			(3, "03"),
329			(4, "04"),
330			(5, "05"),
331			(6, "06"),
332			(7, "07"),
333			(8, "08"),
334			(9, "09"),
335			(10, "10"),
336			(11, "11"),
337			(12, "12"),
338		];
339
340		for (month, expected) in months {
341			let date = Date::new(2024, month, 15).unwrap();
342			assert_eq!(format!("{}", date), format!("2024-{}-15", expected));
343		}
344	}
345
346	#[test]
347	fn test_date_display_days_in_month() {
348		// Test first and last days of various months
349		let test_cases = [
350			(2024, 1, 1, "2024-01-01"),
351			(2024, 1, 31, "2024-01-31"),
352			(2024, 2, 1, "2024-02-01"),
353			(2024, 2, 29, "2024-02-29"), // Leap year
354			(2024, 4, 1, "2024-04-01"),
355			(2024, 4, 30, "2024-04-30"),
356			(2024, 12, 1, "2024-12-01"),
357			(2024, 12, 31, "2024-12-31"),
358		];
359
360		for (year, month, day, expected) in test_cases {
361			let date = Date::new(year, month, day).unwrap();
362			assert_eq!(format!("{}", date), expected);
363		}
364	}
365
366	#[test]
367	fn test_date_roundtrip() {
368		// Test that converting to/from days preserves the date
369		let test_dates = [
370			(1900, 1, 1),
371			(1970, 1, 1),
372			(2000, 2, 29), // Leap year
373			(2024, 12, 31),
374			(2100, 6, 15),
375		];
376
377		for (year, month, day) in test_dates {
378			let date = Date::new(year, month, day).unwrap();
379			let days = date.to_days_since_epoch();
380			let recovered = Date::from_days_since_epoch(days).unwrap();
381
382			assert_eq!(date.year(), recovered.year());
383			assert_eq!(date.month(), recovered.month());
384			assert_eq!(date.day(), recovered.day());
385		}
386	}
387
388	#[test]
389	fn test_leap_year_detection() {
390		assert!(Date::is_leap_year(2000)); // Divisible by 400
391		assert!(Date::is_leap_year(2024)); // Divisible by 4, not by 100
392		assert!(!Date::is_leap_year(1900)); // Divisible by 100, not by 400
393		assert!(!Date::is_leap_year(2023)); // Not divisible by 4
394	}
395
396	#[test]
397	fn test_invalid_dates() {
398		assert!(Date::new(2024, 0, 1).is_none()); // Invalid month
399		assert!(Date::new(2024, 13, 1).is_none()); // Invalid month
400		assert!(Date::new(2024, 1, 0).is_none()); // Invalid day
401		assert!(Date::new(2024, 1, 32).is_none()); // Invalid day
402		assert!(Date::new(2023, 2, 29).is_none()); // Not a leap year
403		assert!(Date::new(2024, 4, 31).is_none()); // April has 30 days
404	}
405
406	#[test]
407	fn test_serde_roundtrip() {
408		let date = Date::new(2024, 3, 15).unwrap();
409		let json = to_string(&date).unwrap();
410		// Wire format is the raw days-since-epoch integer, not an ISO-8601 string.
411		assert_eq!(json, date.to_days_since_epoch().to_string());
412
413		let recovered: Date = from_str(&json).unwrap();
414		assert_eq!(date, recovered);
415	}
416
417	#[test]
418	fn test_serde_postcard_roundtrip_negative_years() {
419		// Binary (postcard) is the hot CDC path; negative days (pre-epoch) must survive the i32 encoding.
420		for (y, m, d) in [(-100, 12, 31), (0, 1, 1), (1970, 1, 1), (2024, 3, 15), (9999, 12, 31)] {
421			let date = Date::new(y, m, d).unwrap();
422			let bytes = to_allocvec(&date).unwrap();
423			let recovered: Date = from_bytes(&bytes).unwrap();
424			assert_eq!(date, recovered);
425			assert_eq!(recovered.year(), y);
426			assert_eq!(recovered.month(), m);
427			assert_eq!(recovered.day(), d);
428		}
429	}
430
431	#[test]
432	fn test_deserialize_rejects_out_of_range_days() {
433		// Days beyond the supported Date range must not decode.
434		let json = 400_000_000i64.to_string();
435		assert!(from_str::<Date>(&json).is_err());
436	}
437
438	fn assert_date_overflow<T: Debug>(result: Result<T, Box<TypeError>>) {
439		let err = result.expect_err("expected DateOverflow error");
440		match *err {
441			TypeError::Temporal {
442				kind: TemporalKind::DateOverflow {
443					..
444				},
445				..
446			} => {}
447			other => panic!("expected DateOverflow, got: {:?}", other),
448		}
449	}
450
451	#[test]
452	fn test_from_ymd_invalid_month() {
453		assert_date_overflow(Date::from_ymd(2024, 0, 1));
454		assert_date_overflow(Date::from_ymd(2024, 13, 1));
455	}
456
457	#[test]
458	fn test_from_ymd_invalid_day() {
459		assert_date_overflow(Date::from_ymd(2024, 1, 0));
460		assert_date_overflow(Date::from_ymd(2024, 1, 32));
461	}
462
463	#[test]
464	fn test_from_ymd_non_leap_year() {
465		assert_date_overflow(Date::from_ymd(2023, 2, 29));
466	}
467
468	#[test]
469	fn saturating_add_sub_whole_days() {
470		// Adding/subtracting a whole-day Duration shifts the date by exactly that many days.
471		let base = Date::from_ymd(2024, 1, 15).unwrap();
472
473		let forward = base.saturating_add(Duration::from_days(2).unwrap());
474		assert_eq!(forward, Date::from_ymd(2024, 1, 17).unwrap());
475
476		let backward = base.saturating_sub(Duration::from_days(2).unwrap());
477		assert_eq!(backward, Date::from_ymd(2024, 1, 13).unwrap());
478	}
479
480	#[test]
481	fn saturating_sub_day_truncates() {
482		// Date has day resolution: a sub-day duration that does not cross a day
483		// boundary truncates to zero days and leaves the date unchanged, while a
484		// 36h duration crosses exactly one boundary and advances exactly 1 day.
485		let base = Date::from_ymd(2024, 1, 15).unwrap();
486
487		let half_day = base.saturating_add(Duration::from_seconds(12 * 3600).unwrap());
488		assert_eq!(half_day, base, "12h is sub-day and must not change the date");
489
490		let day_and_half = base.saturating_add(Duration::from_seconds(36 * 3600).unwrap());
491		assert_eq!(day_and_half, Date::from_ymd(2024, 1, 16).unwrap(), "36h truncates to 1 whole day");
492	}
493
494	#[test]
495	fn saturating_add_clamps_at_max() {
496		// Adding past the valid upper bound saturates rather than overflowing the i32.
497		let max = Date::from_days_since_epoch(365_250_000).unwrap();
498		let clamped = max.saturating_add(Duration::from_days(10).unwrap());
499		assert_eq!(clamped.to_days_since_epoch(), 365_250_000);
500	}
501
502	#[test]
503	fn saturating_sub_clamps_at_min() {
504		// Subtracting past the valid lower bound saturates rather than underflowing the i32.
505		let min = Date::from_days_since_epoch(-365_250_000).unwrap();
506		let clamped = min.saturating_sub(Duration::from_days(10).unwrap());
507		assert_eq!(clamped.to_days_since_epoch(), -365_250_000);
508	}
509}