Skip to main content

tzcompile/model/
calendar.rs

1//! Proleptic Gregorian calendar arithmetic, implemented in-house.
2//!
3//! We deliberately do **not** depend on `time`/`chrono`/`jiff` for date math. `zic`'s
4//! transition model has its own conventions (days that spill across month boundaries via
5//! `Sun>=29`, year ranges back to the far past, `24:00`+ times), and the safest way to
6//! match it exactly is to own the arithmetic and test it against reference `zic`.
7//!
8//! The civil<->days conversion is Howard Hinnant's well-known algorithm
9//! (<http://howardhinnant.github.io/date_algorithms.html>): branch-free, valid across an
10//! enormous range, with the epoch at 1970-01-01. It is the backbone of turning a
11//! `(year, month, day, time)` tuple into a Unix timestamp.
12//!
13//! These primitives are exercised throughout the compiler: expanding rule activations across
14//! their year span, converting each era's `UNTIL` and rule `AT` to a UT instant, the day-form
15//! arithmetic behind recurring POSIX footers (`Sun>=N` → `M{m}.{week}.{wday}`), and decoding
16//! compiled transition instants back to readable timestamps in `explain`. Each is unit-tested
17//! directly and validated end-to-end against reference `zic` via the `zdump` oracle.
18
19use crate::diagnostics::DiagnosticCode;
20
21/// Days of the week, `Sunday = 0` .. `Saturday = 6` (the order `zic` uses).
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Weekday {
24    Sun = 0,
25    Mon = 1,
26    Tue = 2,
27    Wed = 3,
28    Thu = 4,
29    Fri = 5,
30    Sat = 6,
31}
32
33impl Weekday {
34    pub fn from_index(i: u32) -> Weekday {
35        use Weekday::*;
36        match i % 7 {
37            0 => Sun,
38            1 => Mon,
39            2 => Tue,
40            3 => Wed,
41            4 => Thu,
42            5 => Fri,
43            _ => Sat,
44        }
45    }
46}
47
48/// The `ON` day specification of a `Rule` (or the day part of an `UNTIL`).
49///
50/// `zic` permits several forms; this captures all of them so the parser can round-trip
51/// them and the T2 compiler can resolve them to a concrete day-of-month.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum OnDay {
54    /// A fixed day-of-month, e.g. `5`.
55    Day(u8),
56    /// `lastSun`, `lastMon`, ... — the last given weekday in the month.
57    Last(Weekday),
58    /// `Sun>=8` — the first `weekday` on or after `day`.
59    OnAfter(Weekday, u8),
60    /// `Sun<=25` — the last `weekday` on or before `day`.
61    OnBefore(Weekday, u8),
62}
63
64/// Is `year` a leap year in the proleptic Gregorian calendar?
65pub fn is_leap(year: i32) -> bool {
66    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
67}
68
69/// Number of days in `month` (1..=12) of `year`.
70pub fn days_in_month(year: i32, month: u8) -> u8 {
71    match month {
72        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
73        4 | 6 | 9 | 11 => 30,
74        2 if is_leap(year) => 29,
75        2 => 28,
76        _ => 0, // caller validates the month range; 0 makes misuse obvious.
77    }
78}
79
80/// Days since the Unix epoch (1970-01-01) for a civil date. Howard Hinnant's algorithm.
81///
82/// Valid for any `year` in `i32`. `month` is 1..=12, `day` is 1..=31. The function does not
83/// validate the day against the month length — callers that built the date legitimately
84/// (or want `zic`'s spill behaviour) rely on the raw arithmetic.
85pub fn days_from_civil(year: i32, month: u8, day: u8) -> i64 {
86    let y = year as i64 - if month <= 2 { 1 } else { 0 };
87    let era = (if y >= 0 { y } else { y - 399 }) / 400;
88    let yoe = y - era * 400; // [0, 399]
89    let m = month as i64;
90    let d = day as i64;
91    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; // [0, 365]
92    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
93    era * 146097 + doe - 719468
94}
95
96/// Inverse of [`days_from_civil`]: a day count since 1970-01-01 back to `(year, month, day)`.
97/// Howard Hinnant's `civil_from_days`. Used to bound the years an era spans when walking
98/// multi-era zones (we know an era's start/end as UT instants and need the calendar years).
99pub fn civil_from_days(days: i64) -> (i32, u8, u8) {
100    let z = days + 719468;
101    let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
102    let doe = z - era * 146097; // [0, 146096]
103    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
104    let y = yoe + era * 400;
105    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
106    let mp = (5 * doy + 2) / 153; // [0, 11]
107    let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
108    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
109    let year = if m <= 2 { y + 1 } else { y };
110    (year as i32, m as u8, d as u8)
111}
112
113/// The civil year containing a Unix timestamp (UT).
114pub fn year_of_unix(seconds: i64) -> i32 {
115    civil_from_days(seconds.div_euclid(86400)).0
116}
117
118/// Day of the week for a civil date.
119pub fn weekday_of(year: i32, month: u8, day: u8) -> Weekday {
120    // 1970-01-01 was a Thursday. Reduce modulo 7, keeping the result non-negative.
121    let days = days_from_civil(year, month, day);
122    let idx = (days + 4).rem_euclid(7); // +4 shifts epoch-Thursday to Sunday=0 indexing.
123    Weekday::from_index(idx as u32)
124}
125
126/// Resolve an [`OnDay`] to a concrete day-of-month within `(year, month)`.
127///
128/// Returns the day as a (possibly out-of-month) value the way `zic` computes it; for the
129/// `>=`/`<=` forms `zic` allows the result to land in a neighbouring month (e.g. `Sun>=29`
130/// in a 30-day month), which the T2 compiler handles by normalising the date afterwards.
131/// Here we return the raw day number and a month delta so callers can normalise.
132pub fn resolve_on_day(
133    on: OnDay,
134    year: i32,
135    month: u8,
136) -> std::result::Result<ResolvedDay, (DiagnosticCode, String)> {
137    let dim = days_in_month(year, month);
138    let result = match on {
139        OnDay::Day(d) => ResolvedDay {
140            day: d as i32,
141            month,
142            year,
143        },
144        OnDay::Last(wd) => {
145            // Walk back from the last day of the month to the wanted weekday.
146            let last = dim;
147            let last_wd = weekday_of(year, month, last) as i32;
148            let want = wd as i32;
149            let back = (last_wd - want).rem_euclid(7);
150            ResolvedDay {
151                day: last as i32 - back,
152                month,
153                year,
154            }
155        }
156        OnDay::OnAfter(wd, d) => {
157            let start_wd = weekday_at(year, month, d as i32) as i32;
158            let want = wd as i32;
159            let fwd = (want - start_wd).rem_euclid(7);
160            normalise(year, month, d as i32 + fwd)
161        }
162        OnDay::OnBefore(wd, d) => {
163            let start_wd = weekday_at(year, month, d as i32) as i32;
164            let want = wd as i32;
165            let back = (start_wd - want).rem_euclid(7);
166            normalise(year, month, d as i32 - back)
167        }
168    };
169    Ok(result)
170}
171
172/// A fully-resolved (and month-normalised) calendar day.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub struct ResolvedDay {
175    pub year: i32,
176    pub month: u8,
177    pub day: i32,
178}
179
180/// Weekday at a day number that may be <1 or >days_in_month, via the day-count algorithm.
181fn weekday_at(year: i32, month: u8, day: i32) -> Weekday {
182    let days = days_from_civil(year, month, 1) + (day as i64 - 1);
183    Weekday::from_index(((days + 4).rem_euclid(7)) as u32)
184}
185
186/// Normalise a `(year, month, day)` where `day` may have spilled outside `[1, dim]` into a
187/// real calendar date. Handles the `Sun>=29`/`Sun<=2` spill cases `zic` allows.
188fn normalise(mut year: i32, mut month: u8, mut day: i32) -> ResolvedDay {
189    loop {
190        if day < 1 {
191            // Borrow from the previous month.
192            month = if month == 1 {
193                year -= 1;
194                12
195            } else {
196                month - 1
197            };
198            day += days_in_month(year, month) as i32;
199        } else {
200            let dim = days_in_month(year, month) as i32;
201            if day > dim {
202                day -= dim;
203                month = if month == 12 {
204                    year += 1;
205                    1
206                } else {
207                    month + 1
208                };
209            } else {
210                return ResolvedDay { year, month, day };
211            }
212        }
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn leap_years() {
222        assert!(is_leap(2000));
223        assert!(!is_leap(1900));
224        assert!(is_leap(2020));
225        assert!(!is_leap(2021));
226    }
227
228    #[test]
229    fn epoch_is_thursday() {
230        assert_eq!(weekday_of(1970, 1, 1), Weekday::Thu);
231        // A few independently-known anchors.
232        assert_eq!(weekday_of(2000, 1, 1), Weekday::Sat);
233        assert_eq!(weekday_of(2020, 3, 8), Weekday::Sun); // US DST start 2020.
234    }
235
236    #[test]
237    fn days_from_civil_anchors() {
238        assert_eq!(days_from_civil(1970, 1, 1), 0);
239        assert_eq!(days_from_civil(1970, 1, 2), 1);
240        assert_eq!(days_from_civil(1969, 12, 31), -1);
241        // 2020-03-08 07:00 UTC == 1583643120 s; that is day 18329.
242        assert_eq!(days_from_civil(2020, 3, 8), 18329);
243    }
244
245    #[test]
246    fn last_sunday() {
247        // Last Sunday of March 2020 is the 29th.
248        let r = resolve_on_day(OnDay::Last(Weekday::Sun), 2020, 3).unwrap();
249        assert_eq!((r.month, r.day), (3, 29));
250    }
251
252    #[test]
253    fn sun_ge_8_and_le_25() {
254        // First Sunday on/after Mar 8 2020 is the 8th (it is a Sunday).
255        let a = resolve_on_day(OnDay::OnAfter(Weekday::Sun, 8), 2020, 3).unwrap();
256        assert_eq!((a.month, a.day), (3, 8));
257        // Last Sunday on/before Oct 25 2020 is the 25th (it is a Sunday).
258        let b = resolve_on_day(OnDay::OnBefore(Weekday::Sun, 25), 2020, 10).unwrap();
259        assert_eq!((b.month, b.day), (10, 25));
260    }
261
262    #[test]
263    fn spill_into_next_month() {
264        // Sun>=29 in April 2021: Apr has 30 days; the first Sunday on/after Apr 29 2021
265        // is May 2 (Apr 29 is Thu). Verifies normalisation across the boundary.
266        let r = resolve_on_day(OnDay::OnAfter(Weekday::Sun, 29), 2021, 4).unwrap();
267        assert_eq!((r.month, r.day), (5, 2));
268    }
269}