Skip to main content

core_invoice/
date.rs

1//! Calendar [`Date`]: `YYYY-MM-DD`, no time, no timezone. Invalid input fails closed.
2
3use crate::error::DateError;
4use std::fmt;
5
6/// Calendar day. No time, no timezone. Inbound `xs:date` offsets are dropped in formats.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
8pub struct Date {
9    year: i32,
10    month: u8,
11    day: u8,
12}
13
14impl Date {
15    /// Calendar day. Invalid Y-M-D is `Err`. Year `0..=9999`.
16    pub fn new(year: i32, month: u8, day: u8) -> Result<Self, DateError> {
17        if !(0..=9999).contains(&year) || !(1..=12).contains(&month) {
18            return Err(DateError::Invalid);
19        }
20        if day == 0 || day > days_in_month(year, month) {
21            return Err(DateError::Invalid);
22        }
23        Ok(Self { year, month, day })
24    }
25
26    /// `YYYY-MM-DD` only. Time and zone suffixes fail closed.
27    pub fn parse(s: &str) -> Result<Self, DateError> {
28        let s = s.trim();
29        if s.len() < 10 || s.as_bytes().get(4) != Some(&b'-') || s.as_bytes().get(7) != Some(&b'-')
30        {
31            return Err(DateError::Invalid);
32        }
33        if s.len() > 10 {
34            // Time of day is forbidden on the type. Zone suffixes are formats' job.
35            return Err(DateError::Invalid);
36        }
37        let year: i32 = s[..4].parse().map_err(|_| DateError::Invalid)?;
38        let month: u8 = s[5..7].parse().map_err(|_| DateError::Invalid)?;
39        let day: u8 = s[8..10].parse().map_err(|_| DateError::Invalid)?;
40        Self::new(year, month, day)
41    }
42
43    /// Year (`0..=9999`).
44    pub fn year(self) -> i32 {
45        self.year
46    }
47    /// Month (`1..=12`).
48    pub fn month(self) -> u8 {
49        self.month
50    }
51    /// Day of month.
52    pub fn day(self) -> u8 {
53        self.day
54    }
55}
56
57fn days_in_month(year: i32, month: u8) -> u8 {
58    match month {
59        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
60        4 | 6 | 9 | 11 => 30,
61        2 if leap(year) => 29,
62        2 => 28,
63        _ => 0,
64    }
65}
66
67fn leap(year: i32) -> bool {
68    year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
69}
70
71impl fmt::Display for Date {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn parses_iso_day() {
83        let d = Date::parse("2026-06-30").unwrap();
84        assert_eq!(d.to_string(), "2026-06-30");
85        assert!(Date::parse("2026-02-30").is_err());
86        assert!(Date::parse("2026-06-01T00:00:00").is_err());
87        // Zone suffix is rejected; we do not apply an offset and shift the day.
88        assert!(Date::parse("2026-01-15Z").is_err());
89        assert!(Date::parse("2026-01-15+00:00").is_err());
90    }
91}