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
43 changes: 43 additions & 0 deletions src/items/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -816,4 +816,47 @@ mod tests {
// duplicate check. GNU rejects it too.
assert!(parse(&mut "j +05:00").is_err());
}
/// A numeric offset after a time of day is a time zone correction, even
/// when a relative item follows it. GNU `date` reads
/// `2026-08-27 12:00 +3 hours` as the zone `+03:00` plus a bare `hours`
/// (which means one hour), i.e. 10:00 UTC.
#[test]
fn numeric_offset_before_relative_item() {
for (expected, input) in [
("2026-08-27 10:00:00", "2026-08-27 12:00 +3 hours"),
("2026-08-27 10:00:00", "2026-08-27 12:00 +3 hour"),
("2026-08-27 10:00:00", "2026-08-27 12:00 +3hours"),
("2026-08-27 10:00:00", "2026-08-27 12:00 + 3 hours"),
("2026-08-27 10:00:00", "2026-08-27 12:00 +03 hours"),
("2026-08-27 10:00:00", "2026-08-27 12:00 +0300 hours"),
("2026-08-27 10:00:00", "2026-08-27 12:00:00 +3 hours"),
("2026-08-27 10:05:00", "2026-08-27 12:00 +3 hours 5 minutes"),
("2026-08-27 08:00:00", "2026-08-27 12:00 +3 hours ago"),
("2026-08-28 09:00:00", "2026-08-27 12:00 +3 days"),
("2026-08-27 09:01:00", "2026-08-27 12:00 +3 minutes"),
("2026-08-27 16:00:00", "2026-08-27 12:00 -3 hours"),
("2026-08-27 00:00:00", "2026-08-27 12:00 +13 hours"),
// A named zone already fills the zone slot, so the numeric value
// stays a relative item.
("2026-08-27 15:00:00", "2026-08-27 12:00 utc +3 hours"),
// Without a time of day there is no zone slot to fill either.
("2026-08-27 03:00:00", "2026-08-27 +3 hours"),
// A fractional number is never a zone correction.
("2026-08-27 12:00:01", "2026-08-27 12:00 +1.5 seconds"),
] {
let parsed = parse(&mut &*input)
.map(at_utc)
.expect("parsing failed during tests")
.with_time_zone(TimeZone::UTC);
assert_eq!(
expected,
parsed.strftime("%Y-%m-%d %H:%M:%S").to_string(),
"{input}"
);
}

// `+30` is not a valid zone correction, and GNU rejects the whole
// input rather than falling back to a relative item.
assert!(parse(&mut "2026-08-27 12:00 +30 hours").is_err());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

out of range is a hard error now, so please check what date -d '12:00 +30 hours' prints, it should still look like gnu's invalid date

}
}
22 changes: 14 additions & 8 deletions src/items/offset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ use winnow::{
combinator::{alt, peek},
error::{ContextError, ErrMode},
stream::{AsChar, Stream},
token::take_while,
token::{one_of, take_while},
ModalResult, Parser,
};

Expand Down Expand Up @@ -193,12 +193,18 @@ pub(super) fn parse_local(input: &mut &str) -> ModalResult<()> {

/// Parse a timezone starting with `+` or `-`.
pub(super) fn timezone_offset(input: &mut &str) -> ModalResult<Offset> {
// Strings like "+8 years" are ambiguous, they can either be parsed as a
// timezone offset "+8" and a relative time "years", or just a relative time
// "+8 years". GNU date parses them the second way, so we do the same here.
//
// Return early if the input can be parsed as a relative time.
if peek(relative::parse).parse_next(input).is_ok() {
// A number with a fractional part is never a zone correction: GNU `date`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 lines of comment for this guard, could you please make it one or two?

// reads `12:00 +1.5 seconds` as the relative item `+1.5 seconds`, not as
// the offset `+01:00` followed by a stray `.5 seconds`. Backtrack so that
// the relative parser gets a chance at it.
let fraction: ModalResult<_> = peek((
plus_or_minus,
s(dec_uint_str),
'.',
one_of(AsChar::is_dec_digit),
))
.parse_next(input);
if fraction.is_ok() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fraction for a Result reads odd, has_fraction and inline the peek, no?

return Err(ErrMode::Backtrack(ContextError::new()));
}

Expand Down Expand Up @@ -433,7 +439,7 @@ mod tests {
"+2500", // invalid: hours > 24
"-2361", // invalid: minutes > 60
"+2401", // invalid: minutes > 0 when hours == 24
"+23 days", // invalid: ambiguous with relative time parsing
"+25 days", // invalid: hours > 24, even when followed by a relative item
] {
let mut s = input;
assert!(timezone_offset(&mut s).is_err(), "{input}");
Expand Down