date: fix timezone abbreviations using wrong offset outside their DST season

This commit is contained in:
Alvaro Guimaraes
2026-02-21 16:28:56 +01:00
committed by Daniel Hofstetter
parent a972eee8ab
commit e4cc92ba46
2 changed files with 116 additions and 73 deletions
+59 -73
View File
@@ -10,7 +10,7 @@ mod locale;
use clap::{Arg, ArgAction, Command};
use jiff::fmt::strtime::{self, BrokenDownTime, Config, PosixCustom};
use jiff::tz::{TimeZone, TimeZoneDatabase};
use jiff::tz::{Offset, TimeZone, TimeZoneDatabase};
use jiff::{Timestamp, Zoned};
use std::borrow::Cow;
use std::collections::HashMap;
@@ -747,78 +747,61 @@ fn make_format_string(settings: &Settings) -> &str {
}
}
/// Minimal disambiguation rules for highly ambiguous timezone abbreviations.
/// Only includes cases where multiple major timezones share the same abbreviation.
/// All other abbreviations are discovered dynamically from the IANA database.
///
/// Disambiguation rationale (GNU compatible):
/// - CST: Central Standard Time (US) preferred over China/Cuba Standard Time
/// - EST: Eastern Standard Time (US) preferred over Australian Eastern Standard Time
/// - IST: India Standard Time preferred over Israel/Irish Standard Time
/// - MST: Mountain Standard Time (US) preferred over Malaysia Standard Time
/// - PST: Pacific Standard Time (US) - widely used abbreviation
/// - GMT: Alias for UTC (universal)
/// - Australian timezones: AWST, ACST, AEST (cannot be dynamically discovered)
///
/// All other timezones (JST, CET, etc.) are dynamically resolved from IANA database. // spell-checker:disable-line
static PREFERRED_TZ_MAPPINGS: &[(&str, &str)] = &[
// Universal (no ambiguity, but commonly used)
("UTC", "UTC"),
("GMT", "UTC"),
// Highly ambiguous US timezones (GNU compatible)
("PST", "America/Los_Angeles"),
("PDT", "America/Los_Angeles"),
("MST", "America/Denver"),
("MDT", "America/Denver"),
("CST", "America/Chicago"), // Ambiguous: US vs China vs Cuba
("CDT", "America/Chicago"),
("EST", "America/New_York"), // Ambiguous: US vs Australia
("EDT", "America/New_York"),
// Other highly ambiguous cases
/* spell-checker: disable */
("IST", "Asia/Kolkata"), // Ambiguous: India vs Israel vs Ireland
// Australian timezones (cannot be discovered from IANA location names)
("AWST", "Australia/Perth"), // Australian Western Standard Time
("ACST", "Australia/Adelaide"), // Australian Central Standard Time
("ACDT", "Australia/Adelaide"), // Australian Central Daylight Time
("AEST", "Australia/Sydney"), // Australian Eastern Standard Time
("AEDT", "Australia/Sydney"), // Australian Eastern Daylight Time
/* spell-checker: enable */
/// Timezone abbreviations with known fixed UTC offsets.
/// Checked first because the abbreviation encodes the exact offset
/// (e.g., EDT always means UTC-4, even in winter when New York observes EST).
/// Offset is in seconds to support half-hour zones like IST (UTC+5:30).
/// All other timezones (JST, CET, etc.) are dynamically resolved from IANA database.
/* spell-checker: disable */
static FIXED_OFFSET_ABBREVIATIONS: &[(&str, i32)] = &[
("UTC", 0),
("GMT", 0),
// US timezones (GNU compatible)
("PST", -28800), // UTC-8
("PDT", -25200), // UTC-7
("MST", -25200), // UTC-7
("MDT", -21600), // UTC-6
("CST", -21600), // UTC-6 (Ambiguous: US Central, not China/Cuba)
("CDT", -18000), // UTC-5
("EST", -18000), // UTC-5
("EDT", -14400), // UTC-4
// Indian Standard Time (Ambiguous: India vs Israel vs Ireland)
("IST", 19800), // UTC+5:30
// Australian timezones
("AWST", 28800), // UTC+8
("ACST", 34200), // UTC+9:30
("ACDT", 37800), // UTC+10:30
("AEST", 36000), // UTC+10
("AEDT", 39600), // UTC+11
// German timezones
("MEZ", 3600), // UTC+1
("MESZ", 7200), // UTC+2
];
/* spell-checker: enable */
/// Lazy-loaded timezone abbreviation lookup map built from IANA database.
static TZ_ABBREV_CACHE: OnceLock<HashMap<String, String>> = OnceLock::new();
/// Build timezone abbreviation lookup map from IANA database.
/// Uses preferred mappings for disambiguation, then searches all timezones.
/// This is a fallback for abbreviations not covered by FIXED_OFFSET_ABBREVIATIONS.
fn build_tz_abbrev_map() -> HashMap<String, String> {
let mut map = HashMap::new();
// First, add preferred mappings (these take precedence)
for (abbrev, iana) in PREFERRED_TZ_MAPPINGS {
map.insert((*abbrev).to_string(), (*iana).to_string());
}
// Then, try to find additional abbreviations from IANA database
// This gives us broader coverage while respecting disambiguation preferences
let tzdb = TimeZoneDatabase::from_env(); // spell-checker:disable-line
// spell-checker:disable-next-line
for tz_name in tzdb.available() {
let tz_str = tz_name.as_str();
// Skip if we already have a preferred mapping for this zone
if !map.values().any(|v| v == tz_str) {
// For zones without preferred mappings, use last component as potential abbreviation
// e.g., "Pacific/Fiji" could map to "FIJI"
if let Some(last_part) = tz_str.split('/').next_back() {
let potential_abbrev = last_part.to_uppercase();
// Only add if it looks like an abbreviation (2-5 uppercase chars)
if potential_abbrev.len() >= 2
&& potential_abbrev.len() <= 5
&& potential_abbrev.chars().all(|c| c.is_ascii_uppercase())
{
map.entry(potential_abbrev)
.or_insert_with(|| tz_str.to_string());
}
// Use last component as potential abbreviation
// e.g., "Pacific/Fiji" could map to "FIJI"
if let Some(last_part) = tz_str.split('/').next_back() {
let potential_abbrev = last_part.to_uppercase();
// Only add if it looks like an abbreviation (2-5 uppercase chars)
if potential_abbrev.len() >= 2
&& potential_abbrev.len() <= 5
&& potential_abbrev.chars().all(|c| c.is_ascii_uppercase())
{
map.entry(potential_abbrev)
.or_insert_with(|| tz_str.to_string());
}
}
}
@@ -849,19 +832,22 @@ fn try_parse_with_abbreviation<S: AsRef<str>>(date_str: S, now: &Zoned) -> Optio
&& last_word.len() <= 5
&& last_word.chars().all(|c| c.is_ascii_uppercase())
{
if let Some(iana_name) = tz_abbrev_to_iana(last_word) {
// Try to get the timezone
if let Ok(tz) = TimeZone::get(iana_name) {
// Parse the date part (everything before the TZ abbreviation)
let date_part = s.trim_end_matches(last_word).trim();
// Parse in the target timezone so "10:30 EDT" means 10:30 in EDT
if let Ok(parsed) =
parse_datetime::parse_datetime_at_date(now.clone(), date_part)
{
let dt = parsed.datetime();
if let Ok(zoned) = dt.to_zoned(tz) {
return Some(zoned);
}
let tz = if let Some(&(_, offset_secs)) = FIXED_OFFSET_ABBREVIATIONS
.iter()
.find(|(abbr, _)| *abbr == last_word)
{
Offset::from_seconds(offset_secs).ok().map(TimeZone::fixed)
} else {
tz_abbrev_to_iana(last_word).and_then(|name| TimeZone::get(name).ok())
};
if let Some(tz) = tz {
let date_part = s.trim_end_matches(last_word).trim();
// Parse in the target timezone so "10:30 EDT" means 10:30 in EDT
if let Ok(parsed) = parse_datetime::parse_datetime_at_date(now.clone(), date_part) {
let dt = parsed.datetime();
if let Ok(zoned) = dt.to_zoned(tz) {
return Some(zoned);
}
}
}
+57
View File
@@ -1118,6 +1118,63 @@ fn test_date_tz_abbreviation_dst_handling() {
.no_stderr();
}
#[test]
fn test_date_tz_abbreviation_fixed_offset_outside_season() {
// Abbreviations encode a fixed UTC offset regardless of the date.
// Using a DST abbreviation outside its season should still use the
// fixed offset the abbreviation implies, not the zone's current offset.
// EDT (UTC-4) used in winter (New York observes EST in January)
new_ucmd!()
.env("TZ", "UTC")
.arg("-u")
.arg("-d")
.arg("2026-01-15 10:00 EDT")
.arg("+%F %T %Z")
.succeeds()
.stdout_is("2026-01-15 14:00:00 UTC\n");
// PST (UTC-8) used in summer (Los Angeles observes PDT in June)
new_ucmd!()
.env("TZ", "UTC")
.arg("-u")
.arg("-d")
.arg("2026-06-15 10:00 PST")
.arg("+%F %T %Z")
.succeeds()
.stdout_is("2026-06-15 18:00:00 UTC\n");
// PDT (UTC-7) used in winter
new_ucmd!()
.env("TZ", "UTC")
.arg("-u")
.arg("-d")
.arg("2026-01-15 10:00 PDT")
.arg("+%F %T %Z")
.succeeds()
.stdout_is("2026-01-15 17:00:00 UTC\n");
// CDT (UTC-5) used in winter
new_ucmd!()
.env("TZ", "UTC")
.arg("-u")
.arg("-d")
.arg("2026-01-15 10:00 CDT")
.arg("+%F %T %Z")
.succeeds()
.stdout_is("2026-01-15 15:00:00 UTC\n");
// MDT (UTC-6) used in winter
new_ucmd!()
.env("TZ", "UTC")
.arg("-u")
.arg("-d")
.arg("2026-01-15 10:00 MDT")
.arg("+%F %T %Z")
.succeeds()
.stdout_is("2026-01-15 16:00:00 UTC\n");
}
#[test]
fn test_date_tz_abbreviation_with_day_of_week() {
// Test timezone abbreviations with full date format including day of week