use chrono_tz::Tz;
use std::{
collections::HashSet,
sync::{Mutex, OnceLock},
};
use strum::IntoEnumIterator;
use windows_timezones::WindowsTimezone;
pub(crate) enum Tzid {
Iana(String), FixedOffset(i32), Unknown,
}
pub(crate) fn classify(tzid: &str) -> Tzid {
if tzid.parse::<Tz>().is_ok() {
return Tzid::Iana(tzid.to_string());
}
if let Some(windows_timezone) = windows_timezone(tzid) {
return Tzid::Iana(Tz::from(windows_timezone).name().to_string());
}
if let Some(seconds) = parse_fixed_offset(tzid) {
return offset_tzid(seconds);
}
if let Some(rest) = tzid.strip_prefix('/') {
let segments: Vec<&str> = rest.split('/').collect();
for start in 0..segments.len() {
match classify(&segments[start..].join("/")) {
Tzid::Unknown => continue,
resolved => return resolved,
}
}
}
Tzid::Unknown
}
fn windows_timezone(tzid: &str) -> Option<WindowsTimezone> {
tzid.parse().ok().or_else(|| {
WindowsTimezone::iter().find(|windows_timezone| windows_timezone.description() == tzid)
})
}
fn offset_tzid(seconds: i32) -> Tzid {
if seconds % 3600 == 0 {
let hours = seconds / 3600;
let iana = if hours == 0 {
"Etc/GMT".to_string()
} else {
format!("Etc/GMT{:+}", -hours)
};
if iana.parse::<Tz>().is_ok() {
return Tzid::Iana(iana);
}
}
Tzid::FixedOffset(seconds)
}
pub fn normalize(tzid: String) -> String {
match classify(&tzid) {
Tzid::Iana(iana) => iana,
Tzid::FixedOffset(_) => tzid,
Tzid::Unknown => {
warn_unknown(&tzid);
tzid
}
}
}
fn parse_fixed_offset(tzid: &str) -> Option<i32> {
let rest = tzid
.strip_prefix("GMT")
.or_else(|| tzid.strip_prefix("UTC"))?;
let (sign, digits) = match rest.as_bytes().first()? {
b'+' => (1, &rest[1..]),
b'-' => (-1, &rest[1..]),
_ => return None,
};
if !digits.is_ascii() {
return None;
}
let (hours, minutes) = if let Some((hours, minutes)) = digits.split_once(':') {
if !(1..=2).contains(&hours.len()) || minutes.len() != 2 {
return None;
}
(parse_digits(hours)?, parse_digits(minutes)?)
} else {
match digits.len() {
1 | 2 => (parse_digits(digits)?, 0),
4 => (parse_digits(&digits[..2])?, parse_digits(&digits[2..])?),
_ => return None,
}
};
if hours > 23 || minutes > 59 {
return None;
}
Some(sign * (hours * 3600 + minutes * 60))
}
fn parse_digits(value: &str) -> Option<i32> {
value
.bytes()
.all(|byte| byte.is_ascii_digit())
.then(|| value.parse().ok())?
}
pub(crate) fn warn_unknown(tzid: &str) {
static WARNED: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
let mut warned = WARNED
.get_or_init(|| Mutex::new(HashSet::new()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if warned.insert(tzid.to_string()) {
eprintln!("warning: unknown TZID `{tzid}`; treating it as floating local time");
}
}
pub fn from_iana(iana: &str) -> Option<&'static str> {
let tz: Tz = iana.parse().ok()?;
let windows_timezone = WindowsTimezone::try_from(tz).ok()?;
Some(windows_timezone.name())
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn normalize_matrix() {
for (input, expected) in [
("Europe/Stockholm", "Europe/Stockholm"),
("UTC", "UTC"),
("GMT", "GMT"),
("CET", "CET"),
("EST", "EST"),
("US/Pacific", "US/Pacific"),
("W. Europe Standard Time", "Europe/Berlin"),
("GMT+0100", "Etc/GMT-1"),
("GMT+01:00", "Etc/GMT-1"),
("UTC+0100", "Etc/GMT-1"),
("GMT+1", "Etc/GMT-1"),
("GMT-0500", "Etc/GMT+5"),
("GMT+0000", "Etc/GMT"),
("GMT+1300", "Etc/GMT-13"),
("GMT+0530", "GMT+0530"),
("GMT-1300", "GMT-1300"),
("/mozilla.org/20070129_1/Europe/Berlin", "Europe/Berlin"),
(
"/freeassociation.sourceforge.net/America/New_York",
"America/New_York",
),
("/citadel.org/20211207_1/UTC", "UTC"),
(
"(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
"Europe/Berlin",
),
("(UTC-05:00) Eastern Time (US & Canada)", "America/New_York"),
("(UTC) Coordinated Universal Time", "Etc/UTC"),
(
"(UTC+05:30) Chennai, Kolkata, Mumbai, New Delhi",
"Asia/Calcutta",
),
("GMT+9900", "GMT+9900"),
("GMT+", "GMT+"),
("GMT+01:0x", "GMT+01:0x"),
("GMT+éé", "GMT+éé"),
("/not/a/zone", "/not/a/zone"),
("/", "/"),
("(no offset here) Some Place", "(no offset here) Some Place"),
("(UTC+01:00) Unknown Place", "(UTC+01:00) Unknown Place"),
("PST", "PST"),
("AEST", "AEST"),
("tzone://Microsoft/Custom", "tzone://Microsoft/Custom"),
("", ""),
] {
assert_eq!(normalize(input.to_string()), expected, "{input}");
}
}
#[test]
fn from_iana_maps_known_iana_to_windows() {
assert_eq!(
from_iana("America/Sao_Paulo"),
Some("E. South America Standard Time")
);
assert_eq!(
from_iana("America/Los_Angeles"),
Some("Pacific Standard Time")
);
assert_eq!(from_iana("Europe/Berlin"), Some("W. Europe Standard Time"));
assert_eq!(from_iana("Europe/London"), Some("GMT Standard Time"));
}
#[test]
fn from_iana_maps_fixed_offset_to_a_sane_windows_zone() {
assert_eq!(
from_iana("Etc/GMT-1"),
Some("W. Central Africa Standard Time")
);
}
#[test]
fn windows_then_iana_round_trips_for_canonical_pairs() {
for original in [
"E. South America Standard Time",
"Pacific Standard Time",
"W. Europe Standard Time",
"GMT Standard Time",
"Tokyo Standard Time",
"AUS Eastern Standard Time",
] {
let iana = normalize(original.to_string());
assert_eq!(
from_iana(&iana),
Some(original),
"round-trip lost identity for {original}"
);
}
}
}