1use std::time::{SystemTime, UNIX_EPOCH};
2
3const SECONDS_PER_DAY: u64 = 86_400;
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
7pub struct Date(i64);
8
9impl Date {
10 #[must_use]
12 pub fn today() -> Self {
13 let since_epoch = SystemTime::now()
14 .duration_since(UNIX_EPOCH)
15 .unwrap_or_default();
16 Self(i64::try_from(since_epoch.as_secs() / SECONDS_PER_DAY).unwrap_or(i64::MAX))
17 }
18
19 #[must_use]
20 pub fn parse(text: &str) -> Option<Self> {
21 let shaped = text.len() == 10
22 && text.bytes().enumerate().all(|(index, byte)| match index {
23 4 | 7 => byte == b'-',
24 _ => byte.is_ascii_digit(),
25 });
26 if !shaped {
27 return None;
28 }
29 let year: i64 = text[0..4].parse().ok()?;
30 let month: i64 = text[5..7].parse().ok()?;
31 let day: i64 = text[8..10].parse().ok()?;
32 let valid = (1..=12).contains(&month) && (1..=days_in_month(year, month)).contains(&day);
33 valid.then(|| Self(days_from_epoch(year, month, day)))
34 }
35
36 #[must_use]
37 pub fn days_since(self, earlier: Self) -> i64 {
38 self.0 - earlier.0
39 }
40}
41
42fn days_in_month(year: i64, month: i64) -> i64 {
43 let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
44 match month {
45 2 if leap => 29,
46 2 => 28,
47 4 | 6 | 9 | 11 => 30,
48 _ => 31,
49 }
50}
51
52fn days_from_epoch(year: i64, month: i64, day: i64) -> i64 {
54 let year = if month <= 2 { year - 1 } else { year };
55 let era = year.div_euclid(400);
56 let year_of_era = year - era * 400;
57 let day_of_year = (153 * ((month + 9) % 12) + 2) / 5 + day - 1;
58 let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
59 era * 146_097 + day_of_era - 719_468
60}
61
62#[cfg(test)]
63mod tests {
64 use super::Date;
65
66 #[test]
67 fn counts_days_across_months_and_leap_years() {
68 let day = |text| Date::parse(text).unwrap();
69 assert_eq!(day("1970-01-01").days_since(day("1970-01-01")), 0);
70 assert_eq!(day("2024-03-01").days_since(day("2024-02-28")), 2);
71 assert_eq!(day("2026-10-14").days_since(day("2025-10-14")), 365);
72 assert_eq!(day("2000-03-01").days_since(day("1970-01-01")), 11_017);
73 }
74
75 #[test]
76 fn refuses_anything_but_a_real_day() {
77 for refused in [
78 "2026-02-29",
79 "2026-13-01",
80 "2026-00-10",
81 "2026-9-01",
82 "+026-09-01",
83 "",
84 ] {
85 assert_eq!(Date::parse(refused), None, "{refused}");
86 }
87 }
88}