Skip to content
Open
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
60 changes: 51 additions & 9 deletions src/items/timezone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

use jiff::tz::{Offset, TimeZone};
use winnow::{
combinator::{alt, delimited, opt, preceded, repeat},
combinator::{alt, delimited, opt, peek, preceded, repeat, terminated},
stream::AsChar,
token::{one_of, take_while},
ModalResult, Parser,
Expand All @@ -25,13 +25,18 @@ use winnow::{
use super::primitive::{dec_uint, plus_or_minus};

pub(super) fn parse(input: &mut &str) -> ModalResult<TimeZone> {
delimited("TZ=\"", preceded(opt(':'), alt((posix, iana))), '"').parse_next(input)
delimited(
"TZ=\"",
preceded(opt(':'), alt((terminated(posix, peek('"')), iana))),
'"',
)
.parse_next(input)
}

/// Parse a posix (proleptic) timezone string (e.g., "UTC7", "JST-9").
///
/// TODO: This implementation is incomplete. It currently only parses the
/// `STDOFFSET` part of the format.
/// This only parses the `STDOFFSET` part of the format. A rule with a `DST`
/// part or a quoted `<...>` name is left to [`iana`], which hands it to jiff.
///
/// From the GNU docs:
///
Expand Down Expand Up @@ -59,7 +64,10 @@ fn posix(input: &mut &str) -> ModalResult<TimeZone> {
}

/// Parse an IANA (geographical) timezone string (e.g., "Europe/Paris"). If the
/// string is not a valid IANA timezone name, the UTC timezone is returned.
/// string is not a valid IANA timezone name, it is read as a full posix rule
/// (e.g., "CET-1CEST,M3.5.0,M10.5.0/3", "<+03>-3"). If that fails too, the UTC
/// timezone is returned, unless the string starts like a posix rule (e.g.,
/// "EST5EDT,"), which is an error.
///
/// Compatibility notes:
///
Expand Down Expand Up @@ -90,7 +98,14 @@ fn iana(input: &mut &str) -> ModalResult<TimeZone> {
)),
)
.map(|parts: Vec<String>| parts.concat())
.map(|s| TimeZone::get(&s).unwrap_or(TimeZone::UTC))
.verify_map(|s| {
TimeZone::get(&s)
.or_else(|_| TimeZone::posix(&s))
// Like glibc, use the US rules for a DST name without a rule (e.g., "EST5EDT")
.or_else(|_| TimeZone::posix(&format!("{s},M3.2.0,M11.1.0")))
.ok()
.or_else(|| posix(&mut s.as_str()).is_err().then_some(TimeZone::UTC))
})
.parse_next(input)
}

Expand Down Expand Up @@ -126,6 +141,7 @@ fn posix_offset(input: &mut &str) -> ModalResult<i32> {
#[cfg(test)]
mod tests {
use super::*;
use jiff::Timestamp;

#[test]
fn tz_rule() {
Expand Down Expand Up @@ -188,11 +204,37 @@ mod tests {
);
}

// posix with a dst part or a quoted name: (input, offset in July, offset in January)
let july: Timestamp = "2026-07-01T12:00:00Z".parse().unwrap();
let january: Timestamp = "2026-01-01T12:00:00Z".parse().unwrap();
for (input, summer, winter) in [
(r#"TZ="EST5EDT,M3.2.0,M11.1.0""#, -4 * 3600, -5 * 3600),
(r#"TZ="EST5EDT""#, -4 * 3600, -5 * 3600),
(r#"TZ="XYZ+5ABC""#, -4 * 3600, -5 * 3600),
(r#"TZ="CET-1CEST,M3.5.0,M10.5.0/3""#, 2 * 3600, 3600),
(r#"TZ="NZST-12NZDT,M9.5.0,M4.1.0/3""#, 12 * 3600, 13 * 3600),
(r#"TZ="<+03>-3""#, 3 * 3600, 3 * 3600),
(r#"TZ="<-05>5""#, -5 * 3600, -5 * 3600),
(
r#"TZ="<+0530>-5:30""#,
5 * 3600 + 30 * 60,
5 * 3600 + 30 * 60,
),
] {
let mut s = input;
let tz = parse(&mut s).unwrap();
assert_eq!(tz.to_offset(july).seconds(), summer, "{input}");
assert_eq!(tz.to_offset(january).seconds(), winter, "{input}");
}

// invalid
for input in [
r#"UTC"#, // missing "TZ="
r#"tz="UTC""#, // lowercase "tz"
r#"TZ=UTC"#, // missing quotes
r#"UTC"#, // missing "TZ="
r#"tz="UTC""#, // lowercase "tz"
r#"TZ=UTC"#, // missing quotes
r#"TZ="EST5EDT,""#, // empty dst rule
r#"TZ="EST5EDT,M3.2.0""#, // dst rule without an end
r#"TZ="CET-1CEST,M3.5.0,M99""#, // bad dst rule
] {
let mut s = input;
assert!(parse(&mut s).is_err(), "{input}");
Expand Down
Loading