Skip to main content

dpp_rules/common/
date.rs

1//! A dependency-free calendar date, used as an ordering key for regulatory
2//! effective dates.
3//!
4//! `dpp-rules` is `no_std` with no dependencies, so it cannot use `chrono`.
5//! Regulatory phase selection only ever needs to answer "is this date on or
6//! after that date", which is a lexicographic comparison of (year, month, day)
7//! — no calendar arithmetic, no time zones, no leap-year handling.
8//!
9//! Deliberately *not* a validated calendar date. Range checking belongs at the
10//! ingestion boundary, where a bad value can be rejected with a field path and
11//! a message; a rules crate that silently repaired one would hide the defect.
12
13/// A calendar date, comparable by (year, month, day).
14///
15/// The derived `Ord` compares fields in declaration order, which for these
16/// three fields is chronological order.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
18pub struct CalendarDate {
19    pub year: i32,
20    pub month: u8,
21    pub day: u8,
22}
23
24impl CalendarDate {
25    /// Construct a date from year, month and day.
26    #[must_use]
27    pub const fn new(year: i32, month: u8, day: u8) -> Self {
28        Self { year, month, day }
29    }
30
31    /// Whether the month is 1–12 and the day 1–31.
32    ///
33    /// A coarse well-formedness check, not a calendar validation: it accepts
34    /// 31 February. Use it to reject transposed or garbage input at an
35    /// ingestion boundary, not to prove a date exists.
36    #[must_use]
37    pub const fn is_plausible(&self) -> bool {
38        self.month >= 1 && self.month <= 12 && self.day >= 1 && self.day <= 31
39    }
40
41    /// Parse a strict `YYYY-MM-DD` date.
42    ///
43    /// Returns `None` for anything else — wrong length, wrong separators,
44    /// non-digits, or components failing [`Self::is_plausible`]. Deliberately
45    /// strict and deliberately not lenient about partial dates: a compliance
46    /// determination keyed on a date must never run against one the declarer
47    /// did not actually state.
48    #[must_use]
49    pub fn parse_iso(s: &str) -> Option<Self> {
50        let b = s.as_bytes();
51        if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
52            return None;
53        }
54        let field = |from: usize, to: usize| -> Option<u32> {
55            let mut acc: u32 = 0;
56            for &c in &b[from..to] {
57                if !c.is_ascii_digit() {
58                    return None;
59                }
60                acc = acc * 10 + u32::from(c - b'0');
61            }
62            Some(acc)
63        };
64        let date = Self::new(
65            i32::try_from(field(0, 4)?).ok()?,
66            u8::try_from(field(5, 7)?).ok()?,
67            u8::try_from(field(8, 10)?).ok()?,
68        );
69        date.is_plausible().then_some(date)
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn orders_chronologically_across_field_boundaries() {
79        let earlier = CalendarDate::new(2031, 8, 17);
80        let boundary = CalendarDate::new(2031, 8, 18);
81        assert!(earlier < boundary);
82        // Year dominates month, month dominates day.
83        assert!(CalendarDate::new(2030, 12, 31) < CalendarDate::new(2031, 1, 1));
84        assert!(CalendarDate::new(2031, 7, 31) < CalendarDate::new(2031, 8, 1));
85    }
86
87    #[test]
88    fn equal_dates_compare_equal() {
89        assert_eq!(
90            CalendarDate::new(2036, 8, 18),
91            CalendarDate::new(2036, 8, 18)
92        );
93    }
94
95    #[test]
96    fn parses_a_well_formed_iso_date() {
97        assert_eq!(
98            CalendarDate::parse_iso("2031-08-18"),
99            Some(CalendarDate::new(2031, 8, 18))
100        );
101        assert_eq!(
102            CalendarDate::parse_iso("0001-01-01"),
103            Some(CalendarDate::new(1, 1, 1))
104        );
105    }
106
107    #[test]
108    fn rejects_anything_that_is_not_strict_iso() {
109        for bad in [
110            "",
111            "2031-8-18",   // unpadded month
112            "2031/08/18",  // wrong separator
113            "31-08-2031",  // wrong order
114            "2031-08-18T", // trailing content
115            "2031-08-1",   // too short
116            "2031-08-188", // too long
117            "20x1-08-18",  // non-digit
118            "2031-13-01",  // implausible month
119            "2031-00-01",
120            "2031-08-00",
121            "2031-08-32",
122        ] {
123            assert_eq!(CalendarDate::parse_iso(bad), None, "should reject {bad:?}");
124        }
125    }
126
127    #[test]
128    fn plausibility_rejects_out_of_range_components() {
129        assert!(CalendarDate::new(2031, 8, 18).is_plausible());
130        assert!(!CalendarDate::new(2031, 13, 1).is_plausible());
131        assert!(!CalendarDate::new(2031, 0, 1).is_plausible());
132        assert!(!CalendarDate::new(2031, 8, 0).is_plausible());
133        assert!(!CalendarDate::new(2031, 8, 32).is_plausible());
134        // Coarse by design: month length is not checked.
135        assert!(CalendarDate::new(2031, 2, 31).is_plausible());
136    }
137}