Skip to main content

graphforge_rel/
calendar.rs

1//! A proleptic-Gregorian calendar on `i64` days-since-epoch (#1011).
2//!
3//! chrono's `NaiveDate` caps near year ±262,143 and Arrow `Date32` is `i32` days
4//! (≈ year ±5.8M), but openCypher dates span years **−999,999,999 …
5//! +999,999,999**. This module does all DATE math on `i64` days since the Unix
6//! epoch (1970-01-01), so the full range round-trips. It deliberately covers only
7//! the calendar (year/month/day/ordinal/ISO-week/weekday + day-precision
8//! arithmetic + ISO rendering); time-of-day stays nanoseconds-of-day and named
9//! zones stay `chrono_tz` (both year-independent).
10//!
11//! The core conversions are Howard Hinnant's `days_from_civil` / `civil_from_days`
12//! (<http://howardhinnant.github.io/date_algorithms.html>), exact for any year in
13//! the proleptic Gregorian calendar (which has a year 0). Cross-checked against
14//! chrono for in-range years in the unit tests.
15#![allow(
16    clippy::cast_possible_truncation,
17    clippy::cast_sign_loss,
18    reason = "the civil-calendar algorithm casts between i64 day-counts and the \
19              bounded month/day/ordinal fields (month 1..=12, day 1..=31, ordinal \
20              1..=366); every such cast is provably in range, exhaustively verified \
21              against chrono + a 12M-iteration fuzz over the full year span"
22)]
23
24/// 1970-01-01 is a Thursday; ISO weekday 4 (Mon=1 … Sun=7).
25const EPOCH_ISO_WEEKDAY_OFFSET: i64 = 3;
26
27/// Days since 1970-01-01 for the civil date `(year, month, day)` in the proleptic
28/// Gregorian calendar. `month` is 1–12, `day` is 1–31 (not validated here — call
29/// [`ymd_to_days`] for the checked form). Exact for any `i64` year in range.
30#[must_use]
31pub fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
32    let m = i64::from(m);
33    let d = i64::from(d);
34    // Shift so the leap day is the last day of the (shifted) year.
35    let y = if m <= 2 { y - 1 } else { y };
36    let era = if y >= 0 { y } else { y - 399 } / 400;
37    let yoe = y - era * 400; // [0, 399]
38    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; // [0, 365]
39    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
40    era * 146_097 + doe - 719_468
41}
42
43/// The civil date `(year, month, day)` for a days-since-epoch count (the inverse
44/// of [`days_from_civil`]). `month` is 1–12, `day` is 1–31.
45#[must_use]
46pub fn civil_from_days(z: i64) -> (i64, u32, u32) {
47    let z = z + 719_468;
48    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
49    let doe = z - era * 146_097; // [0, 146096]
50    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399]
51    let y = yoe + era * 400;
52    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
53    let mp = (5 * doy + 2) / 153; // [0, 11]
54    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
55    let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; // [1, 12]
56    (if m <= 2 { y + 1 } else { y }, m, d)
57}
58
59/// Whether `year` is a leap year in the proleptic Gregorian calendar.
60#[must_use]
61pub fn is_leap(year: i64) -> bool {
62    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
63}
64
65/// Number of days in `month` (1–12) of `year` (28–31). `month` out of range → 0.
66#[must_use]
67pub fn days_in_month(year: i64, month: u32) -> u32 {
68    match month {
69        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
70        4 | 6 | 9 | 11 => 30,
71        2 => {
72            if is_leap(year) {
73                29
74            } else {
75                28
76            }
77        }
78        _ => 0,
79    }
80}
81
82/// Days in `year` (365 or 366).
83#[must_use]
84pub fn days_in_year(year: i64) -> u32 {
85    if is_leap(year) { 366 } else { 365 }
86}
87
88/// Validated civil → days: `None` for an out-of-range month/day (e.g. 2025-02-30).
89#[must_use]
90pub fn ymd_to_days(y: i64, m: u32, d: u32) -> Option<i64> {
91    if !(1..=12).contains(&m) || d < 1 || d > days_in_month(y, m) {
92        return None;
93    }
94    Some(days_from_civil(y, m, d))
95}
96
97/// Days for the `ordinal`-th day of `year` (1 = Jan 1). `None` if out of range.
98#[must_use]
99pub fn from_ordinal(year: i64, ordinal: u32) -> Option<i64> {
100    if ordinal < 1 || ordinal > days_in_year(year) {
101        return None;
102    }
103    Some(days_from_civil(year, 1, 1) + i64::from(ordinal) - 1)
104}
105
106/// The 1-based day-of-year (1–366) for a days count.
107#[must_use]
108pub fn ordinal(z: i64) -> u32 {
109    let (y, _, _) = civil_from_days(z);
110    (z - days_from_civil(y, 1, 1) + 1) as u32
111}
112
113/// ISO weekday, 1 = Monday … 7 = Sunday.
114#[must_use]
115pub fn iso_weekday(z: i64) -> u32 {
116    ((z + EPOCH_ISO_WEEKDAY_OFFSET).rem_euclid(7)) as u32 + 1
117}
118
119/// Days from the Monday of the date's week (0 = Monday … 6 = Sunday).
120#[must_use]
121pub fn num_days_from_monday(z: i64) -> u32 {
122    (z + EPOCH_ISO_WEEKDAY_OFFSET).rem_euclid(7) as u32
123}
124
125/// The number of ISO weeks in `iso_year` (52 or 53).
126#[must_use]
127pub fn iso_weeks_in_year(iso_year: i64) -> u32 {
128    // A year has 53 ISO weeks iff Jan 1 is a Thursday, or it is a leap year and
129    // Jan 1 is a Wednesday — equivalently, its last day (Dec 31) is Thu/Fri-ish.
130    // Compute via the weekday of Jan 1.
131    let jan1 = days_from_civil(iso_year, 1, 1);
132    let wd = iso_weekday(jan1); // 1..7
133    if wd == 4 || (wd == 3 && is_leap(iso_year)) {
134        53
135    } else {
136        52
137    }
138}
139
140/// The ISO-8601 week-based year and week number `(iso_year, week)` for a days
141/// count. The ISO week-year can differ from the calendar year near Jan 1 / Dec 31.
142#[must_use]
143pub fn iso_week(z: i64) -> (i64, u32) {
144    let (y, _, _) = civil_from_days(z);
145    let ord = i64::from(ordinal(z));
146    let wd = i64::from(iso_weekday(z));
147    // Provisional week within the calendar year.
148    let week = (ord - wd + 10) / 7;
149    if week < 1 {
150        // Belongs to the last week of the previous year.
151        (y - 1, iso_weeks_in_year(y - 1))
152    } else if week > i64::from(iso_weeks_in_year(y)) {
153        // Belongs to week 1 of the next year.
154        (y + 1, 1)
155    } else {
156        (y, week as u32)
157    }
158}
159
160/// Days for the ISO `(iso_year, week, weekday)` (weekday 1 = Mon … 7 = Sun).
161/// `None` if `week`/`weekday` is out of range for that ISO year.
162#[must_use]
163pub fn from_iso_ywd(iso_year: i64, week: u32, weekday: u32) -> Option<i64> {
164    if !(1..=7).contains(&weekday) || week < 1 || week > iso_weeks_in_year(iso_year) {
165        return None;
166    }
167    // Jan 4 is always in ISO week 1; find the Monday of week 1, then offset.
168    let jan4 = days_from_civil(iso_year, 1, 4);
169    let week1_monday = jan4 - (i64::from(iso_weekday(jan4)) - 1);
170    Some(week1_monday + i64::from(week - 1) * 7 + i64::from(weekday - 1))
171}
172
173/// Add a signed number of calendar `months` to `(y, m, d)`, clamping the day to
174/// the target month's length (e.g. Jan 31 + 1 month → Feb 28/29). Returns the
175/// resulting civil date.
176#[must_use]
177pub fn add_months(y: i64, m: u32, d: u32, months: i64) -> (i64, u32, u32) {
178    let total = y * 12 + i64::from(m - 1) + months;
179    let ny = total.div_euclid(12);
180    let nm = total.rem_euclid(12) as u32 + 1;
181    let nd = d.min(days_in_month(ny, nm));
182    (ny, nm, nd)
183}
184
185/// Add `months` calendar months to a days count (day-clamped); convenience over
186/// [`add_months`] for the days representation.
187#[must_use]
188pub fn add_months_to_days(z: i64, months: i64) -> i64 {
189    let (y, m, d) = civil_from_days(z);
190    let (ny, nm, nd) = add_months(y, m, d, months);
191    days_from_civil(ny, nm, nd)
192}
193
194/// The 1-based quarter (1–4) of `month` (1–12).
195#[must_use]
196pub fn quarter_of_month(month: u32) -> u32 {
197    (month - 1) / 3 + 1
198}
199
200/// Canonical openCypher ISO rendering of a date `(YYYY-MM-DD)`. Years 0000–9999
201/// are zero-padded to four digits with no sign; outside that range the ISO-8601
202/// expanded form applies — a leading `+` for years > 9999 and `-` for negative
203/// years (Neo4j: "a plus sign must prefix any year after 9999"). Matches chrono's
204/// `%Y` for the years chrono can render, and extends past chrono's ±262k cap.
205#[must_use]
206pub fn format_date(z: i64) -> String {
207    let (y, m, d) = civil_from_days(z);
208    format!("{}-{m:02}-{d:02}", format_year(y))
209}
210
211/// Year formatting shared by date / localdatetime / datetime rendering.
212#[must_use]
213pub fn format_year(y: i64) -> String {
214    if (0..=9999).contains(&y) {
215        format!("{y:04}")
216    } else if y < 0 {
217        // `{:04}` pads the magnitude to ≥4 digits; longer values are unaffected.
218        format!("-{:04}", -y)
219    } else {
220        // ISO-8601 expanded form: years > 9999 carry an explicit leading `+`.
221        format!("+{y}")
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use chrono::{Datelike, NaiveDate};
229
230    /// chrono's day-count for an in-range date, for cross-checking.
231    fn chrono_days(y: i32, m: u32, d: u32) -> i64 {
232        let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
233        (NaiveDate::from_ymd_opt(y, m, d).unwrap() - epoch).num_days()
234    }
235
236    #[test]
237    fn civil_roundtrip_and_matches_chrono_in_range() {
238        // Sample across the in-range era, including negatives and leap edges.
239        for &(y, m, d) in &[
240            (1970, 1, 1),
241            (2000, 2, 29),
242            (1984, 10, 11),
243            (1, 1, 1),
244            (0, 1, 1),
245            (-1, 12, 31),
246            (-44, 3, 15),
247            (9999, 12, 31),
248            (2017, 10, 29),
249        ] {
250            let z = days_from_civil(y, m, d);
251            assert_eq!(z, chrono_days(y as i32, m, d), "{y}-{m}-{d} vs chrono");
252            assert_eq!(civil_from_days(z), (y, m, d), "roundtrip {y}-{m}-{d}");
253        }
254    }
255
256    #[test]
257    fn extreme_years_roundtrip() {
258        for &(y, m, d) in &[(-999_999_999, 1, 1), (999_999_999, 12, 31)] {
259            let z = days_from_civil(y, m, d);
260            assert_eq!(civil_from_days(z), (y, m, d));
261        }
262        // The span used by Temporal10 [9]: between the two extremes is
263        // 1999999998y 11m 30d → the day-count difference must be exact.
264        let lo = days_from_civil(-999_999_999, 1, 1);
265        let hi = days_from_civil(999_999_999, 12, 31);
266        assert!(hi - lo > 0);
267    }
268
269    #[test]
270    fn weekday_ordinal_match_chrono() {
271        for &(y, m, d) in &[(1970, 1, 1), (2025, 6, 30), (1984, 1, 1), (-1, 6, 15)] {
272            let z = days_from_civil(y, m, d);
273            let nd = NaiveDate::from_ymd_opt(y as i32, m, d).unwrap();
274            assert_eq!(
275                iso_weekday(z),
276                nd.weekday().number_from_monday(),
277                "wd {y}-{m}-{d}"
278            );
279            assert_eq!(num_days_from_monday(z), nd.weekday().num_days_from_monday());
280            assert_eq!(ordinal(z), nd.ordinal(), "ordinal {y}-{m}-{d}");
281        }
282    }
283
284    #[test]
285    fn iso_week_matches_chrono() {
286        // Including the year-boundary cases ISO week is famous for.
287        for &(y, m, d) in &[
288            (1984, 1, 1),   // belongs to ISO week 52 of 1983
289            (2020, 12, 31), // ISO week 53 of 2020
290            (2021, 1, 1),   // still 2020-W53
291            (2025, 6, 30),
292            (2016, 1, 1),
293        ] {
294            let z = days_from_civil(y, m, d);
295            let nd = NaiveDate::from_ymd_opt(y as i32, m, d).unwrap();
296            let iso = nd.iso_week();
297            assert_eq!(
298                iso_week(z),
299                (i64::from(iso.year()), iso.week()),
300                "iso_week {y}-{m}-{d}"
301            );
302        }
303    }
304
305    #[test]
306    fn from_iso_ywd_inverts_iso_week() {
307        for &(y, m, d) in &[(1984, 1, 1), (2020, 12, 31), (2025, 6, 30)] {
308            let z = days_from_civil(y, m, d);
309            let (iy, w) = iso_week(z);
310            let wd = iso_weekday(z);
311            assert_eq!(from_iso_ywd(iy, w, wd), Some(z), "ywd inverse {y}-{m}-{d}");
312        }
313    }
314
315    #[test]
316    fn add_months_clamps_day() {
317        assert_eq!(add_months(2025, 1, 31, 1), (2025, 2, 28)); // Jan 31 +1mo → Feb 28
318        assert_eq!(add_months(2024, 1, 31, 1), (2024, 2, 29)); // leap
319        assert_eq!(add_months(2025, 3, 15, -1), (2025, 2, 15));
320        assert_eq!(add_months(2025, 12, 10, 1), (2026, 1, 10)); // year carry
321        assert_eq!(add_months(2025, 1, 10, -1), (2024, 12, 10)); // year borrow
322    }
323
324    #[test]
325    fn ymd_validation() {
326        assert_eq!(ymd_to_days(2025, 2, 30), None);
327        assert_eq!(ymd_to_days(2024, 2, 29), Some(days_from_civil(2024, 2, 29)));
328        assert_eq!(ymd_to_days(2025, 13, 1), None);
329    }
330
331    #[test]
332    fn format_date_matches_chrono_in_range_and_signs_extremes() {
333        for &(y, m, d) in &[(2025, 6, 30), (1, 1, 1), (985, 7, 4), (-1, 12, 31)] {
334            let z = days_from_civil(y, m, d);
335            let chrono = NaiveDate::from_ymd_opt(y as i32, m, d)
336                .unwrap()
337                .format("%Y-%m-%d")
338                .to_string();
339            assert_eq!(format_date(z), chrono, "format {y}-{m}-{d} vs chrono");
340        }
341        // Extreme / >9999 years use the ISO-8601 expanded form: '-' for negative,
342        // and a mandatory leading '+' for years after 9999 (Neo4j semantics).
343        assert_eq!(
344            format_date(days_from_civil(-999_999_999, 1, 1)),
345            "-999999999-01-01"
346        );
347        assert_eq!(
348            format_date(days_from_civil(999_999_999, 12, 31)),
349            "+999999999-12-31"
350        );
351        assert_eq!(format_year(10000), "+10000");
352        assert_eq!(format_year(0), "0000");
353    }
354}