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};
7use candid::CandidType;
8use serde::{Deserialize, Deserializer, Serialize};
9use std::fmt::{self, Debug, Display};
10use time::{Date as TimeDate, Duration as TimeDuration, Month};
11
12// Invariant:
13// Date is internally represented as days since Unix epoch (`i32`) and is
14// bounded to the proleptic Gregorian calendar range 0000-01-01..=9999-12-31.
15// Human-readable Serde uses ISO-8601 text; binary Serde uses epoch days.
16// Ordering and arithmetic remain numeric and deterministic over day counts.
17
18//
19// Date
20//
21// Represented as days since Unix epoch.
22// Candid uses int32 epoch days; JSON uses ISO-8601 text (`YYYY-MM-DD`).
23//
24
25#[derive(CandidType, Clone, Copy, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
26#[repr(transparent)]
27pub struct Date(i32);
28
29impl Date {
30    /// The Unix epoch date, 1970-01-01.
31    pub const EPOCH: Self = Self(0);
32    /// The earliest supported calendar date, 0000-01-01.
33    pub const MIN: Self = Self(-719_528);
34    /// The latest supported calendar date, 9999-12-31.
35    pub const MAX: Self = Self(2_932_896);
36
37    const fn epoch_date() -> TimeDate {
38        // Safe: constant valid date
39        match TimeDate::from_calendar_date(1970, Month::January, 1) {
40            Ok(d) => d,
41            Err(_) => unreachable!(),
42        }
43    }
44
45    /// Build a date from exact calendar parts.
46    ///
47    /// Returns `None` when any component is out of range.
48    #[must_use]
49    pub fn try_new(y: i32, m: u8, d: u8) -> Option<Self> {
50        if !(0..=9_999).contains(&y) {
51            return None;
52        }
53        let month = Month::try_from(m).ok()?;
54        let date = TimeDate::from_calendar_date(y, month, d).ok()?;
55        Self::from_time_date(date)
56    }
57
58    /// Construct from the bounded internal day-count representation.
59    #[must_use]
60    pub const fn try_from_days_since_epoch(days: i32) -> Option<Self> {
61        if days < Self::MIN.0 || days > Self::MAX.0 {
62            None
63        } else {
64            Some(Self(days))
65        }
66    }
67
68    /// Return the internal day-count representation.
69    #[must_use]
70    pub const fn as_days_since_epoch(self) -> i32 {
71        self.0
72    }
73
74    /// Fallible conversion from `i64` day-count representation.
75    #[must_use]
76    pub fn try_from_i64(days: i64) -> Option<Self> {
77        i32::try_from(days)
78            .ok()
79            .and_then(Self::try_from_days_since_epoch)
80    }
81
82    /// Fallible conversion from `u64` day-count representation.
83    #[must_use]
84    pub fn try_from_u64(days: u64) -> Option<Self> {
85        i32::try_from(days)
86            .ok()
87            .and_then(Self::try_from_days_since_epoch)
88    }
89
90    /// Add a signed number of days without leaving the supported calendar.
91    #[must_use]
92    pub fn checked_add_days(self, days: i64) -> Option<Self> {
93        i64::from(self.0)
94            .checked_add(days)
95            .and_then(Self::try_from_i64)
96    }
97
98    /// Subtract a signed number of days without leaving the supported calendar.
99    #[must_use]
100    pub fn checked_sub_days(self, days: i64) -> Option<Self> {
101        i64::from(self.0)
102            .checked_sub(days)
103            .and_then(Self::try_from_i64)
104    }
105
106    /// Return the signed number of days from `earlier` to this date.
107    #[must_use]
108    pub fn days_since(self, earlier: Self) -> i64 {
109        i64::from(self.0) - i64::from(earlier.0)
110    }
111
112    /// Returns the year component (e.g. 2025).
113    #[must_use]
114    pub fn year(self) -> i32 {
115        self.to_time_date().year()
116    }
117
118    /// Returns the month component (1-12).
119    #[must_use]
120    pub fn month(self) -> u8 {
121        self.to_time_date().month().into()
122    }
123
124    /// Returns the day-of-month component (1-31).
125    #[must_use]
126    pub fn day(self) -> u8 {
127        self.to_time_date().day()
128    }
129
130    /// Parse a strict ISO `YYYY-MM-DD` string into a `Date`.
131    #[must_use]
132    pub fn parse(s: &str) -> Option<Self> {
133        let bytes = s.as_bytes();
134        if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
135            return None;
136        }
137
138        // Phase 1: decode one strict fixed-width `YYYY-MM-DD` payload without
139        // routing through the heavier `time` text parser.
140        let year = parse_ascii_i32(&bytes[0..4])?;
141        let month = parse_ascii_u8(&bytes[5..7])?;
142        let day = parse_ascii_u8(&bytes[8..10])?;
143
144        Self::try_new(year, month, day)
145    }
146
147    fn from_time_date(date: TimeDate) -> Option<Self> {
148        let epoch = Self::epoch_date();
149        let days = (date - epoch).whole_days();
150        Self::try_from_i64(days)
151    }
152
153    // Safe public construction and persisted decoding enforce the bounded Date
154    // invariant before this private calendar conversion is reachable.
155    fn to_time_date(self) -> TimeDate {
156        let epoch = Self::epoch_date();
157        let delta = TimeDuration::days(self.0.into());
158        match epoch.checked_add(delta) {
159            Some(date) => date,
160            None => unreachable!("bounded Date invariant must produce a supported calendar date"),
161        }
162    }
163}
164
165impl Debug for Date {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        write!(f, "Date({self})")
168    }
169}
170
171impl Display for Date {
172    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173        let d = self.to_time_date();
174        let month: u8 = d.month().into();
175        write!(f, "{:04}-{:02}-{:02}", d.year(), month, d.day())
176    }
177}
178
179impl NumericValue for Date {
180    fn try_to_decimal(&self) -> Option<Decimal> {
181        Decimal::from_i64(i64::from(self.0))
182    }
183
184    fn try_from_decimal(value: Decimal) -> Option<Self> {
185        value.to_i32().and_then(Self::try_from_days_since_epoch)
186    }
187}
188
189impl<'de> Deserialize<'de> for Date {
190    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
191    where
192        D: Deserializer<'de>,
193    {
194        if deserializer.is_human_readable() {
195            return Self::parse(&String::deserialize(deserializer)?)
196                .ok_or_else(|| serde::de::Error::custom(TypeParseError::InvalidDate));
197        }
198        // Integer ingress enforces both the wire width and calendar domain.
199        Self::try_from_days_since_epoch(i32::deserialize(deserializer)?)
200            .ok_or_else(|| serde::de::Error::custom(TypeParseError::InvalidDate))
201    }
202}
203
204impl Serialize for Date {
205    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
206    where
207        S: serde::Serializer,
208    {
209        if serializer.is_human_readable() {
210            serializer.collect_str(self)
211        } else {
212            serializer.serialize_i32(self.0)
213        }
214    }
215}
216
217fn parse_ascii_i32(bytes: &[u8]) -> Option<i32> {
218    bytes.iter().try_fold(0_i32, |value, byte| {
219        byte.checked_sub(b'0')
220            .filter(|digit| *digit <= 9)
221            .and_then(|digit| value.checked_mul(10)?.checked_add(i32::from(digit)))
222    })
223}
224
225fn parse_ascii_u8(bytes: &[u8]) -> Option<u8> {
226    bytes.iter().try_fold(0_u8, |value, byte| {
227        byte.checked_sub(b'0')
228            .filter(|digit| *digit <= 9)
229            .and_then(|digit| value.checked_mul(10)?.checked_add(digit))
230    })
231}
232
233//
234// TESTS
235//
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn serde_calendar_boundaries_and_batches_use_compact_days() {
243        let today = Date::try_new(2026, 9, 12).unwrap();
244        for (date, width) in [(Date::EPOCH, 1), (today, 3), (Date::MIN, 5), (Date::MAX, 5)] {
245            let mut encoded = Vec::new();
246            ciborium::into_writer(&date, &mut encoded).unwrap();
247            assert_eq!(encoded.len(), width);
248            let wire: ciborium::Value = ciborium::from_reader(encoded.as_slice()).unwrap();
249            assert_eq!(
250                wire,
251                ciborium::Value::Integer(date.as_days_since_epoch().into())
252            );
253            assert_eq!(
254                ciborium::from_reader::<Date, _>(encoded.as_slice()).unwrap(),
255                date
256            );
257
258            let json = serde_json::to_string(&date).unwrap();
259            assert_eq!(json, format!("\"{date}\""));
260            assert_eq!(serde_json::from_str::<Date>(&json).unwrap(), date);
261
262            let candid = candid::encode_one(date).unwrap();
263            assert_eq!(
264                candid,
265                candid::encode_one(date.as_days_since_epoch()).unwrap()
266            );
267            assert_eq!(candid::decode_one::<Date>(&candid).unwrap(), date);
268
269            let batch = vec![date; 1_000];
270            encoded.clear();
271            ciborium::into_writer(&batch, &mut encoded).unwrap();
272            assert_eq!(encoded.len(), 3 + 1_000 * width);
273            assert_eq!(
274                ciborium::from_reader::<Vec<Date>, _>(encoded.as_slice()).unwrap(),
275                batch
276            );
277        }
278    }
279
280    #[test]
281    fn binary_serde_rejects_days_outside_calendar_domain() {
282        for day in [
283            i64::MIN,
284            i64::from(Date::MIN.as_days_since_epoch()) - 1,
285            i64::from(Date::MAX.as_days_since_epoch()) + 1,
286            i64::MAX,
287        ] {
288            let mut encoded = Vec::new();
289            ciborium::into_writer(&day, &mut encoded).unwrap();
290            assert!(ciborium::from_reader::<Date, _>(encoded.as_slice()).is_err());
291        }
292    }
293
294    // Internal semantic/storage representation behavior.
295
296    #[test]
297    fn from_ymd_and_to_naive_date_round_trip() {
298        let date = Date::try_new(2024, 10, 19).expect("valid calendar date should construct");
299        assert_eq!(date.year(), 2024);
300        assert_eq!(date.month(), 10);
301        assert_eq!(date.day(), 19);
302    }
303
304    #[test]
305    fn try_new_rejects_out_of_range_month_and_day() {
306        assert!(Date::try_new(2025, 13, 99).is_none());
307    }
308
309    #[test]
310    fn invalid_date_parse_returns_none() {
311        assert!(Date::parse("2025-13-40").is_none());
312        assert!(Date::try_new(2025, 2, 30).is_none());
313    }
314
315    #[test]
316    fn try_new_rejects_out_of_range_year() {
317        assert!(Date::try_new(-1, 1, 1).is_none());
318        assert!(Date::try_new(10_000, 1, 1).is_none());
319        assert!(Date::try_new(i32::MAX, 1, 1).is_none());
320    }
321
322    #[test]
323    fn overflow_protection_in_try_from_u64() {
324        // i32::MAX + 1 should safely fail
325        let too_large = (i32::MAX as u64) + 1;
326        assert!(Date::try_from_u64(too_large).is_none());
327    }
328
329    #[test]
330    fn ordering_and_equality_follow_internal_day_count() {
331        let d1 = Date::try_new(2020, 1, 1).unwrap();
332        let d2 = Date::try_new(2021, 1, 1).unwrap();
333
334        assert!(d1 < d2);
335        assert!(d1.as_days_since_epoch() < d2.as_days_since_epoch());
336        assert_eq!(d1, d1);
337    }
338
339    #[test]
340    fn internal_day_count_helpers_round_trip() {
341        let days = -365;
342        let date = Date::try_from_days_since_epoch(days).expect("bounded day should construct");
343        assert_eq!(date.as_days_since_epoch(), days);
344    }
345
346    #[test]
347    fn raw_day_construction_rejects_values_outside_calendar_bounds() {
348        assert_eq!(
349            Date::try_from_days_since_epoch(Date::MIN.as_days_since_epoch()),
350            Some(Date::MIN),
351        );
352        assert_eq!(
353            Date::try_from_days_since_epoch(Date::MAX.as_days_since_epoch()),
354            Some(Date::MAX),
355        );
356        assert!(Date::try_from_days_since_epoch(Date::MIN.as_days_since_epoch() - 1).is_none(),);
357        assert!(Date::try_from_days_since_epoch(Date::MAX.as_days_since_epoch() + 1).is_none(),);
358    }
359
360    #[test]
361    fn checked_day_arithmetic_obeys_calendar_bounds() {
362        let leap_day = Date::try_new(2024, 2, 29).expect("leap day should construct");
363        let march_first = Date::try_new(2024, 3, 1).expect("next day should construct");
364
365        assert_eq!(leap_day.checked_add_days(1), Some(march_first));
366        assert_eq!(march_first.checked_sub_days(1), Some(leap_day));
367        assert_eq!(march_first.days_since(leap_day), 1);
368        assert!(Date::MAX.checked_add_days(1).is_none());
369        assert!(Date::MIN.checked_sub_days(1).is_none());
370        assert!(Date::EPOCH.checked_add_days(i64::MAX).is_none());
371        assert!(Date::EPOCH.checked_sub_days(i64::MIN).is_none());
372    }
373
374    #[test]
375    fn display_formats_as_iso_date() {
376        let date = Date::try_new(2025, 10, 19).unwrap();
377        assert_eq!(format!("{date}"), "2025-10-19");
378    }
379
380    #[test]
381    fn parse_stays_iso_strict() {
382        assert_eq!(Date::parse("2025-10-19"), Date::try_new(2025, 10, 19));
383        assert!(Date::parse("10/19/2025").is_none());
384        assert!(Date::parse("2025-10-19T00:00:00Z").is_none());
385    }
386
387    #[test]
388    fn parse_supports_pre_epoch_and_leap_year_cases() {
389        assert_eq!(
390            Date::parse("1900-01-01"),
391            Date::try_new(1900, 1, 1),
392            "expected non-leap-century date to parse",
393        );
394        assert_eq!(
395            Date::parse("1969-12-31"),
396            Date::try_new(1969, 12, 31),
397            "expected pre-epoch date to parse",
398        );
399        assert_eq!(
400            Date::parse("2000-02-29"),
401            Date::try_new(2000, 2, 29),
402            "expected leap-day date to parse",
403        );
404    }
405
406    #[test]
407    fn parse_rejects_invalid_non_leap_day() {
408        assert!(Date::parse("1900-02-29").is_none());
409    }
410
411    #[test]
412    fn calendar_boundaries_format_and_parse_exactly() {
413        assert_eq!(Date::MIN.to_string(), "0000-01-01");
414        assert_eq!(Date::MAX.to_string(), "9999-12-31");
415        assert_eq!(Date::parse(Date::MIN.to_string().as_str()), Some(Date::MIN));
416        assert_eq!(Date::parse(Date::MAX.to_string().as_str()), Some(Date::MAX));
417    }
418
419    #[test]
420    fn candid_decode_rejects_out_of_range_epoch_days() {
421        let min =
422            candid::encode_one(Date::MIN.as_days_since_epoch()).expect("minimum day should encode");
423        let max =
424            candid::encode_one(Date::MAX.as_days_since_epoch()).expect("maximum day should encode");
425        let below = candid::encode_one(Date::MIN.as_days_since_epoch() - 1)
426            .expect("out-of-range day should encode as raw i32");
427        let above = candid::encode_one(Date::MAX.as_days_since_epoch() + 1)
428            .expect("out-of-range day should encode as raw i32");
429
430        assert_eq!(
431            candid::decode_one::<Date>(&min).expect("minimum day should decode"),
432            Date::MIN,
433        );
434        assert_eq!(
435            candid::decode_one::<Date>(&max).expect("maximum day should decode"),
436            Date::MAX,
437        );
438        assert!(candid::decode_one::<Date>(&below).is_err());
439        assert!(candid::decode_one::<Date>(&above).is_err());
440    }
441}