From 0d9240f5ec30744b595920afaa58396767cc7b3e Mon Sep 17 00:00:00 2001 From: Jay Hemnani <193022578+jayhemnani9910@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:40:12 +0530 Subject: [PATCH] fix(builder): handle wall-clock times in a DST gap like GNU date A date or time that falls in the local zone's DST gap was moved past the gap by jiff (2024-03-10 02:30 in New York became 03:30). GNU date rejects it as an invalid date, so do the same. A time written with its own zone ("2024-03-10 02:30 UTC", "...T02:30:00Z") was built in the local zone first and so came out an hour late; apply the zone to the wall-clock time as written. A relative item that lands in a gap still moves past it, as in GNU. --- src/items/builder.rs | 26 +++++++++++++++++++++++-- tests/date.rs | 45 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/items/builder.rs b/src/items/builder.rs index 7782674..69f74ea 100644 --- a/src/items/builder.rs +++ b/src/items/builder.rs @@ -279,7 +279,11 @@ impl DateTimeBuilder { || self.weekday.is_some() || (zone_seen && self.relative.is_empty()); + // The wall-clock time the input asks for, before the time zone + // resolves it: GNU rejects one that falls in a DST gap. + let mut wanted = base.datetime(); let mut dt = if need_midnight { + wanted = wanted.date().to_datetime(civil::time(0, 0, 0, 0)); base.with().time(civil::time(0, 0, 0, 0)).build()? } else { base @@ -294,19 +298,29 @@ impl DateTimeBuilder { .map_err(|_| "base year must be non-negative")?; date.with_year(base_year).try_into()? }; + wanted = d.to_datetime(wanted.time()); dt = dt.with().date(d).build()?; } // 4b. Apply time. + let mut fixed_offset = self.offset.is_some(); if let Some(time) = self.time.clone() { if let Some(offset) = &time.offset { dt = dt.datetime().to_zoned(offset.try_into()?)?; + fixed_offset = true; } let t: civil::Time = time.try_into()?; + wanted = wanted.date().to_datetime(t); dt = dt.with().time(t).build()?; } + // A date or time that names a moment the time zone skips does not + // exist; jiff would move it past the gap. + if need_midnight && !fixed_offset && dt.datetime() != wanted { + return Err("invalid date: the local time does not exist in this time zone".into()); + } + // 4c. Apply weekday. if let Some(weekday::Weekday { mut offset, day }) = self.weekday { if self.time.is_none() { @@ -347,6 +361,7 @@ impl DateTimeBuilder { + offset.checked_mul(7).ok_or("multiplication overflow")?; dt = dt.checked_add(Span::new().try_days(delta)?)?; + wanted = wanted.checked_add(Span::new().try_days(delta)?)?; } // 4d. Apply fixed offset. This anchors the instant before relative @@ -355,8 +370,15 @@ impl DateTimeBuilder { // across a DST boundary (see issue uutils/coreutils#12555). if let Some(offset) = self.offset { let (offset, hour_adjustment) = offset.normalize(); - dt = dt.checked_add(Span::new().hours(hour_adjustment))?; - dt = dt.datetime().to_zoned((&offset).try_into()?)?; + if need_midnight { + // The zone applies to the wall-clock time as written; going + // through the local zone would move a time in its DST gap. + let wall = wanted.checked_add(Span::new().hours(hour_adjustment))?; + dt = wall.to_zoned((&offset).try_into()?)?; + } else { + dt = dt.checked_add(Span::new().hours(hour_adjustment))?; + dt = dt.datetime().to_zoned((&offset).try_into()?)?; + } } // 4e. Apply relative adjustments. diff --git a/tests/date.rs b/tests/date.rs index 6d81d4c..23e2e3f 100644 --- a/tests/date.rs +++ b/tests/date.rs @@ -492,3 +492,48 @@ fn test_weekday_ignored_with_explicit_date(#[case] input: &str, #[case] expected "`{input}` should resolve to {expected}" ); } + +// Wall-clock times in a DST gap (New York skips 02:00-03:00 on 2024-03-10, +// Sao Paulo skipped midnight on 2018-11-04). Verified against GNU date 9.7. +#[rstest] +#[case::time_in_gap(r#"TZ="America/New_York" 2024-03-10 02:30"#)] +#[case::gap_start(r#"TZ="America/New_York" 2024-03-10 02:00"#)] +#[case::midnight_in_gap(r#"TZ="America/Sao_Paulo" 2018-11-04"#)] +fn test_local_time_in_dst_gap_is_invalid(#[case] input: &str) { + assert!(parse_datetime::parse_datetime(input).is_err(), "{input}"); +} + +#[rstest] +// A time with its own zone is not moved by the local zone's gap. +#[case::utc( + r#"TZ="America/New_York" 2024-03-10 02:30 UTC"#, + "2024-03-10 02:30:00+00:00" +)] +#[case::z( + r#"TZ="America/New_York" 2024-03-10T02:30:00Z"#, + "2024-03-10 02:30:00+00:00" +)] +// Next to the gap, and in the repeated hour, local times still work. +#[case::after_gap( + r#"TZ="America/New_York" 2024-03-10 03:00"#, + "2024-03-10 03:00:00-04:00" +)] +#[case::fold( + r#"TZ="America/New_York" 2024-11-03 01:30"#, + "2024-11-03 01:30:00-04:00" +)] +fn test_dst_gap_neighbours(#[case] input: &str, #[case] expected: &str) { + check_absolute(input, expected); +} + +#[test] +fn test_relative_item_moves_past_dst_gap() { + // GNU moves a relative result that lands in the gap forward. + let parsed = parse_datetime::parse_datetime(r#"TZ="America/Sao_Paulo" 2018-11-03 +1 day"#) + .unwrap() + .expect_in_range(); + assert_eq!( + parsed.to_string(), + "2018-11-04T01:00:00-02:00[America/Sao_Paulo]" + ); +}