From f494fa541013e7ad78e09cb2bf9929982debbb34 Mon Sep 17 00:00:00 2001 From: CrazyRoka Date: Sun, 28 Dec 2025 19:45:20 +0000 Subject: [PATCH 1/2] date: fix handling of case-change flags in locale format specifiers --- src/uu/date/src/date.rs | 3 +- src/uu/date/src/locale.rs | 117 +++++++++++++++++++++++++++++++++++-- tests/by-util/test_date.rs | 24 ++++++++ 3 files changed, 138 insertions(+), 6 deletions(-) diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index c72b1c3048b..3a0adbb32e9 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -395,12 +395,13 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { }; let format_string = make_format_string(&settings); + let format_string = locale::expand_locale_format(format_string); // Format all the dates for date in dates { match date { // TODO: Switch to lenient formatting. - Ok(date) => match strtime::format(format_string, &date) { + Ok(date) => match strtime::format(format_string.as_ref(), &date) { Ok(s) => println!("{s}"), Err(e) => { return Err(USimpleError::new( diff --git a/src/uu/date/src/locale.rs b/src/uu/date/src/locale.rs index c190c1a0dad..ca9febac669 100644 --- a/src/uu/date/src/locale.rs +++ b/src/uu/date/src/locale.rs @@ -64,8 +64,98 @@ cfg_langinfo! { }) } - /// Retrieves the date/time format string from the system locale - fn get_locale_format_string() -> Option { + /// Replaces %c, %x, %X with their locale-specific format strings. + /// + /// If a flag like `^` is present (e.g., `%^c`), it is distributed to the + /// sub-specifiers within the locale string. + pub fn expand_locale_format(format: &str) -> std::borrow::Cow<'_, str> { + let mut result = String::with_capacity(format.len()); + let mut chars = format.chars().peekable(); + let mut modified = false; + + while let Some(c) = chars.next() { + if c != '%' { + result.push(c); + continue; + } + + // Capture flags + let mut flags = Vec::new(); + while let Some(&peek) = chars.peek() { + match peek { + '_' | '-' | '0' | '^' | '#' => { + flags.push(peek); + chars.next(); + }, + _ => break, + } + } + + match chars.peek() { + Some(&spec @ ('c' | 'x' | 'X')) => { + chars.next(); + + let item = match spec { + 'c' => libc::D_T_FMT, + 'x' => libc::D_FMT, + 'X' => libc::T_FMT, + _ => unreachable!(), + }; + + if let Some(s) = get_langinfo(item) { + // If the user requested uppercase (%^c), distribute that flag + // to the expanded specifiers. + let replacement = if flags.contains(&'^') { + distribute_flag(&s, '^') + } else { + s + }; + result.push_str(&replacement); + modified = true; + } else { + // Reconstruct original sequence if lookup fails + result.push('%'); + result.extend(flags); + result.push(spec); + } + }, + Some(_) | None => { + // Not a locale specifier, or end of string. + // Push captured flags and let loop handle the next char. + result.push('%'); + result.extend(flags); + } + } + } + + if modified { + std::borrow::Cow::Owned(result) + } else { + std::borrow::Cow::Borrowed(format) + } + } + + fn distribute_flag(fmt: &str, flag: char) -> String { + let mut res = String::with_capacity(fmt.len() * 2); + let mut chars = fmt.chars().peekable(); + while let Some(c) = chars.next() { + res.push(c); + if c == '%' { + if let Some(&n) = chars.peek() { + if n == '%' { + chars.next(); + res.push('%'); + } else { + res.push(flag); + } + } + } + } + res + } + + /// Retrieves the date/time format string from the system locale (D_T_FMT, D_FMT, T_FMT) + pub fn get_langinfo(item: libc::nl_item) -> Option { // In tests, acquire mutex to prevent race conditions with setlocale() // which is process-global and not thread-safe #[cfg(test)] @@ -76,12 +166,12 @@ cfg_langinfo! { libc::setlocale(libc::LC_TIME, c"".as_ptr()); // Get the date/time format string - let d_t_fmt_ptr = libc::nl_langinfo(libc::D_T_FMT); - if d_t_fmt_ptr.is_null() { + let fmt_ptr = libc::nl_langinfo(item); + if fmt_ptr.is_null() { return None; } - let format = CStr::from_ptr(d_t_fmt_ptr).to_str().ok()?; + let format = CStr::from_ptr(fmt_ptr).to_str().ok()?; if format.is_empty() { return None; } @@ -90,6 +180,11 @@ cfg_langinfo! { } } + /// Retrieves the date/time format string from the system locale + fn get_locale_format_string() -> Option { + get_langinfo(libc::D_T_FMT) + } + /// Ensures the format string includes timezone (%Z) fn ensure_timezone_in_format(format: &str) -> String { if format.contains("%Z") { @@ -123,6 +218,18 @@ pub fn get_locale_default_format() -> &'static str { "%a %b %e %X %Z %Y" } +#[cfg(not(any( + target_os = "linux", + target_vendor = "apple", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd", + target_os = "dragonfly" +)))] +pub fn expand_locale_format(format: &str) -> std::borrow::Cow<'_, str> { + std::borrow::Cow::Borrowed(format) +} + #[cfg(test)] mod tests { cfg_langinfo! { diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index 9a98b1b0309..74625c31c76 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -1404,3 +1404,27 @@ fn test_date_locale_fr_french() { "Output should include timezone information, got: {stdout}" ); } + +#[test] +#[cfg(any( + target_os = "linux", + target_vendor = "apple", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd", + target_os = "dragonfly" +))] +fn test_format_upper_c_locale_expansion() { + new_ucmd!() + .env("LC_ALL", "C") + .env("TZ", "UTC") + .arg("-d") + .arg("2024-01-01") + .arg("+%^c") + .succeeds() + .stdout_str_check(|out| { + // In C locale, %c expands to "%a %b %e %H:%M:%S %Y" (e.g., "Mon Jan 1 ...") + // With the ^ flag, we expect "MON JAN 1 ..." + out.contains("MON") && out.contains("JAN") + }); +} From 49f0f263f8a8589f6b3722c69d61a304a9950f47 Mon Sep 17 00:00:00 2001 From: CrazyRoka Date: Wed, 31 Dec 2025 19:13:10 +0000 Subject: [PATCH 2/2] date: address review comments (docs and tests) - Add documentation for distribute_flag helper. - Add unit tests for flag distribution and locale expansion. - Add integration test for locale expansion with uppercase flag. --- src/uu/date/src/locale.rs | 44 ++++++++++++++++++++++++++++++++++++++ tests/by-util/test_date.rs | 28 ++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/uu/date/src/locale.rs b/src/uu/date/src/locale.rs index ca9febac669..229821a4044 100644 --- a/src/uu/date/src/locale.rs +++ b/src/uu/date/src/locale.rs @@ -135,6 +135,11 @@ cfg_langinfo! { } } + /// Distributes the given flag to all format specifiers in the string. + /// + /// For example, if the format string is "%a %b" and the flag is '^', + /// the result will be "%^a %^b". Literal percent signs ("%%") are skipped + /// and left as is. fn distribute_flag(fmt: &str, flag: char) -> String { let mut res = String::with_capacity(fmt.len() * 2); let mut chars = fmt.chars().peekable(); @@ -367,4 +372,43 @@ mod tests { ); } } + + #[test] + #[cfg(any( + target_os = "linux", + target_vendor = "apple", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd", + target_os = "dragonfly" + ))] + fn test_distribute_flag() { + // Standard distribution + assert_eq!(distribute_flag("%a %b", '^'), "%^a %^b"); + // Ignore literals + assert_eq!(distribute_flag("foo %a bar", '_'), "foo %_a bar"); + // Skip escaped percent signs + assert_eq!(distribute_flag("%% %a", '^'), "%% %^a"); + // Handle flags that might already exist + assert_eq!(distribute_flag("%_a", '^'), "%^_a"); + } + + #[test] + #[cfg(any( + target_os = "linux", + target_vendor = "apple", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd", + target_os = "dragonfly" + ))] + fn test_expand_locale_format_basic() { + let format = "%^x"; + let expanded = expand_locale_format(format).to_string().to_lowercase(); + + assert_ne!(expanded, "%^x", "Should have expanded %^x"); + assert!(expanded.contains("%^d"), "Should contain %^d"); + assert!(expanded.contains("%^m"), "Should contain %^m"); + assert!(expanded.contains("%^y"), "Should contain %^y"); + } } diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index 74625c31c76..259b0c74f6f 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -1428,3 +1428,31 @@ fn test_format_upper_c_locale_expansion() { out.contains("MON") && out.contains("JAN") }); } + +#[test] +#[cfg(any( + target_os = "linux", + target_vendor = "apple", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd", + target_os = "dragonfly" +))] +fn test_format_upper_c_locale_expansion_french() { + let result = new_ucmd!() + .env("LC_ALL", "fr_FR.UTF-8") + .arg("-d") + .arg("2024-01-01T13:00:00") + .arg("+%^c") + .run(); + + let stdout = result.stdout_str(); + + // Should have 13:00 (not 1:00) + assert!( + stdout.contains("13:00"), + "French locale should show 13:00 for 1 PM, got: {stdout}" + ); + + assert_eq!(stdout.to_uppercase(), stdout, "Output should be uppercase"); +}