Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions src/items/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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() {
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
45 changes: 45 additions & 0 deletions tests/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]"
);
}
Loading