diff --git a/src/items/offset.rs b/src/items/offset.rs index e8afed5..c77ac08 100644 --- a/src/items/offset.rs +++ b/src/items/offset.rs @@ -213,7 +213,23 @@ 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 { + let start = input.checkpoint(); + let word: ModalResult<&str> = + s(take_while(1..=MAX_TZ_SIZE, AsChar::is_alpha)).parse_next(input); + if word == Ok("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. @@ -284,88 +300,94 @@ fn timezone_offset_colonless(input: &mut &str) -> ModalResult { /// 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)] @@ -511,6 +533,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. diff --git a/tests/time.rs b/tests/time.rs index bd5aec7..d9b0828 100644 --- a/tests/time.rs +++ b/tests/time.rs @@ -86,6 +86,107 @@ 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); +} + +#[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")] @@ -121,6 +222,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!(