Skip to main content

use_date/
lib.rs

1#![forbid(unsafe_code)]
2//! Primitive proleptic Gregorian date helpers.
3//!
4//! These helpers keep calendar dates explicit and deterministic without adding
5//! timezone handling.
6//!
7//! # Examples
8//!
9//! ```rust
10//! use use_date::{CalendarDate, add_days, day_of_year, days_between, is_valid_date};
11//!
12//! let leap_day = CalendarDate::new(2024, 2, 29).unwrap();
13//! let next_day = add_days(leap_day, 1);
14//!
15//! assert!(is_valid_date(2024, 2, 29));
16//! assert_eq!(day_of_year(2024, 2, 29).unwrap(), 60);
17//! assert_eq!(next_day, CalendarDate::new(2024, 3, 1).unwrap());
18//! assert_eq!(days_between(leap_day, next_day), 1);
19//! ```
20
21const DAYS_BEFORE_MONTH: [u16; 12] = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
24pub struct CalendarDate {
25    year: i32,
26    month: u8,
27    day: u8,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum DateError {
32    InvalidMonth,
33    InvalidDay,
34}
35
36fn is_leap_year(year: i32) -> bool {
37    year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
38}
39
40fn days_in_month(year: i32, month: u8) -> Option<u8> {
41    match month {
42        1 | 3 | 5 | 7 | 8 | 10 | 12 => Some(31),
43        4 | 6 | 9 | 11 => Some(30),
44        2 => Some(if is_leap_year(year) { 29 } else { 28 }),
45        _ => None,
46    }
47}
48
49fn day_of_year_unchecked(year: i32, month: u8, day: u8) -> u16 {
50    let mut ordinal = DAYS_BEFORE_MONTH[usize::from(month - 1)] + u16::from(day);
51
52    if is_leap_year(year) && month > 2 {
53        ordinal += 1;
54    }
55
56    ordinal
57}
58
59fn days_from_civil(year: i32, month: u8, day: u8) -> i64 {
60    let year = i64::from(year) - if month <= 2 { 1 } else { 0 };
61    let era = if year >= 0 { year } else { year - 399 } / 400;
62    let year_of_era = year - era * 400;
63    let month_index = i64::from(month) + if month > 2 { -3 } else { 9 };
64    let day_of_year = (153 * month_index + 2) / 5 + i64::from(day) - 1;
65    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
66
67    era * 146_097 + day_of_era - 719_468
68}
69
70fn civil_from_days(days: i64) -> CalendarDate {
71    let days = days + 719_468;
72    let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
73    let day_of_era = days - era * 146_097;
74    let year_of_era =
75        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
76    let year = year_of_era + era * 400;
77    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
78    let month_index = (5 * day_of_year + 2) / 153;
79    let day = day_of_year - (153 * month_index + 2) / 5 + 1;
80    let month = month_index + if month_index < 10 { 3 } else { -9 };
81    let year = year + if month <= 2 { 1 } else { 0 };
82
83    CalendarDate {
84        year: year as i32,
85        month: month as u8,
86        day: day as u8,
87    }
88}
89
90pub fn is_valid_date(year: i32, month: u8, day: u8) -> bool {
91    matches!(days_in_month(year, month), Some(days) if (1..=days).contains(&day))
92}
93
94impl CalendarDate {
95    pub fn new(year: i32, month: u8, day: u8) -> Result<Self, DateError> {
96        let max_day = days_in_month(year, month).ok_or(DateError::InvalidMonth)?;
97
98        if !(1..=max_day).contains(&day) {
99            return Err(DateError::InvalidDay);
100        }
101
102        Ok(Self { year, month, day })
103    }
104
105    #[must_use]
106    pub fn year(&self) -> i32 {
107        self.year
108    }
109
110    #[must_use]
111    pub fn month(&self) -> u8 {
112        self.month
113    }
114
115    #[must_use]
116    pub fn day(&self) -> u8 {
117        self.day
118    }
119
120    #[must_use]
121    pub fn is_valid(&self) -> bool {
122        is_valid_date(self.year, self.month, self.day)
123    }
124
125    #[must_use]
126    pub fn day_of_year(&self) -> u16 {
127        day_of_year_unchecked(self.year, self.month, self.day)
128    }
129}
130
131pub fn day_of_year(year: i32, month: u8, day: u8) -> Result<u16, DateError> {
132    Ok(CalendarDate::new(year, month, day)?.day_of_year())
133}
134
135#[must_use]
136pub fn days_between(start: CalendarDate, end: CalendarDate) -> i64 {
137    days_from_civil(end.year, end.month, end.day)
138        - days_from_civil(start.year, start.month, start.day)
139}
140
141#[must_use]
142pub fn add_days(date: CalendarDate, days: i64) -> CalendarDate {
143    civil_from_days(days_from_civil(date.year, date.month, date.day) + days)
144}
145
146#[cfg(test)]
147mod tests {
148    use super::{add_days, day_of_year, days_between, is_valid_date, CalendarDate, DateError};
149
150    #[test]
151    fn validates_dates_and_day_of_year() {
152        let leap_day = CalendarDate::new(2024, 2, 29).unwrap();
153
154        assert!(leap_day.is_valid());
155        assert!(is_valid_date(2024, 2, 29));
156        assert!(!is_valid_date(2023, 2, 29));
157        assert_eq!(leap_day.day_of_year(), 60);
158        assert_eq!(day_of_year(2024, 12, 31).unwrap(), 366);
159    }
160
161    #[test]
162    fn adds_days_and_measures_distance() {
163        let start = CalendarDate::new(2024, 2, 28).unwrap();
164        let end = add_days(start, 2);
165
166        assert_eq!(end, CalendarDate::new(2024, 3, 1).unwrap());
167        assert_eq!(days_between(start, end), 2);
168        assert_eq!(add_days(end, -2), start);
169    }
170
171    #[test]
172    fn rejects_invalid_dates() {
173        assert_eq!(CalendarDate::new(2024, 13, 1), Err(DateError::InvalidMonth));
174        assert_eq!(CalendarDate::new(2024, 2, 0), Err(DateError::InvalidDay));
175        assert_eq!(CalendarDate::new(2023, 2, 29), Err(DateError::InvalidDay));
176        assert_eq!(day_of_year(2024, 0, 1), Err(DateError::InvalidMonth));
177    }
178}