1#[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 #[must_use]
27 pub const fn new(year: i32, month: u8, day: u8) -> Self {
28 Self { year, month, day }
29 }
30
31 #[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 #[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 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", "2031/08/18", "31-08-2031", "2031-08-18T", "2031-08-1", "2031-08-188", "20x1-08-18", "2031-13-01", "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 assert!(CalendarDate::new(2031, 2, 31).is_plausible());
136 }
137}