diff --git a/src/items/builder.rs b/src/items/builder.rs index f0ec1b0..dc74cfe 100644 --- a/src/items/builder.rs +++ b/src/items/builder.rs @@ -264,13 +264,15 @@ impl DateTimeBuilder { return Ok(ts.to_zoned(base.offset().to_time_zone())); } - // 3. Determine whether to truncate the time of day. + // 3. Determine whether to truncate the time of day. Like GNU, a + // relative item on its own keeps the current time of day, while a date, + // a weekday, or a timezone item without any relative item resets it to + // midnight. + let zone_seen = self.offset.is_some() || self.local_zone || has_timezone; let need_midnight = self.date.is_some() || self.time.is_some() || self.weekday.is_some() - || self.offset.is_some() - || self.local_zone - || has_timezone; + || (zone_seen && self.relative.is_empty()); let mut dt = if need_midnight { base.with().time(civil::time(0, 0, 0, 0)).build()? @@ -401,12 +403,13 @@ impl DateTimeBuilder { }; let rule_tz = base.time_zone().clone(); + // See `build_in_range` for why a relative item alone keeps the + // current time of day. + let zone_seen = offset.is_some() || local_zone || has_timezone; let need_midnight = date.is_some() || time.is_some() || weekday.is_some() - || offset.is_some() - || local_zone - || has_timezone; + || (zone_seen && relative.is_empty()); let mut dt = ExtendedDateTime::new( DateParts { year: u32::try_from(base.year()).map_err(|_| "year must be non-negative")?, diff --git a/src/items/offset.rs b/src/items/offset.rs index d432f2e..5ccdc10 100644 --- a/src/items/offset.rs +++ b/src/items/offset.rs @@ -168,8 +168,7 @@ impl Display for Offset { } } -/// I'm assuming there are no timezone abbreviations with more -/// than 6 charactres +/// Timezone abbreviations are assumed to be no longer than 6 characters. const MAX_TZ_SIZE: usize = 6; pub(super) fn parse(input: &mut &str) -> ModalResult { diff --git a/tests/date.rs b/tests/date.rs index c7d2978..e410051 100644 --- a/tests/date.rs +++ b/tests/date.rs @@ -396,3 +396,41 @@ fn test_military_j_rejected(#[case] input: &str) { "`{input}` should be rejected, as GNU date does" ); } + +// A relative item keeps the base time of day, even when combined with a time +// zone item. Only a date, a weekday, or a lone zone item resets it to +// midnight: +// +// $ TZ=America/New_York date -d 'j 1 day' # tomorrow, current time of day +// $ TZ=America/New_York date -d 'utc 1 day' # ditto, in UTC +// $ TZ=America/New_York date -d 'j' # today at 00:00 +// $ TZ=America/New_York date -d 'j monday' # next monday at 00:00 +// +// Verified against GNU coreutils 9.7. +#[rstest] +#[case::local_zone_and_relative("j 1 day", "2026-08-30 11:30:45")] +#[case::relative_then_local_zone("5 minutes j", "2026-08-29 11:35:45")] +#[case::offset_and_relative("utc 1 day", "2026-08-30 11:30:45")] +#[case::relative_then_offset("5 minutes utc", "2026-08-29 11:35:45")] +#[case::timezone_rule_and_relative("TZ=\"Europe/Paris\" 1 day", "2026-08-30 17:30:45")] +// Without a relative item, a zone item still truncates to midnight. +#[case::local_zone_alone("j", "2026-08-29 00:00:00")] +#[case::local_zone_and_weekday("j monday", "2026-08-31 00:00:00")] +#[case::offset_alone("utc", "2026-08-29 00:00:00")] +fn test_zone_item_keeps_time_of_day_with_relative(#[case] input: &str, #[case] expected: &str) { + let base = "2026-08-29 11:30:45" + .parse::() + .unwrap() + .to_zoned(TimeZone::get("America/New_York").unwrap()) + .unwrap(); + + let parsed = parse_datetime::parse_datetime_at_date(base, input) + .unwrap() + .expect_in_range(); + + assert_eq!( + parsed.datetime().to_string(), + expected.parse::().unwrap().to_string(), + "`{input}` should resolve to {expected} in its own zone" + ); +}