mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 02:36:48 -06:00
Use chrono instead of hand-rolled UTC conversion (#2121)
* Use chrono for UTC/calendar conversion instead of hand-rolled copies Replaces the hand-rolled civil_from_days / days_from_civil copies and the is_leap / days_in_month calendar helpers with chrono as a workspace dependency (default-features = false, std only, so wasm is unaffected). The spreadsheet serial-date semantics are untouched: Excel's 1900 fictitious leap day (serial 60 = 1900-02-29), the 1899-12-30 / 1904-01-01 serial offsets, Excel's weekday numbering and the 1900/1904 systems keep their explicit special cases in serial_from_ymd / datetime_from_serial / days_in_month_in. chrono only backs the plain proleptic-Gregorian day-number <-> date conversion and the leap-year / month-length helpers those build on; a small fallback keeps the old answer for dates outside chrono's NaiveDate range. The duplicate local is_leap in the financial functions now uses the core one. Output is byte-identical and pinned by the existing date, parse, financial and logging tests. AGENTS.md now says time goes through chrono. Follow-up to storytold/cadcraft#1785 * Never panic on out-of-range clocks or years; keep calendar results outside chrono's range - logging::timestamp: chrono's From<SystemTime> unwraps, so a clock past year 262143 panicked inside the logger. Use the fallible DateTime::from_timestamp and read such a clock as the epoch, like the old saturating code. Years past 9999 are written with {:04} (not %Y, which prefixes a '+'). - date::is_leap: restore the Gregorian leap-year rule for years outside chrono's NaiveDate range (e.g. is_leap(400000) is true again) via direct arithmetic. - date::days_in_month: compute the December boundary with checked_add so days_in_month(i32::MAX, 12) no longer overflows, and compute month lengths through days_from_civil's fallback so out-of-range years keep their real lengths (e.g. days_in_month(400000, 2) is 29). Invalid months still return 0. - civil_from_days: add missing docstring. - AGENTS.md: note the narrow out-of-range fallback exception to the chrono rule. * Don't build an unrepresentable SystemTime in the far-future test UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows' SystemTime and panicked before timestamp() ran, failing the Windows test job. Build it with checked_add and only assert where it exists. * Route out-of-range dates through chrono's 400-year cycle, not hand-rolled copies The PR kept hand-rolled Hinnant `days_from_civil_fallback`/`civil_from_days_fallback` copies for dates outside chrono's ±262143-year range, plus a hand-rolled Gregorian leap rule in `is_leap` and an i32::MAX special-case in `days_in_month`. AGENTS.md carved a "narrow fallback" exception for them, which left calendar arithmetic outside chrono after all. Replace all of it with the Gregorian 400-year cycle (146_097 days): chrono converts the day/year within one cycle and whole cycles are added back via i128 (so the i32/i64 extremes can't overflow). `is_leap` and `days_in_month` reduce the year with `rem_euclid(400)` (leap years and month lengths repeat every 400 years), which also removes the December `i32::MAX` overflow special-case. chrono is now the only calendar logic, and every output is unchanged: verified the cycle result equals the old formula over wide ranges and at the i32/i64 extremes with a scratch chrono program. The Excel 1900 leap bug (serial 60 = fictitious 1900-02-29) and 1904 semantics are untouched -- `serial_from_ymd`/ `datetime_from_serial` only ever hand the converters in-range years, so their serials are byte-for-byte the pre-chrono ones (pinned by a new test). New tests pin `days_from_civil`/`civil_from_days` past chrono's range on both sides of the epoch, round-tripping, with values from the old formula; the existing leap/month-length-beyond-range test still passes unchanged. Removed the AGENTS.md "narrow fallback" exception. Tested: cargo test -p gridcraft-core -p gridcraft-functions; fmt --check; clippy --all-targets -D warnings. * time: preserve out-of-range years in the log timestamp instead of the epoch `DateTime::from_timestamp(..).unwrap_or_default()` changed the existing output: a clock set far ahead (an input the old hand-rolled civil-from-days converted) collapsed to `1970-01-01`. Restore the old conversion domain with a cycle-based helper: reduce by whole 400-year Gregorian cycles (146_097 days), convert the in-range remainder with chrono, and carry the year back as i128. Only the year leaves chrono's range; month/day/time stay exact. Pre-1970 still clamps to the epoch as before. chrono stays the only calendar logic (no new features). apps/gridcraft logging.rs `timestamp` (SystemTime, u64 secs), pinned via a platform-safe `UNIX_EPOCH.checked_add`: u64::MAX/2 s → "292277026596-12-04T15:30:07.000Z" (was "1970-01-01..."), 10e12 s → "318857-05-20T17:46:40.000Z", 253_402_300_800 s → "10000-01-01...". * Name the 400-year range extension as the one exception to the time rule The rule said never to hand-roll calendar math, but the range fix (shifting by whole 400-year Gregorian cycles so dates past chrono's year range keep their old output) is cycle arithmetic, so the rule and the code disagreed. The rule now names that exception: the library still computes month, day and time, and only whole cycles are carried in the year. * Say the library computes month and day; time-of-day is plain arithmetic Narrows the range-extension wording: the date library computes the calendar fields, and splitting seconds into hours, minutes and seconds is ordinary arithmetic, which is what the code does.
This commit is contained in:
@@ -11,6 +11,7 @@ workspace = true
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
js-sys = "0.3"
|
||||
|
||||
+111
-24
@@ -28,15 +28,22 @@ pub struct DateTime {
|
||||
pub weekday: u32,
|
||||
}
|
||||
|
||||
/// Days from 0000-03-01 style civil algorithm (Howard Hinnant), days since 1970-01-01.
|
||||
/// Days since 1970-01-01 for a proleptic Gregorian date, exact for every `i32` year.
|
||||
///
|
||||
/// `NaiveDate` only covers about ±262143 years, but the Gregorian calendar repeats every 400
|
||||
/// years (146_097 days): chrono converts the year within its cycle and whole cycles are added
|
||||
/// back as days (an i128 so the i32 extremes can't overflow). 1970-01-01 is `num_days_from_ce`
|
||||
/// 719_163; the result matches the proleptic Gregorian count the old hand-rolled code gave.
|
||||
pub fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
|
||||
let y = if m <= 2 { y as i64 - 1 } else { y as i64 };
|
||||
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||
let yoe = y - era * 400;
|
||||
let m = m as i64;
|
||||
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d as i64 - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146097 + doe - 719468
|
||||
let (cycles, year) = (i128::from(i64::from(y).div_euclid(400)), i64::from(y).rem_euclid(400));
|
||||
i32::try_from(year)
|
||||
.ok()
|
||||
.and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, d))
|
||||
.and_then(|date| {
|
||||
let days = i128::from(chrono::Datelike::num_days_from_ce(&date)) - 719_163 + cycles * 146_097;
|
||||
i64::try_from(days).ok()
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Milliseconds since the Unix epoch (UTC) from the system clock; `None` when the clock is unavailable.
|
||||
@@ -67,32 +74,50 @@ pub fn serial_in_system(sys: DateSystem, serial_1900: f64) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Proleptic Gregorian `(year, month, day)` for a day count `z` since 1970-01-01 (the inverse of
|
||||
/// [`days_from_civil`]).
|
||||
///
|
||||
/// The calendar repeats every 400 years (146_097 days): chrono converts the day within its cycle
|
||||
/// and whole cycles go back into the year (an i128 so no `z` overflows), which matches the
|
||||
/// proleptic Gregorian answer the old hand-rolled code gave past `NaiveDate`'s ±262143 range.
|
||||
/// 1970-01-01 is `num_days_from_ce` 719_163.
|
||||
pub fn civil_from_days(z: i64) -> (i32, u32, u32) {
|
||||
let z = z + 719468;
|
||||
let era = if z >= 0 { z } else { z - 146096 } / 146097;
|
||||
let doe = z - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
((if m <= 2 { y + 1 } else { y }) as i32, m, d)
|
||||
use chrono::Datelike;
|
||||
let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097));
|
||||
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||
Some(d) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i32::try_from(y).unwrap_or(if y < 0 { i32::MIN } else { i32::MAX }), d.month(), d.day())
|
||||
}
|
||||
// Unreachable: `rest + 719_163` is always a valid CE day.
|
||||
None => (1970, 1, 1),
|
||||
}
|
||||
}
|
||||
|
||||
const UNIX_1899_12_30: i64 = -25569; // days_from_civil(1899, 12, 30)
|
||||
const UNIX_1904_01_01: i64 = -24107;
|
||||
|
||||
/// Whether `y` is a Gregorian leap year, for every `i32` year.
|
||||
pub fn is_leap(y: i32) -> bool {
|
||||
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
|
||||
// A year is a leap year iff 29 February exists in it. Leap years repeat every 400 years, so
|
||||
// the year within its Gregorian cycle (`rem_euclid(400)`) decides it and always fits chrono.
|
||||
let y = i64::from(y).rem_euclid(400) as i32;
|
||||
chrono::NaiveDate::from_ymd_opt(y, 2, 29).is_some()
|
||||
}
|
||||
|
||||
/// Number of days in month `m` of year `y` (1–31), or `0` for an invalid month number, for every
|
||||
/// `i32` year.
|
||||
pub fn days_in_month(y: i32, m: u32) -> u32 {
|
||||
match m {
|
||||
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
2 if is_leap(y) => 29,
|
||||
2 => 28,
|
||||
// The length of a month is the day before the first of the next month. Month lengths repeat
|
||||
// every 400 years, so the year within its Gregorian cycle (`rem_euclid(400)`) is enough and
|
||||
// always fits chrono (so December can't overflow `y + 1`). An invalid month keeps the old `0`.
|
||||
if !(1..=12).contains(&m) {
|
||||
return 0;
|
||||
}
|
||||
let y = i64::from(y).rem_euclid(400) as i32;
|
||||
let first = |y: i32, m: u32| chrono::NaiveDate::from_ymd_opt(y, m, 1);
|
||||
match (first(y, m), if m == 12 { first(y + 1, 1) } else { first(y, m + 1) }) {
|
||||
(Some(a), Some(b)) => u32::try_from((b - a).num_days()).unwrap_or(0),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
@@ -196,6 +221,68 @@ pub const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Th
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn leap_and_month_length_outside_chrono_range() {
|
||||
// In range, the ordinary Gregorian rule holds.
|
||||
assert!(is_leap(2000));
|
||||
assert!(!is_leap(1900));
|
||||
assert!(is_leap(2024));
|
||||
assert!(!is_leap(2023));
|
||||
// Outside chrono's NaiveDate range the Gregorian rule is still applied.
|
||||
assert!(is_leap(400_000)); // divisible by 400
|
||||
assert!(!is_leap(300_100)); // century not divisible by 400
|
||||
assert!(is_leap(i32::MAX - 3)); // 2147483644 is divisible by 4, not a century
|
||||
assert!(!is_leap(i32::MAX)); // 2147483647 is odd
|
||||
// days_in_month keeps calendar lengths out of range and 0 for invalid months.
|
||||
assert_eq!(days_in_month(400_000, 2), 29);
|
||||
assert_eq!(days_in_month(300_100, 2), 28);
|
||||
assert_eq!(days_in_month(400_000, 1), 31);
|
||||
assert_eq!(days_in_month(400_000, 4), 30);
|
||||
assert_eq!(days_in_month(2024, 2), 29);
|
||||
assert_eq!(days_in_month(2023, 2), 28);
|
||||
assert_eq!(days_in_month(2023, 0), 0);
|
||||
assert_eq!(days_in_month(2023, 13), 0);
|
||||
// December at i32::MAX does not overflow and still reports 31 days.
|
||||
assert_eq!(days_in_month(i32::MAX, 12), 31);
|
||||
assert_eq!(days_in_month(i32::MAX, 2), 28); // 2147483647 is not a leap year
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn civil_conversions_exact_beyond_chrono_range() {
|
||||
assert_eq!(days_from_civil(1970, 1, 1), 0);
|
||||
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
||||
// Past `NaiveDate`'s ±262143 the 400-year cycles carry the result, exactly, both sides of
|
||||
// the epoch; values round-trip through the proleptic Gregorian converters.
|
||||
for (y, m, d, z) in [
|
||||
(262_144i32, 1u32, 1u32, 95_026_602i64),
|
||||
(300_000, 1, 1, 108_853_222),
|
||||
(400_000, 2, 29, 145_377_531),
|
||||
(-262_145, 3, 1, -96_465_964),
|
||||
(-300_000, 6, 15, -110_292_112),
|
||||
] {
|
||||
assert_eq!(days_from_civil(y, m, d), z, "{y}-{m}-{d}");
|
||||
assert_eq!(civil_from_days(z), (y, m, d), "{z}");
|
||||
}
|
||||
// The extremes must not overflow or panic (i128 intermediates).
|
||||
let _ = (days_from_civil(i32::MAX, 12, 31), days_from_civil(i32::MIN, 1, 1), civil_from_days(i64::MAX), civil_from_days(i64::MIN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serials_unchanged_through_cycle_conversion() {
|
||||
// The 1900 leap bug and 1904 semantics must stay exact; the converters only ever see
|
||||
// in-range years here, so the serial outputs are byte-for-byte the pre-chrono ones.
|
||||
let s = DateSystem::D1900;
|
||||
assert_eq!(serial_from_ymd(s, 1900, 2, 29), Some(60.0)); // Excel's fictitious leap day
|
||||
assert_eq!(serial_from_ymd(s, 1900, 3, 1), Some(61.0));
|
||||
assert_eq!(serial_from_ymd(s, 9999, 12, 31), Some(2958465.0));
|
||||
assert_eq!(days_in_month_in(s, 1900, 2), 29);
|
||||
let s4 = DateSystem::D1904;
|
||||
assert_eq!(serial_from_ymd(s4, 1904, 1, 1), Some(0.0)); // serial 0 is 1904-01-01
|
||||
assert_eq!(serial_from_ymd(s4, 1904, 2, 29), Some(59.0)); // 1904 is a real leap year
|
||||
assert_eq!(serial_from_ymd(s4, 1900, 2, 29), None); // no fictitious day in 1904 system
|
||||
assert_eq!(days_in_month_in(s4, 1900, 2), 28);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serial_from_unix_time() {
|
||||
assert_eq!(serial_from_unix_ms(0.0), 25569.0);
|
||||
|
||||
Reference in New Issue
Block a user