From d930a0d48017d3b18835299295443dea6b9daf6b Mon Sep 17 00:00:00 2001 From: Manish Goregaokar Date: Sat, 31 Oct 2026 18:21:27 +0000 Subject: [PATCH] Fix POSIX MWD week-of-month and last-weekday normalization Fixes three off-by-one/boundary errors in MwdForTime: - Subtract 1 before dividing day_of_month by 7 so days 7, 14, 21, and 28 stay in their current week block instead of jumping to the next week. - Check days_in_month > 28 rather than day_of_month > 28 when populating extra_days so week-5 (last-weekday) POSIX rules are not prematurely clamped to week 4 on days 1..=28. - Use day_29 <= last_day in normalize_mwd so 29-day leap-year Februaries (where day_29 == last_day) properly normalize week 5 to week 4 for the other six weekdays. Fixes #759. --- crates/provider/src/common.rs | 50 ++++++++++++++++- .../builtins/core/zoned_date_time/tests.rs | 56 +++++++++++++++++++ 2 files changed, 103 insertions(+), 3 deletions(-) diff --git a/crates/provider/src/common.rs b/crates/provider/src/common.rs index dad2d4e2d..e06aa1c2e 100644 --- a/crates/provider/src/common.rs +++ b/crates/provider/src/common.rs @@ -250,14 +250,14 @@ impl MwdForTime { #[cfg(any(feature = "tzif", feature = "experimental_tzif"))] pub(crate) fn from_seconds(seconds: i64) -> Self { let (year, month, day_of_month) = utils::ymd_from_epoch_milliseconds(seconds * 1_000); - let week_of_month = day_of_month / 7 + 1; + let week_of_month = (day_of_month - 1) / 7 + 1; let day_of_week = utils::epoch_seconds_to_day_of_week(seconds); let mut mwd = Mwd::from_u8(month, week_of_month, day_of_week); let days_in_month = utils::iso_days_in_month(year, month); let day_of_week_zeroth_day = (i16::from(day_of_week) - i16::from(day_of_month)).rem_euclid(7) as u8; mwd.normalize_to_week_number(day_of_week_zeroth_day); - if day_of_month > 28 { + if days_in_month > 28 { let day_of_week_day_29 = (day_of_week_zeroth_day + 29).rem_euclid(7); let day_of_week_last_day = (day_of_week_zeroth_day + days_in_month).rem_euclid(7); Self { @@ -291,7 +291,7 @@ impl MwdForTime { // the last $weekday in the month, we need special handling if other.week == 5 { if let Some((day_29, last_day)) = self.extra_days { - if day_29 < last_day { + if day_29 <= last_day { if other.day < day_29 || other.day > last_day { // This day isn't found in the last week. Subtract one. other.week = 4; @@ -330,3 +330,47 @@ pub(crate) enum TransitionKind { // The offsets changed in a way that produces overlapping time. Overlap, } + +#[cfg(all(test, any(feature = "tzif", feature = "experimental_tzif")))] +mod tests { + use super::{Mwd, MwdForTime}; + use crate::utils; + + #[test] + fn mwd_ordering_matches_calendar_days_across_solar_cycle() { + // A 28-year solar cycle (2000..=2027) covers all 7 starting weekdays for + // 28-, 29-, 30-, and 31-day months (including leap-year Februaries). + for year in 2000..=2027 { + for month in 1..=12u8 { + let days_in_month = utils::iso_days_in_month(year, month); + let first_day_secs = utils::epoch_days_from_gregorian_date(year, month, 1) * 86_400; + let first_dow = utils::epoch_seconds_to_day_of_week(first_day_secs); + + for rule_week in 1..=5u8 { + for rule_day in 0..=6u8 { + let first_occurrence = 1 + (rule_day + 7 - first_dow) % 7; + let mut transition_dom = first_occurrence + (rule_week - 1) * 7; + if transition_dom > days_in_month { + transition_dom -= 7; + } + + for dom in 1..=days_in_month { + let secs = utils::epoch_days_from_gregorian_date(year, month, dom) + * 86_400 + + 43_200; + let mwd = MwdForTime::from_seconds(secs); + let mut rule = Mwd::from_u8(month, rule_week, rule_day); + mwd.normalize_mwd(&mut rule); + + assert_eq!( + mwd.mwd.cmp(&rule), + dom.cmp(&transition_dom), + "mismatch for {year}-{month:02}-{dom:02} vs M{month}.{rule_week}.{rule_day} (transition day {transition_dom})" + ); + } + } + } + } + } + } +} diff --git a/crates/temporal/src/builtins/core/zoned_date_time/tests.rs b/crates/temporal/src/builtins/core/zoned_date_time/tests.rs index 8d0fcc8cf..3edd7e1ec 100644 --- a/crates/temporal/src/builtins/core/zoned_date_time/tests.rs +++ b/crates/temporal/src/builtins/core/zoned_date_time/tests.rs @@ -1441,3 +1441,59 @@ fn test_same_date_starts_twice() { } }) } + +#[test] +fn issue_759_posix_dst_near_transitions() { + use crate::PlainDateTime; + + test_all_providers!(provider: { + // Repro 1: America/New_York on 2026-03-07 (7th of the month; DST starts March 8). + let ny = TimeZone::try_from_str_with_provider("America/New_York", provider).unwrap(); + let dt_ny = PlainDateTime::try_new_iso(2026, 3, 7, 12, 0, 0, 0, 0, 0).unwrap(); + let zdt_ny = dt_ny + .to_zoned_date_time_with_provider(ny, Disambiguation::Compatible, provider) + .unwrap(); + assert_eq!( + zdt_ny.to_string_with_provider(provider).unwrap(), + "2026-03-07T12:00:00-05:00[America/New_York]" + ); + + let parsed_ny = parse_zdt_with_reject( + "2026-03-07T12:00:00-05:00[America/New_York]", + provider, + ) + .unwrap(); + assert_eq!(parsed_ny.epoch_nanoseconds(), zdt_ny.epoch_nanoseconds()); + + // Check another multiple of 7 (28th) in the week before fall-back (2025-11-02). + let dt_ny_fall = PlainDateTime::try_new_iso(2025, 10, 28, 12, 0, 0, 0, 0, 0).unwrap(); + let zdt_ny_fall = dt_ny_fall + .to_zoned_date_time_with_provider(ny, Disambiguation::Compatible, provider) + .unwrap(); + assert_eq!( + zdt_ny_fall.to_string_with_provider(provider).unwrap(), + "2025-10-28T12:00:00-04:00[America/New_York]" + ); + + // Repro 2: Europe/London in March 2026 (5 Sundays; BST starts March 29). + let london = TimeZone::try_from_str_with_provider("Europe/London", provider).unwrap(); + for day in 22..=28 { + let dt = PlainDateTime::try_new_iso(2026, 3, day, 12, 0, 0, 0, 0, 0).unwrap(); + let zdt = dt + .to_zoned_date_time_with_provider(london, Disambiguation::Compatible, provider) + .unwrap(); + assert_eq!( + zdt.to_string_with_provider(provider).unwrap(), + format!("2026-03-{day:02}T12:00:00+00:00[Europe/London]") + ); + } + let dt_bst = PlainDateTime::try_new_iso(2026, 3, 29, 12, 0, 0, 0, 0, 0).unwrap(); + let zdt_bst = dt_bst + .to_zoned_date_time_with_provider(london, Disambiguation::Compatible, provider) + .unwrap(); + assert_eq!( + zdt_bst.to_string_with_provider(provider).unwrap(), + "2026-03-29T12:00:00+01:00[Europe/London]" + ); + }) +}