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
44 changes: 44 additions & 0 deletions src/items/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -876,4 +876,48 @@ 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() {

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.

please add a few of these cases in tests/time.rs too, that's where we keep the gnu-compared expectations

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added 12 cases to tests/time.rs, expected values taken from GNU 9.11, plus +30 hours in test_time_invalid. Also rebased onto main and squashed to one commit (62a9b02): time_zone_correction is now separate from timezone_offset, which keeps its relative lookahead for UTC +8 years.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It's an error, like GNU's invalid date. Covered by a new case in test_time_invalid.

}
}
22 changes: 21 additions & 1 deletion 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 @@ -191,6 +191,26 @@ pub(super) fn parse_local(input: &mut &str) -> ModalResult<()> {
.parse_next(input)
}

/// Parse the zone correction that follows a time of day.
///
/// Unlike [`timezone_offset`], a relative unit after the number does not
/// disqualify it: GNU reads `12:00 +3 hours` as the zone `+03:00` plus a bare
/// `hours`. A fractional number is never a correction (`12:00 +1.5 seconds`).
pub(super) fn time_zone_correction(input: &mut &str) -> ModalResult<Offset> {
let has_fraction = peek((
plus_or_minus::<ErrMode<ContextError>>,
s(dec_uint_str),
'.',
one_of(AsChar::is_dec_digit),
))
.parse_next(input)
.is_ok();
if has_fraction {
return Err(ErrMode::Backtrack(ContextError::new()));
}
alt((timezone_offset_colon, timezone_offset_colonless)).parse_next(input)
}

/// Parse a timezone starting with `+` or `-`.
pub(super) fn timezone_offset(input: &mut &str) -> ModalResult<Offset> {
// Like GNU, read "+8 years" as a relative item, not as "+8" and "years".
Expand Down
4 changes: 2 additions & 2 deletions src/items/time.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ use winnow::{

use super::{
epoch::sec_and_nsec,
offset::{timezone_offset, Offset},
offset::{time_zone_correction, timezone_offset, Offset},
primitive::{colon, ctx_err, dec_uint, s},
};

Expand Down Expand Up @@ -99,7 +99,7 @@ pub(super) fn iso(input: &mut &str) -> ModalResult<Time> {
colon,
minute,
opt(preceded(colon, second)),
opt(timezone_offset),
opt(time_zone_correction),
)
.map(|(hour, _, minute, sec_nsec, offset)| Time {
hour,
Expand Down
21 changes: 21 additions & 0 deletions tests/time.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ fn test_time_correction_with_overflow(#[case] input: &str, #[case] expected: &st
#[case("10:59am+01")]
#[case("10:59+01pm")]
#[case("23:59:59+00:00:00")]
#[case("2026-08-27 12:00 +30 hours")]
fn test_time_invalid(#[case] input: &str) {
let result = parse_datetime::parse_datetime(input);
assert_eq!(
Expand Down Expand Up @@ -316,3 +317,23 @@ fn test_relative_fractional_seconds(#[case] input: &str, #[case] expected: &str)

check_time(input, expected, "%H:%M:%S.%N", Some(base));
}

// Expected values from GNU date 9.11 with TZ=UTC: a numeric offset right after
// a time of day is a zone correction, and the relative unit that follows is a
// bare unit, i.e. one of it.
#[rstest]
#[case::hours("2026-08-27 12:00 +3 hours", "2026-08-27 10:00:00")]
#[case::hours_glued("2026-08-27 12:00 +3hours", "2026-08-27 10:00:00")]
#[case::hhmm("2026-08-27 12:00 +0300 hours", "2026-08-27 10:00:00")]
#[case::hours_ago("2026-08-27 12:00 +3 hours ago", "2026-08-27 08:00:00")]
#[case::minutes("2026-08-27 12:00 +3 minutes", "2026-08-27 09:01:00")]
#[case::days("2026-08-27 12:00 +3 days", "2026-08-28 09:00:00")]
#[case::minus("2026-08-27 12:00 -3 hours", "2026-08-27 16:00:00")]
#[case::two_items("2026-08-27 12:00 +3 hours 5 minutes", "2026-08-27 10:05:00")]
#[case::named_zone_first("2026-08-27 12:00 utc +3 hours", "2026-08-27 15:00:00")]
#[case::bare_hour("2026-08-27 12 +3 hours", "2026-08-27 15:00:00")]
#[case::no_time_of_day("2026-08-27 +3 hours", "2026-08-27 03:00:00")]
#[case::fraction("2026-08-27 12:00 +1.5 seconds", "2026-08-27 12:00:01")]
fn test_time_correction_before_relative(#[case] input: &str, #[case] expected: &str) {
check_time(input, expected, "%Y-%m-%d %H:%M:%S", None);
}
Loading