Skip to main content

sofie_check/
date.rs

1use std::time::{SystemTime, UNIX_EPOCH};
2
3const SECONDS_PER_DAY: u64 = 86_400;
4
5/// A UTC calendar day, counted from 1970-01-01.
6#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
7pub struct Date(i64);
8
9impl Date {
10    /// The one system time read outside `Clock`: a flag's age is measured on the day `sofie check` runs.
11    #[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
52// Howard Hinnant's `days_from_civil`: https://howardhinnant.github.io/date_algorithms.html
53fn 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}