Skip to main content

librotenado/
date.rs

1// SPDX-License-Identifier: MIT
2//! Calendar dates without time or time zone, as in `2026-10-24`.
3
4use std::{fmt, str::FromStr};
5
6use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
7
8use crate::ParseError;
9
10/// A day of the Gregorian calendar, written `YYYY-MM-DD` (years 0001 to 9999).
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
12pub struct Date {
13    year: u16,
14    month: u8,
15    day: u8,
16}
17
18impl Date {
19    /// The date, or `None` if it does not exist.
20    pub fn new(year: u16, month: u8, day: u8) -> Option<Self> {
21        let valid = (1..=9999).contains(&year) && (1..=12).contains(&month) && day >= 1 && day <= days_in(year, month);
22        valid.then_some(Date { year, month, day })
23    }
24
25    /// The year.
26    pub fn year(self) -> u16 {
27        self.year
28    }
29
30    /// The month, 1 to 12.
31    pub fn month(self) -> u8 {
32        self.month
33    }
34
35    /// The day of the month.
36    pub fn day(self) -> u8 {
37        self.day
38    }
39}
40
41fn days_in(year: u16, month: u8) -> u8 {
42    match month {
43        4 | 6 | 9 | 11 => 30,
44        2 if year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400)) => 29,
45        2 => 28,
46        _ => 31,
47    }
48}
49
50impl FromStr for Date {
51    type Err = ParseError;
52
53    fn from_str(s: &str) -> Result<Self, Self::Err> {
54        let err = || ParseError::Date(s.to_owned());
55        let b = s.as_bytes();
56        if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
57            return Err(err());
58        }
59        let num = |part: &str| part.bytes().all(|c| c.is_ascii_digit()).then(|| part.parse::<u16>().ok()).flatten();
60        let (year, month, day) = (num(&s[0..4]), num(&s[5..7]), num(&s[8..10]));
61        match (year, month, day) {
62            (Some(y), Some(m), Some(d)) => Date::new(y, m as u8, d as u8).ok_or_else(err),
63            _ => Err(err()),
64        }
65    }
66}
67
68impl fmt::Display for Date {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
71    }
72}
73
74impl Serialize for Date {
75    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
76        serializer.collect_str(self)
77    }
78}
79
80impl<'de> Deserialize<'de> for Date {
81    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
82        String::deserialize(deserializer)?.parse().map_err(de::Error::custom)
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn reads_and_writes_iso_dates() {
92        assert_eq!("2026-10-24".parse::<Date>().unwrap().to_string(), "2026-10-24");
93        assert!("2024-02-29".parse::<Date>().is_ok());
94        assert!("2000-02-29".parse::<Date>().is_ok());
95    }
96
97    #[test]
98    fn rejects_days_that_do_not_exist() {
99        for s in ["2026-02-29", "1900-02-29", "2026-04-31", "2026-13-01", "0000-01-01", "2026-1-01", "26-10-24"] {
100            assert!(s.parse::<Date>().is_err(), "{s:?}");
101        }
102    }
103
104    #[test]
105    fn orders_chronologically() {
106        assert!("2026-01-31".parse::<Date>().unwrap() < "2026-02-01".parse().unwrap());
107    }
108}