Skip to content
Merged
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
17 changes: 10 additions & 7 deletions src/items/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()?
Expand Down Expand Up @@ -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")?,
Expand Down
3 changes: 1 addition & 2 deletions src/items/offset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Offset> {
Expand Down
38 changes: 38 additions & 0 deletions tests/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<DateTime>()
.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::<DateTime>().unwrap().to_string(),
"`{input}` should resolve to {expected} in its own zone"
);
}