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 derive_more::{Add, AddAssign, Sub, SubAssign};
9use serde::{Deserialize, Deserializer, Serialize};
10use std::fmt::{self, Debug, Display};
11use time::{Date as TimeDate, Duration as TimeDuration, Month};
12
13// Invariant:
14// Date is internally represented as days since Unix epoch (`i32`).
15// API/JSON deserialization accepts ISO-8601 text (`YYYY-MM-DD`).
16// Ordering and arithmetic remain numeric and deterministic over day counts.
17
18//
19// Date
20//
21// Represented as days since Unix epoch.
22// API/JSON decode expects ISO-8601 text (`YYYY-MM-DD`).
23//
24
25#[derive(
26    Add,
27    AddAssign,
28    CandidType,
29    Clone,
30    Copy,
31    Default,
32    Eq,
33    PartialEq,
34    Hash,
35    Ord,
36    PartialOrd,
37    Sub,
38    SubAssign,
39)]
40#[repr(transparent)]
41pub struct Date(i32);
42
43impl Date {
44    /// The Unix epoch date, 1970-01-01.
45    pub const EPOCH: Self = Self(0);
46    /// The earliest representable epoch-day value.
47    pub const MIN: Self = Self(i32::MIN);
48    /// The latest representable epoch-day value.
49    pub const MAX: Self = Self(i32::MAX);
50
51    const fn epoch_date() -> TimeDate {
52        // Safe: constant valid date
53        match TimeDate::from_calendar_date(1970, Month::January, 1) {
54            Ok(d) => d,
55            Err(_) => unreachable!(),
56        }
57    }
58
59    /// Build a date from exact calendar parts.
60    ///
61    /// Returns `None` when any component is out of range.
62    #[must_use]
63    pub fn try_new(y: i32, m: u8, d: u8) -> Option<Self> {
64        let month = Month::try_from(m).ok()?;
65        let date = TimeDate::from_calendar_date(y, month, d).ok()?;
66        Some(Self::from_time_date(date))
67    }
68
69    /// Construct directly from internal day-count representation.
70    #[must_use]
71    pub const fn from_days_since_epoch(days: i32) -> Self {
72        Self(days)
73    }
74
75    /// Return the internal day-count representation.
76    #[must_use]
77    pub const fn as_days_since_epoch(self) -> i32 {
78        self.0
79    }
80
81    /// Fallible conversion from `i64` day-count representation.
82    #[must_use]
83    pub fn try_from_i64(days: i64) -> Option<Self> {
84        i32::try_from(days).ok().map(Self)
85    }
86
87    /// Fallible conversion from `u64` day-count representation.
88    #[must_use]
89    pub fn try_from_u64(days: u64) -> Option<Self> {
90        i32::try_from(days).ok().map(Self)
91    }
92
93    /// Returns the year component (e.g. 2025).
94    #[must_use]
95    pub fn year(self) -> i32 {
96        self.to_time_date().year()
97    }
98
99    /// Returns the month component (1-12).
100    #[must_use]
101    pub fn month(self) -> u8 {
102        self.to_time_date().month().into()
103    }
104
105    /// Returns the day-of-month component (1-31).
106    #[must_use]
107    pub fn day(self) -> u8 {
108        self.to_time_date().day()
109    }
110
111    /// Parse a strict ISO `YYYY-MM-DD` string into a `Date`.
112    #[must_use]
113    pub fn parse(s: &str) -> Option<Self> {
114        let bytes = s.as_bytes();
115        if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
116            return None;
117        }
118
119        // Phase 1: decode one strict fixed-width `YYYY-MM-DD` payload without
120        // routing through the heavier `time` text parser.
121        let year = parse_ascii_i32(&bytes[0..4])?;
122        let month = parse_ascii_u8(&bytes[5..7])?;
123        let day = parse_ascii_u8(&bytes[8..10])?;
124
125        Self::try_new(year, month, day)
126    }
127
128    // `time::Date` arithmetic returns `i64` day deltas; this type is fixed to `i32`.
129    #[expect(clippy::cast_possible_truncation)]
130    fn from_time_date(date: TimeDate) -> Self {
131        let epoch = Self::epoch_date();
132        let days = (date - epoch).whole_days();
133        Self(days as i32)
134    }
135
136    // Rebuild calendar components from internal epoch-day storage for display/helpers.
137    fn to_time_date(self) -> TimeDate {
138        let epoch = Self::epoch_date();
139        let delta = TimeDuration::days(self.0.into());
140        epoch.checked_add(delta).unwrap_or({
141            if self.0 >= 0 {
142                TimeDate::MAX
143            } else {
144                TimeDate::MIN
145            }
146        })
147    }
148}
149
150impl Debug for Date {
151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152        write!(f, "Date({self})")
153    }
154}
155
156impl Display for Date {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        let d = self.to_time_date();
159        let month: u8 = d.month().into();
160        write!(f, "{:04}-{:02}-{:02}", d.year(), month, d.day())
161    }
162}
163
164impl NumericValue for Date {
165    fn try_to_decimal(&self) -> Option<Decimal> {
166        Decimal::from_i64(i64::from(self.0))
167    }
168
169    fn try_from_decimal(value: Decimal) -> Option<Self> {
170        value.to_i32().map(Self)
171    }
172}
173
174impl<'de> Deserialize<'de> for Date {
175    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
176    where
177        D: Deserializer<'de>,
178    {
179        struct DateVisitor;
180
181        impl serde::de::Visitor<'_> for DateVisitor {
182            type Value = Date;
183
184            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
185                formatter.write_str("ISO date text or canonical epoch-day integer")
186            }
187
188            fn visit_i32<E>(self, value: i32) -> Result<Self::Value, E> {
189                Ok(Date::from_days_since_epoch(value))
190            }
191
192            fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
193            where
194                E: serde::de::Error,
195            {
196                Date::try_from_i64(value).ok_or_else(|| E::custom(TypeParseError::InvalidDate))
197            }
198
199            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
200            where
201                E: serde::de::Error,
202            {
203                Date::parse(value).ok_or_else(|| E::custom(TypeParseError::InvalidDate))
204            }
205        }
206
207        deserializer.deserialize_any(DateVisitor)
208    }
209}
210
211impl Serialize for Date {
212    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
213    where
214        S: serde::Serializer,
215    {
216        serializer.serialize_str(&self.to_string())
217    }
218}
219
220fn parse_ascii_i32(bytes: &[u8]) -> Option<i32> {
221    bytes.iter().try_fold(0_i32, |value, byte| {
222        byte.checked_sub(b'0')
223            .filter(|digit| *digit <= 9)
224            .and_then(|digit| value.checked_mul(10)?.checked_add(i32::from(digit)))
225    })
226}
227
228fn parse_ascii_u8(bytes: &[u8]) -> Option<u8> {
229    bytes.iter().try_fold(0_u8, |value, byte| {
230        byte.checked_sub(b'0')
231            .filter(|digit| *digit <= 9)
232            .and_then(|digit| value.checked_mul(10)?.checked_add(digit))
233    })
234}
235
236//
237// TESTS
238//
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    // Internal semantic/storage representation behavior.
245
246    #[test]
247    fn from_ymd_and_to_naive_date_round_trip() {
248        let date = Date::try_new(2024, 10, 19).expect("valid calendar date should construct");
249        assert_eq!(date.year(), 2024);
250        assert_eq!(date.month(), 10);
251        assert_eq!(date.day(), 19);
252    }
253
254    #[test]
255    fn try_new_rejects_out_of_range_month_and_day() {
256        assert!(Date::try_new(2025, 13, 99).is_none());
257    }
258
259    #[test]
260    fn invalid_date_parse_returns_none() {
261        assert!(Date::parse("2025-13-40").is_none());
262        assert!(Date::try_new(2025, 2, 30).is_none());
263    }
264
265    #[test]
266    fn try_new_rejects_out_of_range_year() {
267        assert!(Date::try_new(i32::MAX, 1, 1).is_none());
268    }
269
270    #[test]
271    fn overflow_protection_in_try_from_u64() {
272        // i32::MAX + 1 should safely fail
273        let too_large = (i32::MAX as u64) + 1;
274        assert!(Date::try_from_u64(too_large).is_none());
275    }
276
277    #[test]
278    fn ordering_and_equality_follow_internal_day_count() {
279        let d1 = Date::try_new(2020, 1, 1).unwrap();
280        let d2 = Date::try_new(2021, 1, 1).unwrap();
281
282        assert!(d1 < d2);
283        assert!(d1.as_days_since_epoch() < d2.as_days_since_epoch());
284        assert_eq!(d1, d1);
285    }
286
287    #[test]
288    fn internal_day_count_helpers_round_trip() {
289        let days = -365;
290        let date = Date::from_days_since_epoch(days);
291        assert_eq!(date.as_days_since_epoch(), days);
292        assert_eq!(date.as_days_since_epoch(), days);
293    }
294
295    #[test]
296    fn display_formats_as_iso_date() {
297        let date = Date::try_new(2025, 10, 19).unwrap();
298        assert_eq!(format!("{date}"), "2025-10-19");
299    }
300
301    #[test]
302    fn parse_stays_iso_strict() {
303        assert_eq!(Date::parse("2025-10-19"), Date::try_new(2025, 10, 19));
304        assert!(Date::parse("10/19/2025").is_none());
305        assert!(Date::parse("2025-10-19T00:00:00Z").is_none());
306    }
307
308    #[test]
309    fn parse_supports_pre_epoch_and_leap_year_cases() {
310        assert_eq!(
311            Date::parse("1900-01-01"),
312            Date::try_new(1900, 1, 1),
313            "expected non-leap-century date to parse",
314        );
315        assert_eq!(
316            Date::parse("1969-12-31"),
317            Date::try_new(1969, 12, 31),
318            "expected pre-epoch date to parse",
319        );
320        assert_eq!(
321            Date::parse("2000-02-29"),
322            Date::try_new(2000, 2, 29),
323            "expected leap-day date to parse",
324        );
325    }
326
327    #[test]
328    fn parse_rejects_invalid_non_leap_day() {
329        assert!(Date::parse("1900-02-29").is_none());
330    }
331
332    #[test]
333    fn extreme_internal_day_values_format_without_panicking() {
334        let min_rendered = Date::MIN.to_string();
335        let max_rendered = Date::MAX.to_string();
336
337        assert!(!min_rendered.is_empty());
338        assert!(!max_rendered.is_empty());
339    }
340}