Skip to main content

icydb_schema/
date.rs

1//! Canonical day-precision date atom.
2//!
3//! This module owns the strict calendar/date representation and its public
4//! wire conversion. Database ordering and storage policy remain runtime-owned.
5
6use crate::{Decimal, NumericValue, TypeParseError};
7
8use serde::{Deserialize, Deserializer, Serialize};
9use std::fmt::{self, Debug, Display};
10
11// Invariant:
12// Date is internally represented as days since Unix epoch (`i32`) and is
13// bounded to the proleptic Gregorian calendar range 0000-01-01..=9999-12-31.
14// Human-readable Serde uses ISO-8601 text; binary Serde uses epoch days.
15// Ordering and arithmetic remain numeric and deterministic over day counts.
16
17//
18// Date
19//
20// Represented as days since Unix epoch.
21// Wire uses int32 epoch days; JSON uses ISO-8601 text (`YYYY-MM-DD`).
22//
23
24#[derive(Clone, Copy, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
25#[repr(transparent)]
26pub struct Date(i32);
27
28impl Date {
29    /// The Unix epoch date, 1970-01-01.
30    pub const EPOCH: Self = Self(0);
31    /// The earliest supported calendar date, 0000-01-01.
32    pub const MIN: Self = Self(-719_528);
33    /// The latest supported calendar date, 9999-12-31.
34    pub const MAX: Self = Self(2_932_896);
35
36    // March-based Gregorian cycles put the leap day at the end of a year.
37    // The cycle contains 400 * 365 + 97 days; 1970-01-01 is day 719468
38    // after 0000-03-01 in this calendar.
39    const DAYS_PER_CYCLE: i32 = 146_097;
40    const MARCH_EPOCH_OFFSET: i32 = 719_468;
41
42    /// Build a date from exact calendar parts.
43    ///
44    /// Returns `None` when any component is out of range.
45    #[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    /// Construct from the bounded internal day-count representation.
75    #[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    /// Return the internal day-count representation.
85    #[must_use]
86    pub const fn as_days_since_epoch(self) -> i32 {
87        self.0
88    }
89
90    /// Fallible conversion from `i64` day-count representation.
91    #[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    /// Fallible conversion from `u64` day-count representation.
99    #[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    /// Add a signed number of days without leaving the supported calendar.
107    #[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    /// Subtract a signed number of days without leaving the supported calendar.
115    #[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    /// Return the signed number of days from `earlier` to this date.
123    #[must_use]
124    pub fn days_since(self, earlier: Self) -> i64 {
125        i64::from(self.0) - i64::from(earlier.0)
126    }
127
128    /// Returns the year component (e.g. 2025).
129    #[must_use]
130    pub fn year(self) -> i32 {
131        self.calendar_parts().0
132    }
133
134    /// Returns the month component (1-12).
135    #[must_use]
136    pub fn month(self) -> u8 {
137        self.calendar_parts().1
138    }
139
140    /// Returns the day-of-month component (1-31).
141    #[must_use]
142    pub fn day(self) -> u8 {
143        self.calendar_parts().2
144    }
145
146    /// Parse a strict ISO `YYYY-MM-DD` string into a `Date`.
147    #[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        // Decode one strict fixed-width payload before calendar admission.
155        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    // Invert the March-based cycle used by try_new. Accepted epoch days keep
163    // all arithmetic in i32 and yield months 1..=12 and days 1..=31.
164    #[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        // Integer ingress enforces both the wire width and calendar domain.
218        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//
253// TESTS
254//
255
256#[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    // Internal semantic/storage representation behavior.
354
355    #[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        // i32::MAX + 1 should safely fail
384        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}