embedded_date_time/date.rs
1use core::fmt;
2
3use crate::DateTime;
4use crate::Time;
5use crate::Weekday;
6
7/// A simple date in the Gregorian calendar without time zone information.
8#[derive(PartialEq, Eq, Hash, PartialOrd, Ord, Copy, Clone)]
9pub struct Date {
10 /// The year as commonly used in a date.
11 ///
12 /// The range of 2000 to 2099 is guaranteed to be supported by all methods.
13 /// For support beyond this range, consult the documentation of the respective implementation.
14 pub year: u16,
15
16 /// Month of the year [1-12].
17 pub month: u8,
18
19 /// Day of the month [1-31].
20 pub day: u8,
21}
22
23impl Date {
24 /// Create a new Date from year, month and day.
25 ///
26 /// No checks will be performed to validate the date.
27 #[must_use]
28 pub fn new(year: u16, month: u8, day: u8) -> Self {
29 Self { year, month, day }
30 }
31
32 /// Create a new Date from year, month and day.
33 ///
34 /// Returns None if the year, month or day is invalid.
35 ///
36 /// The year must be between 2000 and 2199.
37 /// This range might be extended in the future if the implementation supports it.
38 #[must_use]
39 pub fn new_checked(year: u16, month: u8, day: u8) -> Option<Self> {
40 let result = Self::new(year, month, day);
41 result.is_valid().then_some(result)
42 }
43
44 /// Determine whether the date is a valid combination of year, month and day.
45 ///
46 /// This is guaranteed to work for the years between 2000 and 2199, inclusive.
47 ///
48 /// Years outside of this range will return arbitrary results or may panic.
49 #[must_use]
50 pub fn is_valid(self) -> bool {
51 if !(2000..=2199).contains(&self.year) {
52 return false;
53 }
54
55 let days_in_month = match self.month {
56 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
57 4 | 6 | 9 | 11 => 30,
58 2 => 28 + u8::from(self.is_leap_year()),
59 _ => return false,
60 };
61
62 (1..=days_in_month).contains(&self.day)
63 }
64
65 /// Helper function to determine whether a given year is a leap year.
66 ///
67 /// This is guaranteed to work for the years between 2000 and 2199.
68 ///
69 /// Years outside of this range will return arbitrary results or may panic.
70 #[must_use]
71 pub fn is_leap_year(self) -> bool {
72 let year2000 = self.year2000();
73 // check if the year is divisible by 4 but make an exception for 2100
74 year2000.trailing_zeros() >= 2 && year2000 != 100
75 }
76
77 /// Create a new `DateTime` from a `Date` and a `Time`.
78 ///
79 /// Returns None if the hours, minutes or seconds is invalid.
80 #[must_use]
81 pub fn with_time(self, time: Time) -> DateTime {
82 DateTime { date: self, time }
83 }
84
85 /// Returns the year relative to 2000.
86 ///
87 /// This is guaranteed to work for the years between 2000 and 2255, inclusive.
88 ///
89 /// The purpose of this method is to obtain a year with reduced width for cheaper calculations.
90 /// Subtracting 2000 preserves the leap year pattern.
91 ///
92 /// Years outside of this range will return arbitrary results or may panic.
93 #[must_use]
94 #[expect(
95 clippy::cast_possible_truncation,
96 reason = "the limitation is documented"
97 )]
98 pub fn year2000(self) -> u8 {
99 (self.year - 2000) as u8
100 }
101
102 /// Returns the weekday of the date.
103 ///
104 /// This is guaranteed to work for all valid dates within the years 2000 to 2171.
105 ///
106 /// Invalid dates will return arbitrary results or may panic.
107 #[must_use]
108 pub fn weekday(self) -> Weekday {
109 // number of week-day shifts for each month of a non-leap year
110 // To keep this number low, we use modulo 7 at compile time which won't affect the result.
111 let months = [
112 0_u8, // enables 1-based indexing of months
113 0, // January
114 31 % 7,
115 59 % 7,
116 90 % 7,
117 120 % 7,
118 151 % 7,
119 181 % 7,
120 212 % 7,
121 243 % 7,
122 (273 % 7) as u8,
123 (304 % 7) as u8,
124 (334 % 7) as u8, // December
125 ];
126
127 // number of week-day shifts for the year
128 // one extra-shift for each leap year (with a correction for the skipped year 2100)
129 let year2000 = self.year2000();
130 let year_ordinal = year2000 + year2000.div_ceil(4) - u8::from(year2000 > 100);
131
132 // insert Feb. 29 for leap years
133 let leap_ordinal = u8::from(self.month > 2 && self.is_leap_year());
134
135 // number of week-day shifts for the month
136 let month_ordinal = *months.get(usize::from(self.month)).unwrap_or(&0);
137
138 // number of week-day shifts for the day
139 let day_ordinal = self.day;
140
141 // total number of week-day shifts
142 // This will overflow past the year 2171; extend to u16 if this becomes a real limitation.
143 let ordinal_date = year_ordinal + leap_ordinal + month_ordinal + day_ordinal;
144
145 // this is the only real division (modulo 7)
146 // the offset has been chosen to make the result match the ordinal value of the Weekday-enum
147 Weekday::from((ordinal_date + 5) % 7)
148 }
149}
150
151impl fmt::Debug for Date {
152 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153 fmt::Display::fmt(&self, f)
154 }
155}
156
157impl fmt::Display for Date {
158 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159 write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
160 }
161}
162
163#[cfg(feature = "defmt")]
164impl defmt::Format for Date {
165 fn format(&self, fmt: defmt::Formatter<'_>) {
166 defmt::write!(fmt, "{:04}-{:02}-{:02}", self.year, self.month, self.day);
167 }
168}
169
170#[cfg(feature = "ufmt")]
171impl ufmt::uDebug for Date {
172 fn fmt<W>(&self, fmt: &mut ufmt::Formatter<'_, W>) -> Result<(), W::Error>
173 where
174 W: ufmt::uWrite + ?Sized,
175 {
176 ufmt::uDisplay::fmt(&self, fmt)
177 }
178}
179
180#[cfg(feature = "ufmt")]
181impl ufmt::uDisplay for Date {
182 fn fmt<W>(&self, fmt: &mut ufmt::Formatter<'_, W>) -> Result<(), W::Error>
183 where
184 W: ufmt::uWrite + ?Sized,
185 {
186 use ufmt::uwrite;
187
188 let Self { year, month, day } = *self;
189
190 uwrite!(fmt, "{}-", year)?;
191
192 if month < 10 {
193 uwrite!(fmt, "0{}-", month)?;
194 } else {
195 uwrite!(fmt, "{}-", month)?;
196 }
197
198 if day < 10 {
199 uwrite!(fmt, "0{}", day)?;
200 } else {
201 uwrite!(fmt, "{}", day)?;
202 }
203
204 Ok(())
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 #![expect(clippy::panic, reason = "this is a test")]
211
212 #[test]
213 fn test_validation() {
214 // iterate through all valid (and some invalid) dates and test whether `chrono` and
215 // `embedded-date-time` agree on the validation.
216 for year in 2000_u16..=2199 {
217 for month in 0_u8..=13 {
218 for day in 0_u8..=32 {
219 let embedded_date = crate::Date::new_checked(year, month, day);
220 let chrono_date = chrono::NaiveDate::from_ymd_opt(
221 i32::from(year),
222 u32::from(month),
223 u32::from(day),
224 );
225
226 match (embedded_date, chrono_date) {
227 (None, Some(date)) => {
228 panic!("chrono validated but embedded didn't: {date}")
229 }
230 (Some(date), None) => {
231 panic!("embedded validated but chrono didn't: {date:?}")
232 }
233 (None, None) | (Some(_), Some(_)) => {}
234 }
235 }
236 }
237 }
238 }
239}