Skip to main content

grafeo_common/types/
date.rs

1//! Calendar date type stored as days since Unix epoch.
2//!
3//! Uses Hinnant's civil date algorithms (public domain, no external deps).
4
5use serde::{Deserialize, Serialize};
6use std::fmt;
7
8/// A calendar date, stored as days since Unix epoch (1970-01-01).
9///
10/// Range: roughly year -5,879,610 to +5,879,610.
11///
12/// # Examples
13///
14/// ```
15/// use grafeo_common::types::Date;
16///
17/// let d = Date::from_ymd(2024, 3, 15).unwrap();
18/// assert_eq!(d.year(), 2024);
19/// assert_eq!(d.month(), 3);
20/// assert_eq!(d.day(), 15);
21/// assert_eq!(d.to_string(), "2024-03-15");
22/// ```
23#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
24#[repr(transparent)]
25pub struct Date(i32);
26
27impl Date {
28    /// Creates a date from year, month (1-12), and day (1-31).
29    ///
30    /// Returns `None` if the components are out of range.
31    #[must_use]
32    pub fn from_ymd(year: i32, month: u32, day: u32) -> Option<Self> {
33        if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
34            return None;
35        }
36        // Validate day for the given month
37        let max_day = days_in_month(year, month);
38        if day > max_day {
39            return None;
40        }
41        Some(Self(days_from_civil(year, month, day)))
42    }
43
44    /// Creates a date from days since Unix epoch.
45    #[inline]
46    #[must_use]
47    pub const fn from_days(days: i32) -> Self {
48        Self(days)
49    }
50
51    /// Returns the number of days since Unix epoch.
52    #[inline]
53    #[must_use]
54    pub const fn as_days(self) -> i32 {
55        self.0
56    }
57
58    /// Returns the year component.
59    #[must_use]
60    pub fn year(self) -> i32 {
61        civil_from_days(self.0).0
62    }
63
64    /// Returns the month component (1-12).
65    #[must_use]
66    pub fn month(self) -> u32 {
67        civil_from_days(self.0).1
68    }
69
70    /// Returns the day component (1-31).
71    #[must_use]
72    pub fn day(self) -> u32 {
73        civil_from_days(self.0).2
74    }
75
76    /// Returns (year, month, day) components.
77    #[must_use]
78    pub fn to_ymd(self) -> (i32, u32, u32) {
79        civil_from_days(self.0)
80    }
81
82    /// Parses a date from ISO 8601 format "YYYY-MM-DD".
83    #[must_use]
84    pub fn parse(s: &str) -> Option<Self> {
85        // Handle optional leading minus for negative years
86        let (negative, s) = if let Some(rest) = s.strip_prefix('-') {
87            (true, rest)
88        } else {
89            (false, s)
90        };
91
92        let parts: Vec<&str> = s.splitn(3, '-').collect();
93        if parts.len() != 3 {
94            return None;
95        }
96        let year: i32 = parts[0].parse().ok()?;
97        let month: u32 = parts[1].parse().ok()?;
98        let day: u32 = parts[2].parse().ok()?;
99        let year = if negative { -year } else { year };
100        Self::from_ymd(year, month, day)
101    }
102
103    /// Returns today's date (UTC).
104    #[must_use]
105    pub fn today() -> Self {
106        let ts = super::Timestamp::now();
107        ts.to_date()
108    }
109
110    /// Converts this date to a timestamp at midnight UTC.
111    #[must_use]
112    pub fn to_timestamp(self) -> super::Timestamp {
113        super::Timestamp::from_micros(self.0 as i64 * 86_400_000_000)
114    }
115
116    /// Adds a duration to this date.
117    ///
118    /// Month components are added first (clamping day to month's max),
119    /// then day components.
120    #[must_use]
121    pub fn add_duration(self, dur: &super::Duration) -> Self {
122        let (mut y, mut m, mut d) = self.to_ymd();
123
124        // Add months
125        if dur.months() != 0 {
126            let total_months = y as i64 * 12 + (m as i64 - 1) + dur.months();
127            y = i32::try_from(total_months.div_euclid(12)).unwrap_or(if total_months < 0 {
128                i32::MIN
129            } else {
130                i32::MAX
131            });
132            // reason: rem_euclid(12) + 1 is in [1, 12], always fits u32
133            #[allow(clippy::cast_possible_truncation)]
134            {
135                m = (total_months.rem_euclid(12) + 1) as u32;
136            }
137            // Clamp day to max for new month
138            let max_d = days_in_month(y, m);
139            if d > max_d {
140                d = max_d;
141            }
142        }
143
144        // Add days
145        let days = days_from_civil(y, m, d) as i64 + dur.days();
146        Self(i32::try_from(days).unwrap_or(if days < 0 { i32::MIN } else { i32::MAX }))
147    }
148
149    /// Subtracts a duration from this date.
150    #[must_use]
151    pub fn sub_duration(self, dur: &super::Duration) -> Self {
152        self.add_duration(&dur.neg())
153    }
154
155    /// Truncates this date to the given unit.
156    ///
157    /// - `"year"`: sets month and day to 1 (first day of year)
158    /// - `"month"`: sets day to 1 (first day of month)
159    /// - `"day"`: no-op (already at day precision)
160    #[must_use]
161    pub fn truncate(self, unit: &str) -> Option<Self> {
162        let (y, m, _d) = self.to_ymd();
163        match unit {
164            "year" => Self::from_ymd(y, 1, 1),
165            "month" => Self::from_ymd(y, m, 1),
166            "day" => Some(self),
167            _ => None,
168        }
169    }
170}
171
172impl fmt::Debug for Date {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        write!(f, "Date({})", self)
175    }
176}
177
178impl fmt::Display for Date {
179    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180        let (y, m, d) = civil_from_days(self.0);
181        if y < 0 {
182            write!(f, "-{:04}-{:02}-{:02}", -y, m, d)
183        } else {
184            write!(f, "{:04}-{:02}-{:02}", y, m, d)
185        }
186    }
187}
188
189// ---------------------------------------------------------------------------
190// Hinnant's civil date algorithms (public domain)
191// See: https://howardhinnant.github.io/date_algorithms.html
192// ---------------------------------------------------------------------------
193
194/// Converts (year, month, day) to days since Unix epoch (1970-01-01).
195pub(crate) fn days_from_civil(year: i32, month: u32, day: u32) -> i32 {
196    let y = if month <= 2 { year - 1 } else { year } as i64;
197    let era = y.div_euclid(400);
198    // reason: rem_euclid(400) is in [0, 399], always fits u32
199    #[allow(clippy::cast_possible_truncation)]
200    let yoe = y.rem_euclid(400) as u32; // year of era [0, 399]
201    let m = month;
202    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + day - 1; // day of year [0, 365]
203    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // day of era [0, 146096]
204    let days = era * 146097 + doe as i64 - 719468;
205    i32::try_from(days).unwrap_or(if days < 0 { i32::MIN } else { i32::MAX })
206}
207
208/// Converts days since Unix epoch to (year, month, day).
209pub(crate) fn civil_from_days(days: i32) -> (i32, u32, u32) {
210    let z = days as i64 + 719468;
211    let era = z.div_euclid(146097);
212    // reason: rem_euclid(146097) is in [0, 146096], always fits u32
213    #[allow(clippy::cast_possible_truncation)]
214    let doe = z.rem_euclid(146097) as u32; // day of era [0, 146096]
215    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // year of era [0, 399]
216    let y = yoe as i64 + era * 400;
217    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // day of year [0, 365]
218    let mp = (5 * doy + 2) / 153; // month pseudo [0, 11]
219    let d = doy - (153 * mp + 2) / 5 + 1; // day [1, 31]
220    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // month [1, 12]
221    let y = if m <= 2 { y + 1 } else { y };
222    // reason: year range from i32 input days is bounded within i32
223    #[allow(clippy::cast_possible_truncation)]
224    let year = y as i32;
225    (year, m, d)
226}
227
228/// Returns the number of days in a given month.
229fn days_in_month(year: i32, month: u32) -> u32 {
230    match month {
231        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
232        4 | 6 | 9 | 11 => 30,
233        2 => {
234            if is_leap_year(year) {
235                29
236            } else {
237                28
238            }
239        }
240        _ => 0,
241    }
242}
243
244/// Returns true if the year is a leap year.
245fn is_leap_year(year: i32) -> bool {
246    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn test_epoch() {
255        let d = Date::from_ymd(1970, 1, 1).unwrap();
256        assert_eq!(d.as_days(), 0);
257        assert_eq!(d.year(), 1970);
258        assert_eq!(d.month(), 1);
259        assert_eq!(d.day(), 1);
260    }
261
262    #[test]
263    fn test_known_dates() {
264        // 2024-01-01 is 19723 days after epoch
265        let d = Date::from_ymd(2024, 1, 1).unwrap();
266        assert_eq!(d.as_days(), 19723);
267        assert_eq!(d.to_string(), "2024-01-01");
268
269        // 2000-03-01 (leap year)
270        let d = Date::from_ymd(2000, 3, 1).unwrap();
271        assert_eq!(d.year(), 2000);
272        assert_eq!(d.month(), 3);
273        assert_eq!(d.day(), 1);
274    }
275
276    #[test]
277    fn test_roundtrip() {
278        for days in [-100000, -1, 0, 1, 10000, 19723, 50000] {
279            let d = Date::from_days(days);
280            let (y, m, day) = d.to_ymd();
281            let d2 = Date::from_ymd(y, m, day).unwrap();
282            assert_eq!(d, d2, "roundtrip failed for days={days}");
283        }
284    }
285
286    #[test]
287    fn test_parse() {
288        let d = Date::parse("2024-03-15").unwrap();
289        assert_eq!(d.year(), 2024);
290        assert_eq!(d.month(), 3);
291        assert_eq!(d.day(), 15);
292
293        assert!(Date::parse("not-a-date").is_none());
294        assert!(Date::parse("2024-13-01").is_none()); // invalid month
295        assert!(Date::parse("2024-02-30").is_none()); // invalid day
296    }
297
298    #[test]
299    fn test_display() {
300        assert_eq!(
301            Date::from_ymd(2024, 1, 5).unwrap().to_string(),
302            "2024-01-05"
303        );
304        assert_eq!(
305            Date::from_ymd(100, 12, 31).unwrap().to_string(),
306            "0100-12-31"
307        );
308    }
309
310    #[test]
311    fn test_ordering() {
312        let d1 = Date::from_ymd(2024, 1, 1).unwrap();
313        let d2 = Date::from_ymd(2024, 6, 15).unwrap();
314        assert!(d1 < d2);
315    }
316
317    #[test]
318    fn test_leap_year() {
319        assert!(Date::from_ymd(2000, 2, 29).is_some()); // leap
320        assert!(Date::from_ymd(1900, 2, 29).is_none()); // not leap
321        assert!(Date::from_ymd(2024, 2, 29).is_some()); // leap
322        assert!(Date::from_ymd(2023, 2, 29).is_none()); // not leap
323    }
324
325    #[test]
326    fn test_to_timestamp() {
327        let d = Date::from_ymd(1970, 1, 2).unwrap();
328        assert_eq!(d.to_timestamp().as_micros(), 86_400_000_000);
329    }
330
331    #[test]
332    fn test_truncate() {
333        let d = Date::from_ymd(2024, 6, 15).unwrap();
334
335        let year = d.truncate("year").unwrap();
336        assert_eq!(year.to_string(), "2024-01-01");
337
338        let month = d.truncate("month").unwrap();
339        assert_eq!(month.to_string(), "2024-06-01");
340
341        let day = d.truncate("day").unwrap();
342        assert_eq!(day, d);
343
344        assert!(d.truncate("hour").is_none());
345    }
346
347    #[test]
348    fn test_negative_year() {
349        let d = Date::parse("-0001-01-01").unwrap();
350        assert_eq!(d.year(), -1);
351        assert_eq!(d.to_string(), "-0001-01-01");
352    }
353
354    #[test]
355    fn test_add_duration_months_clamps_day() {
356        use crate::types::Duration;
357        // Jan 31 + 1 month should clamp to Feb 28 (non-leap year)
358        let d = Date::from_ymd(2025, 1, 31).unwrap();
359        let dur = Duration::from_months(1);
360        let result = d.add_duration(&dur);
361        assert_eq!(result.to_string(), "2025-02-28");
362    }
363
364    #[test]
365    fn test_add_duration_months_clamps_leap_year() {
366        use crate::types::Duration;
367        // Jan 31 + 1 month in a leap year should clamp to Feb 29
368        let d = Date::from_ymd(2024, 1, 31).unwrap();
369        let dur = Duration::from_months(1);
370        let result = d.add_duration(&dur);
371        assert_eq!(result.to_string(), "2024-02-29");
372    }
373
374    #[test]
375    fn test_add_duration_days() {
376        use crate::types::Duration;
377        let d = Date::from_ymd(2025, 3, 1).unwrap();
378        let dur = Duration::from_days(10);
379        let result = d.add_duration(&dur);
380        assert_eq!(result.to_string(), "2025-03-11");
381    }
382
383    #[test]
384    fn test_sub_duration() {
385        use crate::types::Duration;
386        let d = Date::from_ymd(2025, 3, 15).unwrap();
387        let dur = Duration::from_months(2);
388        let result = d.sub_duration(&dur);
389        assert_eq!(result.to_string(), "2025-01-15");
390    }
391}