1use crate::error::Code;
7
8pub fn validate_date(s: &str) -> Result<(), Code> {
10 parse_date(s).map(|_| ()).ok_or(Code::InvalidDate)
11}
12
13pub fn validate_datetime(s: &str) -> Result<(), Code> {
16 let bytes = s.as_bytes();
17
18 let date_part = s.get(..10).ok_or(Code::InvalidDateTime)?;
19 parse_date(date_part).ok_or(Code::InvalidDateTime)?;
20 match bytes.get(10) {
21 Some(b'T' | b't') => {}
22 _ => return Err(Code::InvalidDateTime),
23 }
24
25 let time_part = s.get(11..19).ok_or(Code::InvalidDateTime)?;
26 let tb = time_part.as_bytes();
27 if tb.get(2) != Some(&b':') || tb.get(5) != Some(&b':') {
28 return Err(Code::InvalidDateTime);
29 }
30 let hour = two_digits(tb, 0).ok_or(Code::InvalidDateTime)?;
31 let minute = two_digits(tb, 3).ok_or(Code::InvalidDateTime)?;
32 let second = two_digits(tb, 6).ok_or(Code::InvalidDateTime)?;
33 if hour > 23 || minute > 59 || second > 60 {
35 return Err(Code::InvalidDateTime);
36 }
37
38 let mut rest = s.get(19..).ok_or(Code::InvalidDateTime)?;
39 if let Some(after_dot) = rest.strip_prefix('.') {
40 let digits = after_dot.bytes().take_while(u8::is_ascii_digit).count();
41 if digits == 0 {
42 return Err(Code::InvalidDateTime);
43 }
44 rest = after_dot.get(digits..).ok_or(Code::InvalidDateTime)?;
45 }
46
47 if rest.is_empty() {
48 return Err(Code::MissingTimezone);
49 }
50 if matches!(rest, "Z" | "z") {
51 return Ok(());
52 }
53
54 let ob = rest.as_bytes();
55 if ob.len() != 6 || !matches!(ob.first(), Some(b'+' | b'-')) || ob.get(3) != Some(&b':') {
56 return Err(Code::InvalidDateTime);
57 }
58 let off_h = two_digits(ob, 1).ok_or(Code::InvalidDateTime)?;
59 let off_m = two_digits(ob, 4).ok_or(Code::InvalidDateTime)?;
60 if off_h > 23 || off_m > 59 {
61 return Err(Code::InvalidDateTime);
62 }
63
64 Ok(())
65}
66
67fn parse_date(s: &str) -> Option<(u32, u32, u32)> {
68 let b = s.as_bytes();
69 if b.len() != 10 || b.get(4) != Some(&b'-') || b.get(7) != Some(&b'-') {
70 return None;
71 }
72
73 let year = four_digits(b, 0)?;
74 let month = two_digits(b, 5)?;
75 let day = two_digits(b, 8)?;
76
77 if month == 0 || month > 12 || day == 0 || day > days_in_month(year, month) {
78 return None;
79 }
80 Some((year, month, day))
81}
82
83fn days_in_month(year: u32, month: u32) -> u32 {
84 match month {
85 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
86 4 | 6 | 9 | 11 => 30,
87 2 if is_leap_year(year) => 29,
88 2 => 28,
89 _ => 0,
90 }
91}
92
93fn is_leap_year(year: u32) -> bool {
94 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
95}
96
97fn two_digits(b: &[u8], at: usize) -> Option<u32> {
98 let d0 = digit(*b.get(at)?)?;
99 let d1 = digit(*b.get(at + 1)?)?;
100 Some(d0 * 10 + d1)
101}
102
103fn four_digits(b: &[u8], at: usize) -> Option<u32> {
104 let hi = two_digits(b, at)?;
105 let lo = two_digits(b, at + 2)?;
106 Some(hi * 100 + lo)
107}
108
109fn digit(c: u8) -> Option<u32> {
110 c.is_ascii_digit().then(|| u32::from(c - b'0'))
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 #[test]
118 fn ordinary_dates_pass() {
119 assert!(validate_date("2026-08-29").is_ok());
120 assert!(validate_date("2000-02-29").is_ok());
121 assert!(validate_date("2024-02-29").is_ok());
122 }
123
124 #[test]
125 fn dates_that_do_not_exist_fail() {
126 assert_eq!(validate_date("2023-02-29"), Err(Code::InvalidDate));
127 assert_eq!(validate_date("1900-02-29"), Err(Code::InvalidDate));
128 assert_eq!(validate_date("2026-13-01"), Err(Code::InvalidDate));
129 assert_eq!(validate_date("2026-04-31"), Err(Code::InvalidDate));
130 assert_eq!(validate_date("2026-00-10"), Err(Code::InvalidDate));
131 }
132
133 #[test]
134 fn lenient_date_spellings_are_rejected() {
135 assert_eq!(validate_date("2026-8-29"), Err(Code::InvalidDate));
136 assert_eq!(validate_date("2026/08/29"), Err(Code::InvalidDate));
137 assert_eq!(
138 validate_date("2026-08-29T00:00:00Z"),
139 Err(Code::InvalidDate)
140 );
141 assert_eq!(validate_date(""), Err(Code::InvalidDate));
142 }
143
144 #[test]
145 fn datetimes_with_an_offset_pass() {
146 assert!(validate_datetime("2026-08-29T14:30:00Z").is_ok());
147 assert!(validate_datetime("2026-08-29T14:30:00+02:00").is_ok());
148 assert!(validate_datetime("2026-08-29T14:30:00-05:00").is_ok());
149 assert!(validate_datetime("2026-08-29T14:30:00.123456Z").is_ok());
150 }
151
152 #[test]
153 fn a_naive_datetime_is_an_error_not_an_assumption() {
154 assert_eq!(
155 validate_datetime("2026-08-29T14:30:00"),
156 Err(Code::MissingTimezone)
157 );
158 assert_eq!(
159 validate_datetime("2026-08-29T14:30:00.500"),
160 Err(Code::MissingTimezone)
161 );
162 }
163
164 #[test]
165 fn malformed_datetimes_fail_as_malformed() {
166 assert_eq!(validate_datetime("2026-08-29"), Err(Code::InvalidDateTime));
167 assert_eq!(
168 validate_datetime("2026-08-29 14:30:00Z"),
169 Err(Code::InvalidDateTime)
170 );
171 assert_eq!(
172 validate_datetime("2026-08-29T25:00:00Z"),
173 Err(Code::InvalidDateTime)
174 );
175 assert_eq!(
176 validate_datetime("2026-08-29T14:30:00+2:00"),
177 Err(Code::InvalidDateTime)
178 );
179 }
180}