mirror of
https://github.com/uutils/coreutils.git
synced 2026-06-10 15:48:22 -07:00
date: fix timezone abbreviations using wrong offset outside their DST season
This commit is contained in:
committed by
Daniel Hofstetter
parent
a972eee8ab
commit
e4cc92ba46
+59
-73
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user