1use crate::{Decimal, NumericValue, TypeParseError};
7
8use serde::{Deserialize, Deserializer, Serialize};
9use std::fmt::{self, Debug, Display};
10
11#[derive(Clone, Copy, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
25#[repr(transparent)]
26pub struct Date(i32);
27
28impl Date {
29 pub const EPOCH: Self = Self(0);
31 pub const MIN: Self = Self(-719_528);
33 pub const MAX: Self = Self(2_932_896);
35
36 const DAYS_PER_CYCLE: i32 = 146_097;
40 const MARCH_EPOCH_OFFSET: i32 = 719_468;
41
42 #[must_use]
46 pub fn try_new(y: i32, m: u8, d: u8) -> Option<Self> {
47 if !(0..=9_999).contains(&y) {
48 return None;
49 }
50 let month_days = match m {
51 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
52 4 | 6 | 9 | 11 => 30,
53 2 if y % 4 == 0 && (y % 100 != 0 || y % 400 == 0) => 29,
54 2 => 28,
55 _ => return None,
56 };
57 if d == 0 || d > month_days {
58 return None;
59 }
60
61 let year = y - i32::from(m <= 2);
62 let cycle = year.div_euclid(400);
63 let cycle_year = year.rem_euclid(400);
64 let march_month = i32::from(m) + if m > 2 { -3 } else { 9 };
65 let cycle_day = 365 * cycle_year + cycle_year / 4 - cycle_year / 100
66 + (153 * march_month + 2) / 5
67 + i32::from(d)
68 - 1;
69 Self::try_from_days_since_epoch(
70 cycle * Self::DAYS_PER_CYCLE + cycle_day - Self::MARCH_EPOCH_OFFSET,
71 )
72 }
73
74 #[must_use]
76 pub const fn try_from_days_since_epoch(days: i32) -> Option<Self> {
77 if days < Self::MIN.0 || days > Self::MAX.0 {
78 None
79 } else {
80 Some(Self(days))
81 }
82 }
83
84 #[must_use]
86 pub const fn as_days_since_epoch(self) -> i32 {
87 self.0
88 }
89
90 #[must_use]
92 pub fn try_from_i64(days: i64) -> Option<Self> {
93 i32::try_from(days)
94 .ok()
95 .and_then(Self::try_from_days_since_epoch)
96 }
97
98 #[must_use]
100 pub fn try_from_u64(days: u64) -> Option<Self> {
101 i32::try_from(days)
102 .ok()
103 .and_then(Self::try_from_days_since_epoch)
104 }
105
106 #[must_use]
108 pub fn checked_add_days(self, days: i64) -> Option<Self> {
109 i64::from(self.0)
110 .checked_add(days)
111 .and_then(Self::try_from_i64)
112 }
113
114 #[must_use]
116 pub fn checked_sub_days(self, days: i64) -> Option<Self> {
117 i64::from(self.0)
118 .checked_sub(days)
119 .and_then(Self::try_from_i64)
120 }
121
122 #[must_use]
124 pub fn days_since(self, earlier: Self) -> i64 {
125 i64::from(self.0) - i64::from(earlier.0)
126 }
127
128 #[must_use]
130 pub fn year(self) -> i32 {
131 self.calendar_parts().0
132 }
133
134 #[must_use]
136 pub fn month(self) -> u8 {
137 self.calendar_parts().1
138 }
139
140 #[must_use]
142 pub fn day(self) -> u8 {
143 self.calendar_parts().2
144 }
145
146 #[must_use]
148 pub fn parse(s: &str) -> Option<Self> {
149 let bytes = s.as_bytes();
150 if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
151 return None;
152 }
153
154 let year = parse_ascii_i32(&bytes[0..4])?;
156 let month = parse_ascii_u8(&bytes[5..7])?;
157 let day = parse_ascii_u8(&bytes[8..10])?;
158
159 Self::try_new(year, month, day)
160 }
161
162 #[expect(
165 clippy::cast_possible_truncation,
166 clippy::cast_sign_loss,
167 reason = "bounded Gregorian conversion yields positive u8 month/day components"
168 )]
169 fn calendar_parts(self) -> (i32, u8, u8) {
170 let march_day = self.0 + Self::MARCH_EPOCH_OFFSET;
171 let cycle = march_day.div_euclid(Self::DAYS_PER_CYCLE);
172 let cycle_day = march_day.rem_euclid(Self::DAYS_PER_CYCLE);
173 let cycle_year =
174 (cycle_day - cycle_day / 1_460 + cycle_day / 36_524 - cycle_day / 146_096) / 365;
175 let year = cycle * 400 + cycle_year;
176 let march_ordinal = cycle_day - (365 * cycle_year + cycle_year / 4 - cycle_year / 100);
177 let march_month = (5 * march_ordinal + 2) / 153;
178 let day = march_ordinal - (153 * march_month + 2) / 5 + 1;
179 let month = march_month + if march_month < 10 { 3 } else { -9 };
180
181 (year + i32::from(month <= 2), month as u8, day as u8)
182 }
183}
184
185impl Debug for Date {
186 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187 write!(f, "Date({self})")
188 }
189}
190
191impl Display for Date {
192 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193 let (year, month, day) = self.calendar_parts();
194 write!(f, "{year:04}-{month:02}-{day:02}")
195 }
196}
197
198impl NumericValue for Date {
199 fn try_to_decimal(&self) -> Option<Decimal> {
200 Decimal::from_i64(i64::from(self.0))
201 }
202
203 fn try_from_decimal(value: Decimal) -> Option<Self> {
204 value.to_i32().and_then(Self::try_from_days_since_epoch)
205 }
206}
207
208impl<'de> Deserialize<'de> for Date {
209 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
210 where
211 D: Deserializer<'de>,
212 {
213 if deserializer.is_human_readable() {
214 return Self::parse(&String::deserialize(deserializer)?)
215 .ok_or_else(|| serde::de::Error::custom(TypeParseError::InvalidDate));
216 }
217 Self::try_from_days_since_epoch(i32::deserialize(deserializer)?)
219 .ok_or_else(|| serde::de::Error::custom(TypeParseError::InvalidDate))
220 }
221}
222
223impl Serialize for Date {
224 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
225 where
226 S: serde::Serializer,
227 {
228 if serializer.is_human_readable() {
229 serializer.collect_str(self)
230 } else {
231 serializer.serialize_i32(self.0)
232 }
233 }
234}
235
236fn parse_ascii_i32(bytes: &[u8]) -> Option<i32> {
237 bytes.iter().try_fold(0_i32, |value, byte| {
238 byte.checked_sub(b'0')
239 .filter(|digit| *digit <= 9)
240 .and_then(|digit| value.checked_mul(10)?.checked_add(i32::from(digit)))
241 })
242}
243
244fn parse_ascii_u8(bytes: &[u8]) -> Option<u8> {
245 bytes.iter().try_fold(0_u8, |value, byte| {
246 byte.checked_sub(b'0')
247 .filter(|digit| *digit <= 9)
248 .and_then(|digit| value.checked_mul(10)?.checked_add(digit))
249 })
250}
251
252#[cfg(test)]
257mod tests {
258 use super::*;
259
260 #[test]
261 fn every_supported_calendar_day_matches_the_reference_calendar() {
262 use time::{Date as ReferenceDate, Month};
263
264 let epoch = ReferenceDate::from_calendar_date(1970, Month::January, 1).unwrap();
265 for days in Date::MIN.as_days_since_epoch()..=Date::MAX.as_days_since_epoch() {
266 let reference = ReferenceDate::from_julian_day(epoch.to_julian_day() + days).unwrap();
267 let (year, month, day) = reference.to_calendar_date();
268 let month = u8::from(month);
269 let date = Date::try_from_days_since_epoch(days).unwrap();
270 assert_eq!((date.year(), date.month(), date.day()), (year, month, day));
271 assert_eq!(Date::try_new(year, month, day), Some(date));
272 }
273 }
274
275 #[test]
276 fn calendar_admission_matches_reference_at_month_and_leap_boundaries() {
277 use time::{Date as ReferenceDate, Month};
278
279 let epoch = ReferenceDate::from_calendar_date(1970, Month::January, 1).unwrap();
280 for year in [
281 0, 1, 4, 100, 400, 1582, 1900, 1969, 1970, 2000, 2024, 2100, 2400, 9999,
282 ] {
283 for month in 0..=13 {
284 for day in 0..=32 {
285 let expected = Month::try_from(month)
286 .ok()
287 .and_then(|month| ReferenceDate::from_calendar_date(year, month, day).ok())
288 .map(|date| (date - epoch).whole_days());
289 assert_eq!(
290 Date::try_new(year, month, day)
291 .map(|date| i64::from(date.as_days_since_epoch())),
292 expected,
293 "{year:04}-{month:02}-{day:02}"
294 );
295 }
296 }
297 }
298 }
299
300 #[test]
301 fn serde_calendar_boundaries_and_batches_use_compact_days() {
302 let today = Date::try_new(2026, 9, 12).unwrap();
303 for (date, width) in [(Date::EPOCH, 1), (today, 3), (Date::MIN, 5), (Date::MAX, 5)] {
304 let mut encoded = Vec::new();
305 serde_cbor::to_writer(&mut encoded, &date).unwrap();
306 assert_eq!(encoded.len(), width);
307 let wire: serde_cbor::Value = serde_cbor::from_slice(encoded.as_slice()).unwrap();
308 assert_eq!(
309 wire,
310 serde_cbor::Value::Integer(date.as_days_since_epoch().into())
311 );
312 assert_eq!(
313 serde_cbor::from_slice::<Date>(encoded.as_slice()).unwrap(),
314 date
315 );
316
317 let json = serde_json::to_string(&date).unwrap();
318 assert_eq!(json, format!("\"{date}\""));
319 assert_eq!(serde_json::from_str::<Date>(&json).unwrap(), date);
320
321 let wire = crate::wire::encode_one(date).unwrap();
322 assert_eq!(
323 wire,
324 crate::wire::encode_one(date.as_days_since_epoch()).unwrap()
325 );
326 assert_eq!(crate::wire::decode_one::<Date>(&wire).unwrap(), date);
327
328 let batch = vec![date; 1_000];
329 encoded.clear();
330 serde_cbor::to_writer(&mut encoded, &batch).unwrap();
331 assert_eq!(encoded.len(), 3 + 1_000 * width);
332 assert_eq!(
333 serde_cbor::from_slice::<Vec<Date>>(encoded.as_slice()).unwrap(),
334 batch
335 );
336 }
337 }
338
339 #[test]
340 fn binary_serde_rejects_days_outside_calendar_domain() {
341 for day in [
342 i64::MIN,
343 i64::from(Date::MIN.as_days_since_epoch()) - 1,
344 i64::from(Date::MAX.as_days_since_epoch()) + 1,
345 i64::MAX,
346 ] {
347 let mut encoded = Vec::new();
348 serde_cbor::to_writer(&mut encoded, &day).unwrap();
349 assert!(serde_cbor::from_slice::<Date>(encoded.as_slice()).is_err());
350 }
351 }
352
353 #[test]
356 fn from_ymd_and_to_naive_date_round_trip() {
357 let date = Date::try_new(2024, 10, 19).expect("valid calendar date should construct");
358 assert_eq!(date.year(), 2024);
359 assert_eq!(date.month(), 10);
360 assert_eq!(date.day(), 19);
361 }
362
363 #[test]
364 fn try_new_rejects_out_of_range_month_and_day() {
365 assert!(Date::try_new(2025, 13, 99).is_none());
366 }
367
368 #[test]
369 fn invalid_date_parse_returns_none() {
370 assert!(Date::parse("2025-13-40").is_none());
371 assert!(Date::try_new(2025, 2, 30).is_none());
372 }
373
374 #[test]
375 fn try_new_rejects_out_of_range_year() {
376 assert!(Date::try_new(-1, 1, 1).is_none());
377 assert!(Date::try_new(10_000, 1, 1).is_none());
378 assert!(Date::try_new(i32::MAX, 1, 1).is_none());
379 }
380
381 #[test]
382 fn overflow_protection_in_try_from_u64() {
383 let too_large = (i32::MAX as u64) + 1;
385 assert!(Date::try_from_u64(too_large).is_none());
386 }
387
388 #[test]
389 fn ordering_and_equality_follow_internal_day_count() {
390 let d1 = Date::try_new(2020, 1, 1).unwrap();
391 let d2 = Date::try_new(2021, 1, 1).unwrap();
392
393 assert!(d1 < d2);
394 assert!(d1.as_days_since_epoch() < d2.as_days_since_epoch());
395 assert_eq!(d1, d1);
396 }
397
398 #[test]
399 fn internal_day_count_helpers_round_trip() {
400 let days = -365;
401 let date = Date::try_from_days_since_epoch(days).expect("bounded day should construct");
402 assert_eq!(date.as_days_since_epoch(), days);
403 }
404
405 #[test]
406 fn raw_day_construction_rejects_values_outside_calendar_bounds() {
407 assert_eq!(
408 Date::try_from_days_since_epoch(Date::MIN.as_days_since_epoch()),
409 Some(Date::MIN),
410 );
411 assert_eq!(
412 Date::try_from_days_since_epoch(Date::MAX.as_days_since_epoch()),
413 Some(Date::MAX),
414 );
415 assert!(Date::try_from_days_since_epoch(Date::MIN.as_days_since_epoch() - 1).is_none(),);
416 assert!(Date::try_from_days_since_epoch(Date::MAX.as_days_since_epoch() + 1).is_none(),);
417 }
418
419 #[test]
420 fn checked_day_arithmetic_obeys_calendar_bounds() {
421 let leap_day = Date::try_new(2024, 2, 29).expect("leap day should construct");
422 let march_first = Date::try_new(2024, 3, 1).expect("next day should construct");
423
424 assert_eq!(leap_day.checked_add_days(1), Some(march_first));
425 assert_eq!(march_first.checked_sub_days(1), Some(leap_day));
426 assert_eq!(march_first.days_since(leap_day), 1);
427 assert!(Date::MAX.checked_add_days(1).is_none());
428 assert!(Date::MIN.checked_sub_days(1).is_none());
429 assert!(Date::EPOCH.checked_add_days(i64::MAX).is_none());
430 assert!(Date::EPOCH.checked_sub_days(i64::MIN).is_none());
431 }
432
433 #[test]
434 fn display_formats_as_iso_date() {
435 let date = Date::try_new(2025, 10, 19).unwrap();
436 assert_eq!(format!("{date}"), "2025-10-19");
437 }
438
439 #[test]
440 fn parse_stays_iso_strict() {
441 assert_eq!(Date::parse("2025-10-19"), Date::try_new(2025, 10, 19));
442 assert!(Date::parse("10/19/2025").is_none());
443 assert!(Date::parse("2025-10-19T00:00:00Z").is_none());
444 }
445
446 #[test]
447 fn parse_supports_pre_epoch_and_leap_year_cases() {
448 assert_eq!(
449 Date::parse("1900-01-01"),
450 Date::try_new(1900, 1, 1),
451 "expected non-leap-century date to parse",
452 );
453 assert_eq!(
454 Date::parse("1969-12-31"),
455 Date::try_new(1969, 12, 31),
456 "expected pre-epoch date to parse",
457 );
458 assert_eq!(
459 Date::parse("2000-02-29"),
460 Date::try_new(2000, 2, 29),
461 "expected leap-day date to parse",
462 );
463 }
464
465 #[test]
466 fn parse_rejects_invalid_non_leap_day() {
467 assert!(Date::parse("1900-02-29").is_none());
468 }
469
470 #[test]
471 fn calendar_boundaries_format_and_parse_exactly() {
472 assert_eq!(Date::MIN.to_string(), "0000-01-01");
473 assert_eq!(Date::MAX.to_string(), "9999-12-31");
474 assert_eq!(Date::parse(Date::MIN.to_string().as_str()), Some(Date::MIN));
475 assert_eq!(Date::parse(Date::MAX.to_string().as_str()), Some(Date::MAX));
476 }
477
478 #[test]
479 fn wire_decode_rejects_out_of_range_epoch_days() {
480 let min = crate::wire::encode_one(Date::MIN.as_days_since_epoch())
481 .expect("minimum day should encode");
482 let max = crate::wire::encode_one(Date::MAX.as_days_since_epoch())
483 .expect("maximum day should encode");
484 let below = crate::wire::encode_one(Date::MIN.as_days_since_epoch() - 1)
485 .expect("out-of-range day should encode as raw i32");
486 let above = crate::wire::encode_one(Date::MAX.as_days_since_epoch() + 1)
487 .expect("out-of-range day should encode as raw i32");
488
489 assert_eq!(
490 crate::wire::decode_one::<Date>(&min).expect("minimum day should decode"),
491 Date::MIN,
492 );
493 assert_eq!(
494 crate::wire::decode_one::<Date>(&max).expect("maximum day should decode"),
495 Date::MAX,
496 );
497 assert!(crate::wire::decode_one::<Date>(&below).is_err());
498 assert!(crate::wire::decode_one::<Date>(&above).is_err());
499 }
500}