leap/
date.rs

1use std::fmt;
2use std::ops::{RangeFrom, RangeInclusive};
3
4use crate::week::{DAYS, Day};
5use crate::{Error, Result};
6
7const MONTHS: RangeInclusive<u8> = 1..=12;
8const YEARS: RangeFrom<u16> = 1..;
9
10#[expect(clippy::cast_possible_truncation)]
11const DAYS_PER_WEEK: u8 = DAYS.len() as u8;
12
13fn month_days(year: u16, month: u8) -> RangeInclusive<u8> {
14    let month = usize::from(month);
15    let is_leap_year =
16        year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400);
17    1..=[
18        31,                                 // January
19        if is_leap_year { 29 } else { 28 }, // February
20        31,                                 // March
21        30,                                 // April
22        31,                                 // May
23        30,                                 // June
24        31,                                 // July
25        31,                                 // August
26        30,                                 // September
27        31,                                 // October
28        30,                                 // November
29        31,                                 // December
30    ][month - 1]
31}
32
33/// Returns the number of days between the first days of the year 1 and the
34/// specified year.
35fn days_before_year(year: u16) -> usize {
36    debug_assert!(YEARS.contains(&year));
37    let years = usize::from(year - 1);
38    years * 365       // Number of years, times days per year,
39        + years / 4   // plus leap years, which are divisible by 4,
40        - years / 100 // but not by 100, 
41        + years / 400 // unless also divisible by 400.  I don't make the rules.
42}
43
44fn days_before_month(year: u16, month: u8) -> usize {
45    debug_assert!(YEARS.contains(&year));
46    debug_assert!(MONTHS.contains(&month));
47    (1..month).map(|m| month_days(year, m).count()).sum()
48}
49
50/// Proleptic Gregorian calendar date.
51#[derive(Clone, Copy, PartialEq, Eq)]
52pub struct Date {
53    year: u16,
54    month: u8,
55    day: u8,
56}
57
58impl Date {
59    /// December 31st, tens of thousands of years from the day you're reading
60    /// this.
61    pub const MAX: Date = Date {
62        year: u16::MAX,
63        month: 12,
64        day: 31,
65    };
66
67    /// Returns the number of days between the first day of the year 1 and this
68    /// date.
69    fn count_days(self) -> usize {
70        let days = usize::from(self.day - 1);
71        days_before_year(self.year) + days_before_month(self.year, self.month) + days
72    }
73
74    #[must_use]
75    pub fn day_of_week(self) -> Day {
76        // +1 because January 1st of the year 1 CE would have been a Monday.
77        DAYS[(self.count_days() + 1) % DAYS.len()]
78    }
79
80    fn day_of_next_month(self, day: u8) -> Date {
81        let month = self.month + 1;
82        let (year, month) = if MONTHS.contains(&month) {
83            (self.year, month)
84        } else {
85            (self.year + 1, 1)
86        };
87        Date { year, month, day }
88    }
89
90    /// Returns the date [one day later](https://www.youtube.com/watch?v=Ph1M0F99Xv8) than self.
91    ///
92    /// # Panics
93    ///
94    /// Will panic in debug mode only if the resulting date would exceed
95    /// [`Date::MAX`].
96    #[must_use]
97    pub fn plus_one_day(self) -> Date {
98        let day = self.day + 1;
99        if month_days(self.year, self.month).contains(&day) {
100            Date { day, ..self }
101        } else {
102            self.day_of_next_month(1)
103        }
104    }
105
106    /// Returns the date [one week later](https://www.youtube.com/watch?v=BKP3Qe_zZ18) than self.
107    ///
108    /// # Panics
109    ///
110    /// Will panic in debug mode only if the resulting date would exceed
111    /// [`Date::MAX`].
112    #[must_use]
113    pub fn plus_one_week(self) -> Date {
114        let month_days = month_days(self.year, self.month);
115        let day = self.day + DAYS_PER_WEEK;
116        if month_days.contains(&day) {
117            Date { day, ..self }
118        } else {
119            self.day_of_next_month(day - month_days.end())
120        }
121    }
122
123    /// Constructs a date in the specified year, month, and day.  All three
124    /// fields are 1-based.
125    ///
126    /// # Errors
127    ///
128    /// Returns [`Err`] if the date is invalid.  For example, you can't
129    /// construct a nonsensical date like February 40th.
130    ///
131    /// # Examples
132    ///
133    /// ```
134    /// use leap::Date;
135    ///
136    /// assert_eq!(
137    ///     Date::from_ymd(2000, 1, 1)
138    ///         .expect("January 1st, 2000")
139    ///         .to_string(),
140    ///     "2000-01-01"
141    /// );
142    /// ```
143    pub fn from_ymd(year: u16, month: u8, day: u8) -> Result<Date> {
144        (YEARS.contains(&year) && MONTHS.contains(&month) && month_days(year, month).contains(&day))
145            .then_some(Date { year, month, day })
146            .ok_or(Error::Date { year, month, day })
147    }
148
149    #[must_use]
150    pub fn day(self) -> u8 {
151        self.day
152    }
153
154    #[must_use]
155    pub fn month(self) -> u8 {
156        self.month
157    }
158
159    #[must_use]
160    pub fn year(self) -> u16 {
161        self.year
162    }
163
164    /// Returns true iff this date is February 29.
165    #[must_use]
166    pub fn is_leap_day(self) -> bool {
167        self.month == 2 && self.day == 29
168    }
169}
170
171impl fmt::Display for Date {
172    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173        let Date { year, month, day } = self;
174        write!(f, "{year:04}-{month:02}-{day:02}")
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn fields_are_one_based() {
184        assert!(Date::from_ymd(1, 1, 1).is_ok());
185        assert!(Date::from_ymd(1, 1, 0).is_err());
186        assert!(Date::from_ymd(1, 0, 1).is_err());
187        assert!(Date::from_ymd(0, 1, 1).is_err());
188    }
189
190    #[test]
191    fn day_of_week_works() {
192        for (year, day) in [
193            (1, Day::Mon), // 0001-01-01 would have been a Monday.
194            (2, Day::Tue), // 365 % 7 == 1, so each year starts one week day later than the last.
195            (3, Day::Wed),
196            (4, Day::Thu),
197            (5, Day::Sat), // 0004 would have been a leap year, pushing 0005 back an extra day.
198        ] {
199            let got = Date::from_ymd(year, 1, 1).map(Date::day_of_week);
200            assert_eq!(got, Ok(day), "{year}-01-01");
201        }
202    }
203}