From 84a67ece113b544e08585ba12ae6e22bdea51fb2 Mon Sep 17 00:00:00 2001 From: Jay Hemnani <193022578+jayhemnani9910@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:24:02 +0530 Subject: [PATCH 1/3] fix(offset): accept DST after a standard time zone name GNU date reads a standard time zone followed by the word DST as its daylight saving time zone, one hour ahead: "EST DST" is EDT. The module doc quotes that rule, but the parser rejected "2026-01-10 10:00 EST DST" as invalid. Add the hour when DST follows a zone that takes it; after a daylight saving time zone such as EDT, and after T, GNU rejects it, and so does this. --- src/items/offset.rs | 69 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/items/offset.rs b/src/items/offset.rs index e8afed5..ad210b1 100644 --- a/src/items/offset.rs +++ b/src/items/offset.rs @@ -215,6 +215,22 @@ fn timezone_name_offset(input: &mut &str) -> ModalResult { let nextword = s(take_while(1..=MAX_TZ_SIZE, AsChar::is_alpha)).parse_next(input)?; let tz = timezone_name_to_offset(nextword)?; + // A standard time zone followed by the word DST is its daylight saving + // time zone, one hour ahead: "EST DST" is EDT. + if takes_dst(nextword) { + let start = input.checkpoint(); + let word: ModalResult<&str> = + s(take_while(1..=MAX_TZ_SIZE, AsChar::is_alpha)).parse_next(input); + if word.is_ok_and(|word| word.eq_ignore_ascii_case("dst")) { + return Ok(tz.merge(Offset { + negative: false, + hours: 1, + minutes: 0, + })); + } + input.reset(&start); + } + // Like GNU, read "UTC +8 years" as "UTC" and "+8 years", not as "UTC+8" // and "years". `timezone_offset` rejects input that parses as relative. let start = input.checkpoint(); @@ -279,6 +295,31 @@ fn timezone_offset_colonless(input: &mut &str) -> ModalResult { }) } +/// Whether a named timezone can be followed by DST. GNU date rejects it after +/// a daylight saving time zone, and after `t`. +fn takes_dst(name: &str) -> bool { + !matches!( + name.to_ascii_lowercase().as_str(), + "t" | "west" + | "pdt" + | "nzdt" + | "ndt" + | "msd" + | "mesz" + | "mest" + | "mdt" + | "eest" + | "edt" + | "clst" + | "cest" + | "cdt" + | "bst" + | "brst" + | "akdt" + | "adt" + ) +} + /// Named timezone list. /// /// The full list of timezones can be extracted from @@ -511,6 +552,34 @@ mod tests { } } + #[test] + fn timezone_name_with_dst() { + for (input, expected) in [ + ("est dst", off(true, 4, 0)), // EST is UTC-5, so EST DST is UTC-4 + ("cet dst", off(false, 2, 0)), // more than one space + ("utc dst", off(false, 1, 0)), // UTC + ("ist dst", off(false, 6, 30)), // offset with minutes (ist=+5:30) + ("a dst", off(false, 2, 0)), // military letter (a=+1) + ] { + let mut s = input; + assert_eq!(timezone_name_offset(&mut s).unwrap(), expected, "{input}"); + assert!(s.is_empty(), "{input}"); + } + + for input in [ + "edt dst", // already a daylight saving time zone + "t dst", // rejected by GNU date as well + "estdst", // DST must be a separate word + "est dstx", + ] { + let mut s = input; + assert!( + timezone_name_offset(&mut s).is_err() || !s.is_empty(), + "{input}" + ); + } + } + #[test] fn offset_followed_by_relative() { // "+8 years" is a relative item, so it is not an offset. From a5cc38932063642aeda087134992ab907d4f3621 Mon Sep 17 00:00:00 2001 From: Jay Hemnani <193022578+jayhemnani9910@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:00:48 +0530 Subject: [PATCH 2/3] fix(offset): carry the DST flag in the time zone table Mark the zones that can be followed by DST in timezone_name_to_offset instead of keeping a second list of names, and compare the next word directly since the input is already lowercase. Test full dates with EST DST, CET DST and IST DST, and EDT DST as invalid. --- src/items/offset.rs | 195 ++++++++++++++++++++------------------------ tests/time.rs | 10 +++ 2 files changed, 98 insertions(+), 107 deletions(-) diff --git a/src/items/offset.rs b/src/items/offset.rs index ad210b1..c77ac08 100644 --- a/src/items/offset.rs +++ b/src/items/offset.rs @@ -213,15 +213,15 @@ pub(super) fn timezone_offset(input: &mut &str) -> ModalResult { /// Parse a timezone by name, with an optional numeric offset appended. fn timezone_name_offset(input: &mut &str) -> ModalResult { let nextword = s(take_while(1..=MAX_TZ_SIZE, AsChar::is_alpha)).parse_next(input)?; - let tz = timezone_name_to_offset(nextword)?; + let (tz, takes_dst) = timezone_name_to_offset(nextword)?; // A standard time zone followed by the word DST is its daylight saving // time zone, one hour ahead: "EST DST" is EDT. - if takes_dst(nextword) { + if takes_dst { let start = input.checkpoint(); let word: ModalResult<&str> = s(take_while(1..=MAX_TZ_SIZE, AsChar::is_alpha)).parse_next(input); - if word.is_ok_and(|word| word.eq_ignore_ascii_case("dst")) { + if word == Ok("dst") { return Ok(tz.merge(Offset { negative: false, hours: 1, @@ -295,118 +295,99 @@ fn timezone_offset_colonless(input: &mut &str) -> ModalResult { }) } -/// Whether a named timezone can be followed by DST. GNU date rejects it after -/// a daylight saving time zone, and after `t`. -fn takes_dst(name: &str) -> bool { - !matches!( - name.to_ascii_lowercase().as_str(), - "t" | "west" - | "pdt" - | "nzdt" - | "ndt" - | "msd" - | "mesz" - | "mest" - | "mdt" - | "eest" - | "edt" - | "clst" - | "cest" - | "cdt" - | "bst" - | "brst" - | "akdt" - | "adt" - ) -} - /// Named timezone list. /// /// The full list of timezones can be extracted from /// https://www.timeanddate.com/time/zones/. GNU date only supports a subset of /// these. We support the same subset as GNU date. -fn timezone_name_to_offset(input: &str) -> ModalResult { - let (negative, hours, minutes) = match input { - "z" => (false, 0, 0), - "y" => (true, 12, 0), - "x" => (true, 11, 0), - "wet" => (false, 0, 0), - "west" => (false, 1, 0), - "wat" => (false, 1, 0), - "w" => (true, 10, 0), - "v" => (true, 9, 0), - "utc" => (false, 0, 0), - "ut" => (false, 0, 0), - "u" => (true, 8, 0), - "t" => (true, 7, 0), - "sst" => (true, 12, 0), - "sgt" => (false, 8, 0), - "sast" => (false, 2, 0), - "s" => (true, 6, 0), - "r" => (true, 5, 0), - "q" => (true, 4, 0), - "pst" => (true, 8, 0), - "pdt" => (true, 7, 0), - "p" => (true, 3, 0), - "o" => (true, 2, 0), - "nzst" => (false, 12, 0), - "nzdt" => (false, 13, 0), - "nst" => (true, 3, 30), - "ndt" => (true, 2, 30), - "n" => (true, 1, 0), - "mst" => (true, 7, 0), - "msk" => (false, 3, 0), - "msd" => (false, 4, 0), - "mez" => (false, 1, 0), - "mesz" => (false, 2, 0), - "mest" => (false, 2, 0), - "mdt" => (true, 6, 0), - "m" => (false, 12, 0), - "l" => (false, 11, 0), - "kst" => (false, 9, 0), - "k" => (false, 10, 0), - "jst" => (false, 9, 0), - "ist" => (false, 5, 30), - "i" => (false, 9, 0), - "hst" => (true, 10, 0), - "h" => (false, 8, 0), - "gst" => (false, 10, 0), - "gmt" => (false, 0, 0), - "g" => (false, 7, 0), - "f" => (false, 6, 0), - "est" => (true, 5, 0), - "eet" => (false, 2, 0), - "eest" => (false, 3, 0), - "edt" => (true, 4, 0), - "eat" => (false, 3, 0), - "e" => (false, 5, 0), - "d" => (false, 4, 0), - "cst" => (true, 6, 0), - "clt" => (true, 4, 0), - "clst" => (true, 3, 0), - "cet" => (false, 1, 0), - "cest" => (false, 2, 0), - "cdt" => (true, 5, 0), - "cat" => (false, 2, 0), - "c" => (false, 3, 0), - "bst" => (false, 1, 0), - "brt" => (true, 3, 0), - "brst" => (true, 2, 0), - "b" => (false, 2, 0), - "ast" => (true, 4, 0), - "art" => (true, 3, 0), - "akst" => (true, 9, 0), - "akdt" => (true, 8, 0), - "adt" => (true, 3, 0), - "a" => (false, 1, 0), +/// +/// The flag says whether the zone can be followed by DST. GNU date rejects it +/// after a daylight saving time zone, and after `t`. +fn timezone_name_to_offset(input: &str) -> ModalResult<(Offset, bool)> { + let (negative, hours, minutes, takes_dst) = match input { + "z" => (false, 0, 0, true), + "y" => (true, 12, 0, true), + "x" => (true, 11, 0, true), + "wet" => (false, 0, 0, true), + "west" => (false, 1, 0, false), + "wat" => (false, 1, 0, true), + "w" => (true, 10, 0, true), + "v" => (true, 9, 0, true), + "utc" => (false, 0, 0, true), + "ut" => (false, 0, 0, true), + "u" => (true, 8, 0, true), + "t" => (true, 7, 0, false), + "sst" => (true, 12, 0, true), + "sgt" => (false, 8, 0, true), + "sast" => (false, 2, 0, true), + "s" => (true, 6, 0, true), + "r" => (true, 5, 0, true), + "q" => (true, 4, 0, true), + "pst" => (true, 8, 0, true), + "pdt" => (true, 7, 0, false), + "p" => (true, 3, 0, true), + "o" => (true, 2, 0, true), + "nzst" => (false, 12, 0, true), + "nzdt" => (false, 13, 0, false), + "nst" => (true, 3, 30, true), + "ndt" => (true, 2, 30, false), + "n" => (true, 1, 0, true), + "mst" => (true, 7, 0, true), + "msk" => (false, 3, 0, true), + "msd" => (false, 4, 0, false), + "mez" => (false, 1, 0, true), + "mesz" => (false, 2, 0, false), + "mest" => (false, 2, 0, false), + "mdt" => (true, 6, 0, false), + "m" => (false, 12, 0, true), + "l" => (false, 11, 0, true), + "kst" => (false, 9, 0, true), + "k" => (false, 10, 0, true), + "jst" => (false, 9, 0, true), + "ist" => (false, 5, 30, true), + "i" => (false, 9, 0, true), + "hst" => (true, 10, 0, true), + "h" => (false, 8, 0, true), + "gst" => (false, 10, 0, true), + "gmt" => (false, 0, 0, true), + "g" => (false, 7, 0, true), + "f" => (false, 6, 0, true), + "est" => (true, 5, 0, true), + "eet" => (false, 2, 0, true), + "eest" => (false, 3, 0, false), + "edt" => (true, 4, 0, false), + "eat" => (false, 3, 0, true), + "e" => (false, 5, 0, true), + "d" => (false, 4, 0, true), + "cst" => (true, 6, 0, true), + "clt" => (true, 4, 0, true), + "clst" => (true, 3, 0, false), + "cet" => (false, 1, 0, true), + "cest" => (false, 2, 0, false), + "cdt" => (true, 5, 0, false), + "cat" => (false, 2, 0, true), + "c" => (false, 3, 0, true), + "bst" => (false, 1, 0, false), + "brt" => (true, 3, 0, true), + "brst" => (true, 2, 0, false), + "b" => (false, 2, 0, true), + "ast" => (true, 4, 0, true), + "art" => (true, 3, 0, true), + "akst" => (true, 9, 0, true), + "akdt" => (true, 8, 0, false), + "adt" => (true, 3, 0, false), + "a" => (false, 1, 0, true), _ => return Err(ErrMode::Backtrack(ContextError::new())), }; - Ok(Offset { - negative, - hours, - minutes, - }) + Ok(( + Offset { + negative, + hours, + minutes, + }, + takes_dst, + )) } #[cfg(test)] diff --git a/tests/time.rs b/tests/time.rs index bd5aec7..e9b89ab 100644 --- a/tests/time.rs +++ b/tests/time.rs @@ -86,6 +86,15 @@ fn test_time_correction(#[case] input: &str, #[case] expected: &str) { check_time(input, expected, "%H:%M:%S%.9f", None); } +#[rstest] +#[case::est("2026-01-10 10:00 EST DST", "2026-01-10 14:00:00")] +#[case::lowercase("2026-01-10 10:00 est dst", "2026-01-10 14:00:00")] +#[case::cet("2026-01-10 10:00 CET DST", "2026-01-10 08:00:00")] +#[case::ist_with_minutes("2026-01-10 10:00 IST DST", "2026-01-10 03:30:00")] +fn test_time_zone_dst(#[case] input: &str, #[case] expected: &str) { + check_time(input, expected, "%Y-%m-%d %H:%M:%S", None); +} + #[rstest] #[case::plus_12("11:34:56+12:00", "2022-06-09 23:34:56")] #[case::minus_12("12:34:56-12:00", "2022-06-11 00:34:56")] @@ -121,6 +130,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-01-10 10:00 EDT DST")] fn test_time_invalid(#[case] input: &str) { let result = parse_datetime::parse_datetime(input); assert_eq!( From 888c6a67f7609560eb4059b3f4e26a4dbd40a3b4 Mon Sep 17 00:00:00 2001 From: Jay Hemnani <193022578+jayhemnani9910@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:10:11 +0530 Subject: [PATCH 3/3] test: check every named time zone against GNU date Parse each name in the time zone table, alone and followed by DST, and compare with GNU date 9.7, which rejects DST after the daylight saving time zones and T. --- tests/time.rs | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/tests/time.rs b/tests/time.rs index e9b89ab..d9b0828 100644 --- a/tests/time.rs +++ b/tests/time.rs @@ -95,6 +95,98 @@ fn test_time_zone_dst(#[case] input: &str, #[case] expected: &str) { check_time(input, expected, "%Y-%m-%d %H:%M:%S", None); } +#[test] +fn test_time_zone_names() { + // Every named time zone, alone and followed by DST, as GNU date 9.7 reads + // them. None means GNU date rejects DST after that zone. + for (name, expected, expected_dst) in [ + ("Z", "2026-01-10 10:00", Some("2026-01-10 09:00")), + ("Y", "2026-01-10 22:00", Some("2026-01-10 21:00")), + ("X", "2026-01-10 21:00", Some("2026-01-10 20:00")), + ("WET", "2026-01-10 10:00", Some("2026-01-10 09:00")), + ("WEST", "2026-01-10 09:00", None), + ("WAT", "2026-01-10 09:00", Some("2026-01-10 08:00")), + ("W", "2026-01-10 20:00", Some("2026-01-10 19:00")), + ("V", "2026-01-10 19:00", Some("2026-01-10 18:00")), + ("UTC", "2026-01-10 10:00", Some("2026-01-10 09:00")), + ("UT", "2026-01-10 10:00", Some("2026-01-10 09:00")), + ("U", "2026-01-10 18:00", Some("2026-01-10 17:00")), + ("T", "2026-01-10 17:00", None), + ("SST", "2026-01-10 22:00", Some("2026-01-10 21:00")), + ("SGT", "2026-01-10 02:00", Some("2026-01-10 01:00")), + ("SAST", "2026-01-10 08:00", Some("2026-01-10 07:00")), + ("S", "2026-01-10 16:00", Some("2026-01-10 15:00")), + ("R", "2026-01-10 15:00", Some("2026-01-10 14:00")), + ("Q", "2026-01-10 14:00", Some("2026-01-10 13:00")), + ("PST", "2026-01-10 18:00", Some("2026-01-10 17:00")), + ("PDT", "2026-01-10 17:00", None), + ("P", "2026-01-10 13:00", Some("2026-01-10 12:00")), + ("O", "2026-01-10 12:00", Some("2026-01-10 11:00")), + ("NZST", "2026-01-09 22:00", Some("2026-01-09 21:00")), + ("NZDT", "2026-01-09 21:00", None), + ("NST", "2026-01-10 13:30", Some("2026-01-10 12:30")), + ("NDT", "2026-01-10 12:30", None), + ("N", "2026-01-10 11:00", Some("2026-01-10 10:00")), + ("MST", "2026-01-10 17:00", Some("2026-01-10 16:00")), + ("MSK", "2026-01-10 07:00", Some("2026-01-10 06:00")), + ("MSD", "2026-01-10 06:00", None), + ("MEZ", "2026-01-10 09:00", Some("2026-01-10 08:00")), + ("MESZ", "2026-01-10 08:00", None), + ("MEST", "2026-01-10 08:00", None), + ("MDT", "2026-01-10 16:00", None), + ("M", "2026-01-09 22:00", Some("2026-01-09 21:00")), + ("L", "2026-01-09 23:00", Some("2026-01-09 22:00")), + ("KST", "2026-01-10 01:00", Some("2026-01-10 00:00")), + ("K", "2026-01-10 00:00", Some("2026-01-09 23:00")), + ("JST", "2026-01-10 01:00", Some("2026-01-10 00:00")), + ("IST", "2026-01-10 04:30", Some("2026-01-10 03:30")), + ("I", "2026-01-10 01:00", Some("2026-01-10 00:00")), + ("HST", "2026-01-10 20:00", Some("2026-01-10 19:00")), + ("H", "2026-01-10 02:00", Some("2026-01-10 01:00")), + ("GST", "2026-01-10 00:00", Some("2026-01-09 23:00")), + ("GMT", "2026-01-10 10:00", Some("2026-01-10 09:00")), + ("G", "2026-01-10 03:00", Some("2026-01-10 02:00")), + ("F", "2026-01-10 04:00", Some("2026-01-10 03:00")), + ("EST", "2026-01-10 15:00", Some("2026-01-10 14:00")), + ("EET", "2026-01-10 08:00", Some("2026-01-10 07:00")), + ("EEST", "2026-01-10 07:00", None), + ("EDT", "2026-01-10 14:00", None), + ("EAT", "2026-01-10 07:00", Some("2026-01-10 06:00")), + ("E", "2026-01-10 05:00", Some("2026-01-10 04:00")), + ("D", "2026-01-10 06:00", Some("2026-01-10 05:00")), + ("CST", "2026-01-10 16:00", Some("2026-01-10 15:00")), + ("CLT", "2026-01-10 14:00", Some("2026-01-10 13:00")), + ("CLST", "2026-01-10 13:00", None), + ("CET", "2026-01-10 09:00", Some("2026-01-10 08:00")), + ("CEST", "2026-01-10 08:00", None), + ("CDT", "2026-01-10 15:00", None), + ("CAT", "2026-01-10 08:00", Some("2026-01-10 07:00")), + ("C", "2026-01-10 07:00", Some("2026-01-10 06:00")), + ("BST", "2026-01-10 09:00", None), + ("BRT", "2026-01-10 13:00", Some("2026-01-10 12:00")), + ("BRST", "2026-01-10 12:00", None), + ("B", "2026-01-10 08:00", Some("2026-01-10 07:00")), + ("AST", "2026-01-10 14:00", Some("2026-01-10 13:00")), + ("ART", "2026-01-10 13:00", Some("2026-01-10 12:00")), + ("AKST", "2026-01-10 19:00", Some("2026-01-10 18:00")), + ("AKDT", "2026-01-10 18:00", None), + ("ADT", "2026-01-10 13:00", None), + ("A", "2026-01-10 09:00", Some("2026-01-10 08:00")), + ] { + let input = format!("2026-01-10 10:00 {name}"); + check_time(&input, expected, "%Y-%m-%d %H:%M", None); + + let input = format!("{input} DST"); + match expected_dst { + Some(expected) => check_time(&input, expected, "%Y-%m-%d %H:%M", None), + None => assert!( + parse_datetime::parse_datetime(&input).is_err(), + "Input string '{input}' did not produce an error when parsing" + ), + } + } +} + #[rstest] #[case::plus_12("11:34:56+12:00", "2022-06-09 23:34:56")] #[case::minus_12("12:34:56-12:00", "2022-06-11 00:34:56")]