Skip to main content

seam_core/
datetime.rs

1//! Written here rather than delegated to a date crate because the two rules
2//! that matter are policy, not parsing: a `Date` is never widened into an
3//! instant, and a `DateTime` without an offset is an error rather than a guess
4//! about local time. Typed conversion happens in the bindings.
5
6use crate::error::Code;
7
8/// Strict `YYYY-MM-DD`, including month lengths and leap years.
9pub fn validate_date(s: &str) -> Result<(), Code> {
10    parse_date(s).map(|_| ()).ok_or(Code::InvalidDate)
11}
12
13/// RFC 3339 with a mandatory offset. Returns [`Code::MissingTimezone`] when the
14/// value is well-formed but unzoned, since that is the common mistake.
15pub 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    // 60 is a leap second, which RFC 3339 allows.
34    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}