From 81b15f0e5201c6b8fa03d7a684794db3bb86e2a1 Mon Sep 17 00:00:00 2001 From: Jake Abendroth Date: Mon, 23 Feb 2026 01:47:49 -0800 Subject: [PATCH 1/4] date: Fix %x, %X, %r to respect locale settings - Add locale.rs functions to query nl_langinfo for D_FMT, T_FMT, T_FMT_AMPM - Add expand_locale_specifiers() to replace %x, %X, %r with locale formats - Integrate locale specifier expansion before jiff formatting - Un-ignore and extend locale tests with graceful degradation --- src/uu/date/src/date.rs | 33 ++++++++++++++- src/uu/date/src/locale.rs | 86 ++++++++++++++++++++++++++++++++++++++ tests/by-util/test_date.rs | 60 ++++++++++++++++++++++---- 3 files changed, 170 insertions(+), 9 deletions(-) diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index 0c4e469d77b..435939c7752 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -699,14 +699,45 @@ pub fn uu_app() -> Command { .arg(Arg::new(OPT_FORMAT).num_args(0..)) } +/// Expand `%x`, `%X`, `%r` into locale format strings from `nl_langinfo` +/// before jiff sees them. `%%` is protected so `%%x` is not expanded. +fn expand_locale_specifiers(format: &str) -> Cow<'_, str> { + if !format.contains("%x") && !format.contains("%X") && !format.contains("%r") { + return Cow::Borrowed(format); + } + + const PLACEHOLDER: &str = "\x00PCT\x00"; + let mut s = format.replace("%%", PLACEHOLDER); + + if s.contains("%x") { + if let Some(date_fmt) = locale::get_locale_date_format() { + s = s.replace("%x", &date_fmt); + } + } + if s.contains("%X") { + if let Some(time_fmt) = locale::get_locale_time_format() { + s = s.replace("%X", &time_fmt); + } + } + if s.contains("%r") { + if let Some(ampm) = locale::get_locale_time_ampm_format() { + s = s.replace("%r", &m); + } + } + + Cow::Owned(s.replace(PLACEHOLDER, "%%")) +} + fn format_date_with_locale_aware_months( date: &Zoned, format_string: &str, config: &Config, skip_localization: bool, ) -> Result { + let format_string = expand_locale_specifiers(format_string); + let format_string = &*format_string; + // First check if format string has GNU modifiers (width/flags) and format if present - // This optimization combines detection and formatting in a single pass if let Some(result) = format_modifiers::format_with_modifiers_if_present(date, format_string, config) { diff --git a/src/uu/date/src/locale.rs b/src/uu/date/src/locale.rs index 086b35cee58..e064fd5e70b 100644 --- a/src/uu/date/src/locale.rs +++ b/src/uu/date/src/locale.rs @@ -39,6 +39,13 @@ cfg_langinfo! { const DATE_FMT: libc::nl_item = 0x2006c; #[cfg(not(target_os = "linux"))] const DATE_FMT: libc::nl_item = libc::D_T_FMT; + + /// `D_FMT` — locale date format (used by `%x`) + const D_FMT_ITEM: libc::nl_item = libc::D_FMT; + /// `T_FMT` — locale time format (used by `%X`) + const T_FMT_ITEM: libc::nl_item = libc::T_FMT; + /// `T_FMT_AMPM` — locale 12-hour time format (used by `%r`) + const T_FMT_AMPM_ITEM: libc::nl_item = libc::T_FMT_AMPM; } cfg_langinfo! { @@ -117,6 +124,46 @@ cfg_langinfo! { } } +cfg_langinfo! { + fn query_nl_langinfo(item: libc::nl_item) -> Option { + #[cfg(test)] + let _lock = LOCALE_MUTEX.lock().unwrap(); + + unsafe { + libc::setlocale(libc::LC_TIME, c"".as_ptr()); + + let ptr = libc::nl_langinfo(item); + if ptr.is_null() { + return None; + } + + let s = CStr::from_ptr(ptr).to_str().ok()?; + if s.is_empty() { + return None; + } + + Some(s.to_string()) + } + } + + /// Returns the locale date format (`D_FMT`) used by `%x`. + pub fn get_locale_date_format() -> Option { + query_nl_langinfo(D_FMT_ITEM) + } + + /// Returns the locale time format (`T_FMT`) used by `%X`. + pub fn get_locale_time_format() -> Option { + query_nl_langinfo(T_FMT_ITEM) + } + + /// Returns the locale 12-hour time format (`T_FMT_AMPM`) used by `%r`. + /// Falls back to `%I:%M:%S %p` if the locale leaves it empty. + pub fn get_locale_time_ampm_format() -> Option { + query_nl_langinfo(T_FMT_AMPM_ITEM) + .or_else(|| Some("%I:%M:%S %p".to_string())) + } +} + /// On platforms without nl_langinfo support, use 24-hour format by default #[cfg(not(any( target_os = "linux", @@ -130,6 +177,45 @@ pub fn get_locale_default_format() -> &'static str { "%a %b %e %X %Z %Y" } +/// Fallback for platforms without `nl_langinfo`. +#[cfg(not(any( + target_os = "linux", + target_vendor = "apple", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd", + target_os = "dragonfly" +)))] +pub fn get_locale_date_format() -> Option { + None +} + +/// Fallback for platforms without `nl_langinfo`. +#[cfg(not(any( + target_os = "linux", + target_vendor = "apple", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd", + target_os = "dragonfly" +)))] +pub fn get_locale_time_format() -> Option { + None +} + +/// Fallback for platforms without `nl_langinfo`. +#[cfg(not(any( + target_os = "linux", + target_vendor = "apple", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd", + target_os = "dragonfly" +)))] +pub fn get_locale_time_ampm_format() -> Option { + None +} + #[cfg(test)] mod tests { cfg_langinfo! { diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index 760e214cc84..77ffec9d0e3 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -1760,13 +1760,10 @@ fn test_date_french_full_sentence() { } } -/// Test that %x format specifier respects locale settings -/// This is a regression test for locale-aware date formatting +/// Test that %x respects locale settings (regression for locale-aware date formatting) #[test] -#[ignore = "https://bugs.launchpad.net/ubuntu/+source/rust-coreutils/+bug/2137410"] #[cfg(any(target_os = "linux", target_vendor = "apple"))] fn test_date_format_x_locale_aware() { - // With C locale, %x should output MM/DD/YY (US format) new_ucmd!() .env("TZ", "UTC") .env("LC_ALL", "C") @@ -1776,16 +1773,63 @@ fn test_date_format_x_locale_aware() { .succeeds() .stdout_is("01/19/97\n"); - // With French locale, %x should output DD/MM/YYYY (European format) - // GNU date outputs: 19/01/1997 - new_ucmd!() + // French locale: day comes first if locale is available. + // If fr_FR.UTF-8 is not installed, falls back to C and we skip the assertion. + let result = new_ucmd!() .env("TZ", "UTC") .env("LC_ALL", "fr_FR.UTF-8") .arg("-d") .arg("1997-01-19 08:17:48") .arg("+%x") + .succeeds(); + + let output = result.stdout_str().trim(); + // If French locale is available, day (19) comes first; otherwise falls back to C (01 first) + if output.starts_with("19") { + // French locale working + assert!( + output.contains("01") && output.contains("1997"), + "French %%x should contain month 01 and year 1997, got: {output}" + ); + } + // else: locale not installed, skipping French-specific checks +} + +/// Test that %X respects locale settings +#[test] +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +fn test_date_format_big_x_locale_aware() { + new_ucmd!() + .env("TZ", "UTC") + .env("LC_ALL", "C") + .arg("-d") + .arg("1997-01-19 08:17:48") + .arg("+%X") + .succeeds() + .stdout_is("08:17:48\n"); + + new_ucmd!() + .env("TZ", "UTC") + .env("LC_ALL", "fr_FR.UTF-8") + .arg("-d") + .arg("1997-01-19 08:17:48") + .arg("+%X") + .succeeds() + .stdout_is("08:17:48\n"); +} + +/// Test that %r respects locale settings +#[test] +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +fn test_date_format_r_locale_aware() { + new_ucmd!() + .env("TZ", "UTC") + .env("LC_ALL", "C") + .arg("-d") + .arg("1997-01-19 08:17:48") + .arg("+%r") .succeeds() - .stdout_is("19/01/1997\n"); + .stdout_is("08:17:48 AM\n"); } #[test] From b38ef52db8a217f7076d498b1867f6be36e92903 Mon Sep 17 00:00:00 2001 From: Jake Abendroth Date: Tue, 24 Feb 2026 23:39:27 -0800 Subject: [PATCH 2/4] test: enhance locale-aware date formatting tests for French and change test from check to assertion --- tests/by-util/test_date.rs | 57 ++++++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index 77ffec9d0e3..8f999cd5128 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -1760,7 +1760,18 @@ fn test_date_french_full_sentence() { } } -/// Test that %x respects locale settings (regression for locale-aware date formatting) +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +fn locale_is_available(locale: &str) -> bool { + use std::process::Command; + Command::new("locale") + .env("LC_ALL", locale) + .arg("charmap") + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "UTF-8") + .unwrap_or(false) +} + +/// Test that %x uses the locale's D_FMT (e.g. French: "19.01.1997" not "01/19/97"). #[test] #[cfg(any(target_os = "linux", target_vendor = "apple"))] fn test_date_format_x_locale_aware() { @@ -1773,29 +1784,22 @@ fn test_date_format_x_locale_aware() { .succeeds() .stdout_is("01/19/97\n"); - // French locale: day comes first if locale is available. - // If fr_FR.UTF-8 is not installed, falls back to C and we skip the assertion. - let result = new_ucmd!() + if !locale_is_available("fr_FR.UTF-8") { + println!("Skipping French locale %x test — fr_FR.UTF-8 not available"); + return; + } + // French D_FMT="%d.%m.%Y" + new_ucmd!() .env("TZ", "UTC") .env("LC_ALL", "fr_FR.UTF-8") .arg("-d") .arg("1997-01-19 08:17:48") .arg("+%x") - .succeeds(); - - let output = result.stdout_str().trim(); - // If French locale is available, day (19) comes first; otherwise falls back to C (01 first) - if output.starts_with("19") { - // French locale working - assert!( - output.contains("01") && output.contains("1997"), - "French %%x should contain month 01 and year 1997, got: {output}" - ); - } - // else: locale not installed, skipping French-specific checks + .succeeds() + .stdout_is("19.01.1997\n"); } -/// Test that %X respects locale settings +/// Test that %X uses the locale's T_FMT. #[test] #[cfg(any(target_os = "linux", target_vendor = "apple"))] fn test_date_format_big_x_locale_aware() { @@ -1808,6 +1812,10 @@ fn test_date_format_big_x_locale_aware() { .succeeds() .stdout_is("08:17:48\n"); + if !locale_is_available("fr_FR.UTF-8") { + println!("Skipping French locale %X test — fr_FR.UTF-8 not available"); + return; + } new_ucmd!() .env("TZ", "UTC") .env("LC_ALL", "fr_FR.UTF-8") @@ -1818,7 +1826,7 @@ fn test_date_format_big_x_locale_aware() { .stdout_is("08:17:48\n"); } -/// Test that %r respects locale settings +/// Test that %r uses the locale's T_FMT_AMPM. #[test] #[cfg(any(target_os = "linux", target_vendor = "apple"))] fn test_date_format_r_locale_aware() { @@ -1830,6 +1838,19 @@ fn test_date_format_r_locale_aware() { .arg("+%r") .succeeds() .stdout_is("08:17:48 AM\n"); + + if !locale_is_available("fr_FR.UTF-8") { + println!("Skipping French locale %r test — fr_FR.UTF-8 not available"); + return; + } + new_ucmd!() + .env("TZ", "UTC") + .env("LC_ALL", "fr_FR.UTF-8") + .arg("-d") + .arg("1997-01-19 08:17:48") + .arg("+%r") + .succeeds() + .stdout_is("08:17:48 AM\n"); } #[test] From 5519da846ab028355db1bc76cb98590fbcb8d10f Mon Sep 17 00:00:00 2001 From: Jake Abendroth Date: Wed, 25 Feb 2026 02:33:51 -0800 Subject: [PATCH 3/4] test: fix %x, %r linux/macos locale differences and fallback for French --- src/uu/date/src/locale.rs | 4 ++-- tests/by-util/test_date.rs | 42 ++++++++++++++++++++++++++++++-------- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/uu/date/src/locale.rs b/src/uu/date/src/locale.rs index e064fd5e70b..d26ee81c573 100644 --- a/src/uu/date/src/locale.rs +++ b/src/uu/date/src/locale.rs @@ -157,10 +157,10 @@ cfg_langinfo! { } /// Returns the locale 12-hour time format (`T_FMT_AMPM`) used by `%r`. - /// Falls back to `%I:%M:%S %p` if the locale leaves it empty. + /// Falls back to `%H:%M:%S` if the locale leaves it empty (like fr_FR). pub fn get_locale_time_ampm_format() -> Option { query_nl_langinfo(T_FMT_AMPM_ITEM) - .or_else(|| Some("%I:%M:%S %p".to_string())) + .or_else(|| Some("%H:%M:%S".to_string())) } } diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index 8f999cd5128..b473cee1800 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -1646,8 +1646,7 @@ fn test_date_locale_fr_french() { #[test] fn test_date_posix_format_specifiers() { let cases = [ - // %r: 12-hour time with zero-padded hour (08:17:48 AM, not 8:17:48 AM) - ("%r", "08:17:48 AM"), + // %r is tested separately in `test_date_format_r_locale_aware` (locale-aware) // %x: locale date in MM/DD/YY format ("%x", "01/19/97"), // %X: locale time in HH:MM:SS format @@ -1788,15 +1787,20 @@ fn test_date_format_x_locale_aware() { println!("Skipping French locale %x test — fr_FR.UTF-8 not available"); return; } - // French D_FMT="%d.%m.%Y" - new_ucmd!() + // French D_FMT (e.g. "19/01/1997" on Linux or "19.01.1997" on macOS) + let result = new_ucmd!() .env("TZ", "UTC") .env("LC_ALL", "fr_FR.UTF-8") .arg("-d") .arg("1997-01-19 08:17:48") .arg("+%x") - .succeeds() - .stdout_is("19.01.1997\n"); + .succeeds(); + + let out = result.stdout_str(); + assert!( + out.contains("19/01/1997") || out.contains("19.01.1997"), + "Unexpected %x output for fr_FR.UTF-8: {out}" + ); } /// Test that %X uses the locale's T_FMT. @@ -1839,18 +1843,38 @@ fn test_date_format_r_locale_aware() { .succeeds() .stdout_is("08:17:48 AM\n"); - if !locale_is_available("fr_FR.UTF-8") { - println!("Skipping French locale %r test — fr_FR.UTF-8 not available"); + if !locale_is_available("en_US.UTF-8") { + println!("Skipping en_US locale %r test — en_US.UTF-8 not available"); return; } new_ucmd!() .env("TZ", "UTC") - .env("LC_ALL", "fr_FR.UTF-8") + .env("LC_ALL", "en_US.UTF-8") .arg("-d") .arg("1997-01-19 08:17:48") .arg("+%r") .succeeds() .stdout_is("08:17:48 AM\n"); + + if !locale_is_available("fr_FR.UTF-8") { + println!("Skipping fr_FR locale %r test — fr_FR.UTF-8 not available"); + return; + } + // French does not define AM/PM strings so it should fallback to %H:%M:%S like GNU `date` + let result = new_ucmd!() + .env("TZ", "UTC") + .env("LC_ALL", "fr_FR.UTF-8") + .arg("-d") + .arg("1997-01-19 08:17:48") + .arg("+%r") + .succeeds(); + + let out = result.stdout_str(); + // On some platforms like macOS it might still spit out AM/PM if the locale is incomplete or fallback happens differently. + assert!( + out.contains("08:17:48"), + "Unexpected %r output for fr_FR.UTF-8: {out}" + ); } #[test] From 9a5b0e94e6f8c9fdc5180f521ac213b0f52a1ccb Mon Sep 17 00:00:00 2001 From: Jake Abendroth Date: Wed, 25 Feb 2026 02:55:23 -0800 Subject: [PATCH 4/4] test: strictly assert %x and %r instead of loose contains checks --- src/uu/date/src/date.rs | 5 ++--- src/uu/date/src/locale.rs | 16 ++++++++++------ tests/by-util/test_date.rs | 39 ++++++++++++++++++++------------------ 3 files changed, 33 insertions(+), 27 deletions(-) diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index 435939c7752..32afb43d49c 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -720,9 +720,8 @@ fn expand_locale_specifiers(format: &str) -> Cow<'_, str> { } } if s.contains("%r") { - if let Some(ampm) = locale::get_locale_time_ampm_format() { - s = s.replace("%r", &m); - } + let ampm = locale::get_locale_time_ampm_format(); + s = s.replace("%r", &m); } Cow::Owned(s.replace(PLACEHOLDER, "%%")) diff --git a/src/uu/date/src/locale.rs b/src/uu/date/src/locale.rs index d26ee81c573..19827bcb889 100644 --- a/src/uu/date/src/locale.rs +++ b/src/uu/date/src/locale.rs @@ -157,10 +157,14 @@ cfg_langinfo! { } /// Returns the locale 12-hour time format (`T_FMT_AMPM`) used by `%r`. - /// Falls back to `%H:%M:%S` if the locale leaves it empty (like fr_FR). - pub fn get_locale_time_ampm_format() -> Option { - query_nl_langinfo(T_FMT_AMPM_ITEM) - .or_else(|| Some("%H:%M:%S".to_string())) + /// GNU date falls back to `%I:%M:%S %p` if it's completely undefined. + /// However, if a locale explicitly defines it as empty (like French), it uses `%H:%M:%S`. + pub fn get_locale_time_ampm_format() -> String { + let fmt = query_nl_langinfo(T_FMT_AMPM_ITEM); + match fmt.as_deref() { + Some("") | None => "%I:%M:%S %p".to_string(), + Some(s) => s.to_string(), + } } } @@ -212,8 +216,8 @@ pub fn get_locale_time_format() -> Option { target_os = "openbsd", target_os = "dragonfly" )))] -pub fn get_locale_time_ampm_format() -> Option { - None +pub fn get_locale_time_ampm_format() -> String { + "%I:%M:%S %p".to_string() } #[cfg(test)] diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index b473cee1800..013a9616224 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -1788,19 +1788,20 @@ fn test_date_format_x_locale_aware() { return; } // French D_FMT (e.g. "19/01/1997" on Linux or "19.01.1997" on macOS) - let result = new_ucmd!() + let expected = if cfg!(target_os = "macos") { + "19.01.1997\n" + } else { + "19/01/1997\n" + }; + + new_ucmd!() .env("TZ", "UTC") .env("LC_ALL", "fr_FR.UTF-8") .arg("-d") .arg("1997-01-19 08:17:48") .arg("+%x") - .succeeds(); - - let out = result.stdout_str(); - assert!( - out.contains("19/01/1997") || out.contains("19.01.1997"), - "Unexpected %x output for fr_FR.UTF-8: {out}" - ); + .succeeds() + .stdout_is(expected); } /// Test that %X uses the locale's T_FMT. @@ -1860,21 +1861,23 @@ fn test_date_format_r_locale_aware() { println!("Skipping fr_FR locale %r test — fr_FR.UTF-8 not available"); return; } - // French does not define AM/PM strings so it should fallback to %H:%M:%S like GNU `date` - let result = new_ucmd!() + // French does not define AM/PM strings so it should fallback to %H:%M:%S like GNU `date` on Linux. + // However, on macOS, `nl_langinfo(T_FMT_AMPM)` for fr_FR.UTF-8 returns "%I:%M:%S %p", + // so it formatting outputs "08:17:48 AM" + let expected = if cfg!(target_os = "macos") { + "08:17:48 AM\n" + } else { + "08:17:48\n" + }; + + new_ucmd!() .env("TZ", "UTC") .env("LC_ALL", "fr_FR.UTF-8") .arg("-d") .arg("1997-01-19 08:17:48") .arg("+%r") - .succeeds(); - - let out = result.stdout_str(); - // On some platforms like macOS it might still spit out AM/PM if the locale is incomplete or fallback happens differently. - assert!( - out.contains("08:17:48"), - "Unexpected %r output for fr_FR.UTF-8: {out}" - ); + .succeeds() + .stdout_is(expected); } #[test]