Skip to main content

core_invoice/
date.rs

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