diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index a8e465968..0c4e469d7 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -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> = 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 { 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>(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); } } } diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index 16c8727b8..760e214cc 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -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