1const SECONDS_PER_DAY: i64 = 86_400;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10pub struct CivilDate {
11 pub year: i64,
13 pub month: i64,
15 pub day: i64,
17}
18
19impl CivilDate {
20 #[must_use]
22 pub fn new(year: i64, month: i64, day: i64) -> Option<Self> {
23 let date = Self { year, month, day };
24 date.is_valid().then_some(date)
25 }
26
27 #[must_use]
29 pub fn is_valid(self) -> bool {
30 days_in_month(self.year, self.month)
31 .is_some_and(|max_day| self.day >= 1 && self.day <= max_day)
32 }
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40pub struct CivilDateTime {
41 pub date: CivilDate,
43 pub hour: i64,
45 pub minute: i64,
47 pub second: i64,
49}
50
51impl CivilDateTime {
52 #[must_use]
54 pub fn new(date: CivilDate, hour: i64, minute: i64, second: i64) -> Option<Self> {
55 let datetime = Self {
56 date,
57 hour,
58 minute,
59 second,
60 };
61 datetime.is_valid().then_some(datetime)
62 }
63
64 #[must_use]
66 pub fn is_valid(self) -> bool {
67 self.date.is_valid()
68 && (0..24).contains(&self.hour)
69 && (0..60).contains(&self.minute)
70 && (0..60).contains(&self.second)
71 }
72}
73
74#[must_use]
76pub const fn is_leap_year(year: i64) -> bool {
77 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
78}
79
80#[must_use]
82pub const fn days_in_month(year: i64, month: i64) -> Option<i64> {
83 match month {
84 1 | 3 | 5 | 7 | 8 | 10 | 12 => Some(31),
85 4 | 6 | 9 | 11 => Some(30),
86 2 if is_leap_year(year) => Some(29),
87 2 => Some(28),
88 _ => None,
89 }
90}
91
92#[must_use]
96pub fn civil_from_days(days_since_unix_epoch: i64) -> Option<CivilDate> {
97 civil_from_days_i128(i128::from(days_since_unix_epoch))
98}
99
100#[must_use]
102pub fn days_from_civil(date: CivilDate) -> Option<i64> {
103 if !date.is_valid() {
104 return None;
105 }
106
107 let mut year = i128::from(date.year);
108 let month = i128::from(date.month);
109 let day = i128::from(date.day);
110
111 if month <= 2 {
112 year -= 1;
113 }
114
115 let era = year.div_euclid(400);
116 let yoe = year - era * 400;
117 let mp = month + if month > 2 { -3 } else { 9 };
118 let doy = (153 * mp + 2) / 5 + day - 1;
119 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
120 i64::try_from(era * 146_097 + doe - 719_468).ok()
121}
122
123#[must_use]
125pub const fn datetime_from_epoch_secs(epoch_secs: i64) -> CivilDateTime {
126 let days = epoch_secs.div_euclid(SECONDS_PER_DAY);
127 let secs_of_day = epoch_secs.rem_euclid(SECONDS_PER_DAY);
128 let date = civil_from_epoch_days(days);
129
130 CivilDateTime {
131 date,
132 hour: secs_of_day / 3_600,
133 minute: (secs_of_day % 3_600) / 60,
134 second: secs_of_day % 60,
135 }
136}
137
138#[must_use]
140pub fn epoch_secs_from_datetime(datetime: CivilDateTime) -> Option<i64> {
141 if !datetime.is_valid() {
142 return None;
143 }
144
145 let days = days_from_civil(datetime.date)?;
146 let hour_secs = datetime.hour.checked_mul(3_600)?;
147 let minute_secs = datetime.minute.checked_mul(60)?;
148 let secs_of_day = hour_secs
149 .checked_add(minute_secs)?
150 .checked_add(datetime.second)?;
151 days.checked_mul(SECONDS_PER_DAY)?.checked_add(secs_of_day)
152}
153
154const fn civil_from_epoch_days(days_since_unix_epoch: i64) -> CivilDate {
155 let z = days_since_unix_epoch + 719_468;
156 let era = z.div_euclid(146_097);
157 let doe = z - era * 146_097;
158 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
159 let mut year = yoe + era * 400;
160 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
161 let mp = (5 * doy + 2) / 153;
162 let day = doy - (153 * mp + 2) / 5 + 1;
163 let month = if mp < 10 { mp + 3 } else { mp - 9 };
164
165 if month <= 2 {
166 year += 1;
167 }
168
169 CivilDate { year, month, day }
170}
171
172fn civil_from_days_i128(days_since_unix_epoch: i128) -> Option<CivilDate> {
173 let z = days_since_unix_epoch + 719_468;
174 let era = z.div_euclid(146_097);
175 let doe = z - era * 146_097;
176 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
177 let mut year = yoe + era * 400;
178 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
179 let mp = (5 * doy + 2) / 153;
180 let day = doy - (153 * mp + 2) / 5 + 1;
181 let month = if mp < 10 { mp + 3 } else { mp - 9 };
182
183 if month <= 2 {
184 year += 1;
185 }
186
187 Some(CivilDate {
188 year: i64::try_from(year).ok()?,
189 month: i64::try_from(month).ok()?,
190 day: i64::try_from(day).ok()?,
191 })
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 #[test]
199 fn validates_dates_and_times() {
200 assert!(CivilDate::new(2000, 2, 29).is_some());
201 assert!(CivilDate::new(1900, 2, 29).is_none());
202 assert!(CivilDate::new(2024, 13, 1).is_none());
203 assert!(
204 CivilDateTime::new(
205 CivilDate {
206 year: 2024,
207 month: 1,
208 day: 1
209 },
210 23,
211 59,
212 59
213 )
214 .is_some()
215 );
216 assert!(
217 CivilDateTime::new(
218 CivilDate {
219 year: 2024,
220 month: 1,
221 day: 1
222 },
223 24,
224 0,
225 0
226 )
227 .is_none()
228 );
229 assert!(
230 CivilDateTime::new(
231 CivilDate {
232 year: 2024,
233 month: 1,
234 day: 1
235 },
236 -1,
237 0,
238 0
239 )
240 .is_none()
241 );
242 assert!(
243 CivilDateTime::new(
244 CivilDate {
245 year: 2024,
246 month: 1,
247 day: 1
248 },
249 0,
250 -1,
251 0
252 )
253 .is_none()
254 );
255 assert!(
256 CivilDateTime::new(
257 CivilDate {
258 year: 2024,
259 month: 1,
260 day: 1
261 },
262 0,
263 0,
264 -1
265 )
266 .is_none()
267 );
268 }
269
270 #[test]
271 fn converts_days_and_civil_dates() {
272 assert_eq!(civil_from_days(0), CivilDate::new(1970, 1, 1));
273 assert_eq!(civil_from_days(-1), CivilDate::new(1969, 12, 31));
274 assert_eq!(
275 days_from_civil(CivilDate {
276 year: 1970,
277 month: 1,
278 day: 1
279 }),
280 Some(0)
281 );
282 assert_eq!(
283 days_from_civil(CivilDate {
284 year: 1969,
285 month: 12,
286 day: 31
287 }),
288 Some(-1)
289 );
290 assert_eq!(
291 days_from_civil(CivilDate {
292 year: 1900,
293 month: 2,
294 day: 29
295 }),
296 None
297 );
298 }
299
300 #[test]
301 fn converts_epoch_seconds_and_datetimes() {
302 let cases = [
303 (
304 -1,
305 CivilDateTime {
306 date: CivilDate {
307 year: 1969,
308 month: 12,
309 day: 31,
310 },
311 hour: 23,
312 minute: 59,
313 second: 59,
314 },
315 ),
316 (
317 0,
318 CivilDateTime {
319 date: CivilDate {
320 year: 1970,
321 month: 1,
322 day: 1,
323 },
324 hour: 0,
325 minute: 0,
326 second: 0,
327 },
328 ),
329 (
330 951_782_400,
331 CivilDateTime {
332 date: CivilDate {
333 year: 2000,
334 month: 2,
335 day: 29,
336 },
337 hour: 0,
338 minute: 0,
339 second: 0,
340 },
341 ),
342 (
343 1_700_000_000,
344 CivilDateTime {
345 date: CivilDate {
346 year: 2023,
347 month: 11,
348 day: 14,
349 },
350 hour: 22,
351 minute: 13,
352 second: 20,
353 },
354 ),
355 ];
356
357 for (epoch_secs, datetime) in cases {
358 assert_eq!(datetime_from_epoch_secs(epoch_secs), datetime);
359 assert_eq!(epoch_secs_from_datetime(datetime), Some(epoch_secs));
360 }
361 }
362
363 #[test]
364 fn rejects_invalid_datetime_before_epoch_conversion() {
365 assert_eq!(
366 epoch_secs_from_datetime(CivilDateTime {
367 date: CivilDate {
368 year: 2024,
369 month: 1,
370 day: 1
371 },
372 hour: -1,
373 minute: 0,
374 second: 0,
375 }),
376 None
377 );
378 }
379}