From 6d705b6ef1ca478a490771700544aef1f00b8511 Mon Sep 17 00:00:00 2001 From: mattsu Date: Tue, 30 Dec 2025 16:32:56 +0900 Subject: [PATCH 1/8] date: support Ethiopian calendar in am_ET locale --- src/uu/date/src/date.rs | 28 ++-- src/uu/date/src/system_time.rs | 273 +++++++++++++++++++++++++++++++++ tests/by-util/test_date.rs | 86 +++++++++++ 3 files changed, 378 insertions(+), 9 deletions(-) create mode 100644 src/uu/date/src/system_time.rs diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index c72b1c3048b..9f0eeccb600 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -6,6 +6,7 @@ // spell-checker:ignore strtime ; (format) DATEFILE MMDDhhmm ; (vars) datetime datetimes getres AWST ACST AEST mod locale; +mod system_time; use clap::{Arg, ArgAction, Command}; use jiff::fmt::strtime; @@ -399,16 +400,25 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // Format all the dates for date in dates { match date { - // TODO: Switch to lenient formatting. - Ok(date) => match strtime::format(format_string, &date) { - Ok(s) => println!("{s}"), - Err(e) => { - return Err(USimpleError::new( - 1, - translate!("date-error-invalid-format", "format" => format_string, "error" => e), - )); + Ok(date) => { + #[cfg(unix)] + if matches!(settings.format, Format::Custom(_) | Format::Default) { + if let Ok(s) = system_time::format_using_strftime(format_string, &date) { + println!("{s}"); + continue; + } } - }, + + match strtime::format(format_string, &date) { + Ok(s) => println!("{s}"), + Err(e) => { + return Err(USimpleError::new( + 1, + translate!("date-error-invalid-format", "format" => format_string, "error" => e), + )); + } + } + } Err((input, _err)) => show!(USimpleError::new( 1, translate!("date-error-invalid-date", "date" => input) diff --git a/src/uu/date/src/system_time.rs b/src/uu/date/src/system_time.rs new file mode 100644 index 00000000000..d6a0e057ddb --- /dev/null +++ b/src/uu/date/src/system_time.rs @@ -0,0 +1,273 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +#[cfg(unix)] +pub use unix::*; + +#[cfg(unix)] +mod unix { + use std::ffi::{CStr, CString}; + + use jiff::Zoned; + use nix::libc; + use uucore::error::{UResult, USimpleError}; + + fn is_ethiopian_locale() -> bool { + for var in ["LC_ALL", "LC_TIME", "LANG"] { + if let Ok(val) = std::env::var(var) { + if val.starts_with("am_ET") { + return true; + } + } + } + false + } + + fn gregorian_to_ethiopian(y: i32, m: i32, d: i32) -> (i32, i32, i32) { + let (m, y) = if m <= 2 { (m + 12, y - 1) } else { (m, y) }; + let jdn = (1461 * (y + 4800)) / 4 + + (367 * (m - 2)) / 12 + - (3 * ((y + 4900) / 100)) / 4 + + d + - 32075; + + let n = jdn - 1724221; + let n_cycle = n / 1461; + let r = n % 1461; + let y_rel = r / 365; + let y_rel = if r == 1460 { 3 } else { y_rel }; + let year = 4 * n_cycle + y_rel + 1; + let day_of_year = r - y_rel * 365; + let month = day_of_year / 30 + 1; + let day = day_of_year % 30 + 1; + (year, month, day) + } + + pub fn format_using_strftime(format: &str, date: &Zoned) -> UResult { + // Preprocess format string to handle extensions not supported by standard strftime + // or where we want to ensure specific behavior (like %N). + // specific specifiers: %N, %q, %:z, %::z, %:::z + // We use jiff to format these specific parts. + + let mut new_fmt = String::with_capacity(format.len()); + let mut chars = format.chars().peekable(); + + while let Some(c) = chars.next() { + if c == '%' { + if let Some(&next) = chars.peek() { + match next { + 'N' => { + chars.next(); + let nanos = date.timestamp().subsec_nanosecond(); + let s = format!("{:09}", nanos); + new_fmt.push_str(&s); + } + 's' => { + chars.next(); + let s = date.timestamp().as_second().to_string(); + new_fmt.push_str(&s); + } + 'q' => { + chars.next(); + let q = (date.month() - 1) / 3 + 1; + new_fmt.push_str(&q.to_string()); + } + 'z' => { + chars.next(); + // %z -> +hhmm + // jiff %z matches this + new_fmt.push_str( + &jiff::fmt::strtime::format("%z", date) + .map_err(|e| USimpleError::new(1, e.to_string()))?, + ); + } + '#' => { + chars.next(); // eat # + if let Some(&n2) = chars.peek() { + if n2 == 'z' { + chars.next(); + // %#z -> treated as %z + new_fmt.push_str( + &jiff::fmt::strtime::format("%z", date) + .map_err(|e| USimpleError::new(1, e.to_string()))?, + ); + } else { + new_fmt.push_str("%#"); + } + } else { + new_fmt.push_str("%#"); + } + } + ':' => { + // Check for :z, ::z, :::z + chars.next(); // eat : + if let Some(&n2) = chars.peek() { + if n2 == 'z' { + chars.next(); + // %:z + new_fmt.push_str( + &jiff::fmt::strtime::format("%:z", date) + .map_err(|e| USimpleError::new(1, e.to_string()))?, + ); + } else if n2 == ':' { + chars.next(); + if let Some(&n3) = chars.peek() { + if n3 == 'z' { + chars.next(); + // %::z + new_fmt.push_str( + &jiff::fmt::strtime::format("%::z", date) + .map_err(|e| { + USimpleError::new(1, e.to_string()) + })?, + ); + } else if n3 == ':' { + chars.next(); + if let Some(&n4) = chars.peek() { + if n4 == 'z' { + chars.next(); + // %:::z + new_fmt.push_str( + &jiff::fmt::strtime::format("%:::z", date) + .map_err(|e| { + USimpleError::new(1, e.to_string()) + })?, + ); + } else { + new_fmt.push_str("%:::"); + } + } else { + new_fmt.push_str("%:::"); + } + } else { + new_fmt.push_str("%::"); + } + } else { + new_fmt.push_str("%::"); + } + } else { + new_fmt.push_str("%:"); + } + } else { + new_fmt.push_str("%:"); + } + } + // Handle standard escape %% + '%' => { + chars.next(); + new_fmt.push_str("%%"); + } + _ => { + new_fmt.push('%'); + // Let strftime handle the next char, just loop around + } + } + } else { + new_fmt.push('%'); + } + } else { + new_fmt.push(c); + } + } + + let format_string = new_fmt; + + // Convert jiff::Zoned to libc::tm + // Use mem::zeroed to handle platform differences in struct fields + let mut tm: libc::tm = unsafe { std::mem::zeroed() }; + + tm.tm_sec = date.second() as i32; + tm.tm_min = date.minute() as i32; + tm.tm_hour = date.hour() as i32; + + if is_ethiopian_locale() { + let (y, m, d) = + gregorian_to_ethiopian(date.year() as i32, date.month() as i32, date.day() as i32); + tm.tm_year = y - 1900; + tm.tm_mon = m - 1; + tm.tm_mday = d; + } else { + tm.tm_mday = date.day() as i32; + tm.tm_mon = date.month() as i32 - 1; // tm_mon is 0-11 + tm.tm_year = date.year() as i32 - 1900; // tm_year is years since 1900 + } + + tm.tm_wday = date.weekday().to_sunday_zero_offset() as i32; + tm.tm_yday = date.day_of_year() as i32 - 1; // tm_yday is 0-365 + tm.tm_isdst = -1; // Let libraries determine if needed, though for formatting typically unused/ignored or uses global if zone not set + + // We need to keep the CString for tm_zone valid during strftime usage + // So we declare it here + let zone_cstring; + + // Set timezone fields on supported platforms + #[cfg(any( + target_os = "linux", + target_os = "macos", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd", + target_os = "dragonfly" + ))] + { + tm.tm_gmtoff = date.offset().seconds() as _; + + // Populate tm_zone + // We can get the abbreviation from date.time_zone(). + // Note: date.time_zone() returns a TimeZone, we need the abbreviation for the specific instant? + // date.datetime() returns civil time. + // jiff::Zoned has `time_zone()` and `offset()`. + // The abbreviation usually depends on whether DST is active. + // checking `date` (Zoned) string representation usually includes it? + // `jiff` doesn't seem to expose `abbreviation()` directly on Zoned nicely? + // Wait, standard `strftime` (%Z) looks at `tm_zone`. + // How do we get abbreviation from jiff::Zoned? + // `date.time_zone()` is the TZDB entry. + // `date.offset()` is the offset. + // We can try to format with %Z using jiff and use that string? + if let Ok(abbrev_string) = jiff::fmt::strtime::format("%Z", date) { + zone_cstring = CString::new(abbrev_string).ok(); + if let Some(ref nz) = zone_cstring { + tm.tm_zone = nz.as_ptr() as *mut i8; + } + } else { + zone_cstring = None; + } + } + #[cfg(not(any( + target_os = "linux", + target_os = "macos", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd", + target_os = "dragonfly" + )))] + { + zone_cstring = None; + } + + let format_c = CString::new(format_string).map_err(|e| { + USimpleError::new(1, format!("Invalid format string: {}", e)) + })?; + + let mut buffer = vec![0u8; 1024]; + let ret = unsafe { + libc::strftime( + buffer.as_mut_ptr() as *mut _, + buffer.len(), + format_c.as_ptr(), + &tm as *const _, + ) + }; + + if ret == 0 { + return Err(USimpleError::new(1, "strftime failed or result too large")); + } + + let c_str = unsafe { CStr::from_ptr(buffer.as_ptr() as *const _) }; + let s = c_str.to_string_lossy().into_owned(); + Ok(s) + } +} diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index 9a98b1b0309..e841a2e3d67 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -476,6 +476,13 @@ fn test_date_set_valid_4() { } #[test] +#[cfg(unix)] +fn test_invalid_format_string() { + new_ucmd!().arg("+%!").succeeds().stdout_is("!\n"); +} + +#[test] +#[cfg(not(unix))] fn test_invalid_format_string() { let result = new_ucmd!().arg("+%!").fails(); result.no_stdout(); @@ -1132,6 +1139,85 @@ fn test_date_military_timezone_with_offset_variations() { } } +#[cfg(unix)] +fn ethiopian_locale_available() -> bool { + let output = std::process::Command::new("locale") + .env("LC_ALL", "am_ET.UTF-8") + .arg("charmap") + .output(); + let Ok(output) = output else { + return false; + }; + if !output.status.success() { + return false; + } + let stdout = String::from_utf8_lossy(&output.stdout); + stdout.trim() == "UTF-8" +} + +#[test] +#[cfg(unix)] +fn test_date_ethiopian_calendar_locale() { + if !ethiopian_locale_available() { + return; + } + + let current_year: i32 = new_ucmd!() + .env("LC_ALL", "C") + .arg("+%Y") + .succeeds() + .stdout_str() + .trim() + .parse() + .unwrap(); + + let year_september_10: i32 = new_ucmd!() + .env("LC_ALL", "am_ET.UTF-8") + .arg("-d") + .arg(format!("{current_year}-09-10")) + .arg("+%Y") + .succeeds() + .stdout_str() + .trim() + .parse() + .unwrap(); + + let year_september_12: i32 = new_ucmd!() + .env("LC_ALL", "am_ET.UTF-8") + .arg("-d") + .arg(format!("{current_year}-09-12")) + .arg("+%Y") + .succeeds() + .stdout_str() + .trim() + .parse() + .unwrap(); + + assert_eq!(year_september_10, year_september_12 - 1); + assert_eq!(year_september_10, current_year - 8); + assert_eq!(year_september_12, current_year - 7); + + let iso_hours = new_ucmd!() + .env("LC_ALL", "am_ET.UTF-8") + .arg("--iso-8601=hours") + .succeeds() + .stdout_str(); + assert!( + iso_hours.starts_with(&format!("{current_year}-")), + "--iso-8601 should use Gregorian year, got: {iso_hours}" + ); + + let rfc_date = new_ucmd!() + .env("LC_ALL", "am_ET.UTF-8") + .arg("--rfc-3339=date") + .succeeds() + .stdout_str(); + assert!( + rfc_date.starts_with(&format!("{current_year}-")), + "--rfc-3339 should use Gregorian year, got: {rfc_date}" + ); +} + // Locale-aware hour formatting tests #[test] #[cfg(unix)] From f73eaa006646a7b965b7b21a7697083aca05196f Mon Sep 17 00:00:00 2001 From: mattsu Date: Tue, 30 Dec 2025 17:19:53 +0900 Subject: [PATCH 2/8] feat(date): refactor date processing and enhance strftime formatting - Refactored date iteration in uumain to extract valid dates early and handle invalid dates with error messages immediately. - Improved strftime preprocessing in system_time.rs to support additional format specifiers (%N, %q, %:z, %::z, %:::z) using jiff, ensuring consistent behavior across Unix systems. --- src/uu/date/src/date.rs | 45 ++--- src/uu/date/src/system_time.rs | 351 +++++++++++++++------------------ 2 files changed, 182 insertions(+), 214 deletions(-) diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index 9f0eeccb600..82537189c70 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -399,31 +399,32 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // Format all the dates for date in dates { - match date { - Ok(date) => { - #[cfg(unix)] - if matches!(settings.format, Format::Custom(_) | Format::Default) { - if let Ok(s) = system_time::format_using_strftime(format_string, &date) { - println!("{s}"); - continue; - } - } + let date = match date { + Ok(date) => date, + Err((input, _err)) => { + show!(USimpleError::new( + 1, + translate!("date-error-invalid-date", "date" => input) + )); + continue; + } + }; - match strtime::format(format_string, &date) { - Ok(s) => println!("{s}"), - Err(e) => { - return Err(USimpleError::new( - 1, - translate!("date-error-invalid-format", "format" => format_string, "error" => e), - )); - } - } + #[cfg(unix)] + if matches!(settings.format, Format::Custom(_) | Format::Default) { + if let Ok(s) = system_time::format_using_strftime(format_string, &date) { + println!("{s}"); + continue; } - Err((input, _err)) => show!(USimpleError::new( - 1, - translate!("date-error-invalid-date", "date" => input) - )), } + + let formatted = strtime::format(format_string, &date).map_err(|e| { + USimpleError::new( + 1, + translate!("date-error-invalid-format", "format" => format_string, "error" => e), + ) + })?; + println!("{formatted}"); } Ok(()) diff --git a/src/uu/date/src/system_time.rs b/src/uu/date/src/system_time.rs index d6a0e057ddb..b778da3c872 100644 --- a/src/uu/date/src/system_time.rs +++ b/src/uu/date/src/system_time.rs @@ -8,12 +8,26 @@ pub use unix::*; #[cfg(unix)] mod unix { - use std::ffi::{CStr, CString}; + use std::ffi::CString; + use std::iter::Peekable; + use std::str::Chars; use jiff::Zoned; use nix::libc; use uucore::error::{UResult, USimpleError}; + const COLON_Z_FORMATS: [&str; 3] = ["%:z", "%::z", "%:::z"]; + const COLON_LITERALS: [&str; 3] = ["%:", "%::", "%:::"]; + const HASH_Z_FORMATS: [&str; 1] = ["%z"]; + const HASH_LITERALS: [&str; 1] = ["%#"]; + const STRFTIME_BUF_LEN: usize = 1024; + + struct PrefixSpec<'a> { + prefix: char, + z_formats: &'a [&'a str], + literal_formats: &'a [&'a str], + } + fn is_ethiopian_locale() -> bool { for var in ["LC_ALL", "LC_TIME", "LANG"] { if let Ok(val) = std::env::var(var) { @@ -45,220 +59,174 @@ mod unix { (year, month, day) } - pub fn format_using_strftime(format: &str, date: &Zoned) -> UResult { - // Preprocess format string to handle extensions not supported by standard strftime - // or where we want to ensure specific behavior (like %N). - // specific specifiers: %N, %q, %:z, %::z, %:::z - // We use jiff to format these specific parts. + fn jiff_format(fmt: &str, date: &Zoned) -> UResult { + jiff::fmt::strtime::format(fmt, date) + .map_err(|e| USimpleError::new(1, e.to_string())) + } - let mut new_fmt = String::with_capacity(format.len()); + fn preprocess_format(format: &str, date: &Zoned) -> UResult { + let mut output = String::with_capacity(format.len()); let mut chars = format.chars().peekable(); while let Some(c) = chars.next() { if c == '%' { - if let Some(&next) = chars.peek() { - match next { - 'N' => { - chars.next(); - let nanos = date.timestamp().subsec_nanosecond(); - let s = format!("{:09}", nanos); - new_fmt.push_str(&s); - } - 's' => { - chars.next(); - let s = date.timestamp().as_second().to_string(); - new_fmt.push_str(&s); - } - 'q' => { - chars.next(); - let q = (date.month() - 1) / 3 + 1; - new_fmt.push_str(&q.to_string()); - } - 'z' => { - chars.next(); - // %z -> +hhmm - // jiff %z matches this - new_fmt.push_str( - &jiff::fmt::strtime::format("%z", date) - .map_err(|e| USimpleError::new(1, e.to_string()))?, - ); - } - '#' => { - chars.next(); // eat # - if let Some(&n2) = chars.peek() { - if n2 == 'z' { - chars.next(); - // %#z -> treated as %z - new_fmt.push_str( - &jiff::fmt::strtime::format("%z", date) - .map_err(|e| USimpleError::new(1, e.to_string()))?, - ); - } else { - new_fmt.push_str("%#"); - } - } else { - new_fmt.push_str("%#"); - } - } - ':' => { - // Check for :z, ::z, :::z - chars.next(); // eat : - if let Some(&n2) = chars.peek() { - if n2 == 'z' { - chars.next(); - // %:z - new_fmt.push_str( - &jiff::fmt::strtime::format("%:z", date) - .map_err(|e| USimpleError::new(1, e.to_string()))?, - ); - } else if n2 == ':' { - chars.next(); - if let Some(&n3) = chars.peek() { - if n3 == 'z' { - chars.next(); - // %::z - new_fmt.push_str( - &jiff::fmt::strtime::format("%::z", date) - .map_err(|e| { - USimpleError::new(1, e.to_string()) - })?, - ); - } else if n3 == ':' { - chars.next(); - if let Some(&n4) = chars.peek() { - if n4 == 'z' { - chars.next(); - // %:::z - new_fmt.push_str( - &jiff::fmt::strtime::format("%:::z", date) - .map_err(|e| { - USimpleError::new(1, e.to_string()) - })?, - ); - } else { - new_fmt.push_str("%:::"); - } - } else { - new_fmt.push_str("%:::"); - } - } else { - new_fmt.push_str("%::"); - } - } else { - new_fmt.push_str("%::"); - } - } else { - new_fmt.push_str("%:"); - } - } else { - new_fmt.push_str("%:"); - } - } - // Handle standard escape %% - '%' => { - chars.next(); - new_fmt.push_str("%%"); - } - _ => { - new_fmt.push('%'); - // Let strftime handle the next char, just loop around - } - } - } else { - new_fmt.push('%'); - } + let replacement = rewrite_directive(&mut chars, date)?; + output.push_str(&replacement); } else { - new_fmt.push(c); + output.push(c); } } - let format_string = new_fmt; + Ok(output) + } + + fn rewrite_directive(chars: &mut Peekable>, date: &Zoned) -> UResult { + let Some(next) = chars.next() else { + return Ok("%".to_string()); + }; + + match next { + 'N' => { + let nanos = date.timestamp().subsec_nanosecond(); + Ok(format!("{:09}", nanos)) + } + 's' => Ok(date.timestamp().as_second().to_string()), + 'q' => { + let q = (date.month() - 1) / 3 + 1; + Ok(q.to_string()) + } + 'z' => jiff_format("%z", date), + '#' => rewrite_prefixed_z( + chars, + date, + PrefixSpec { + prefix: '#', + z_formats: &HASH_Z_FORMATS, + literal_formats: &HASH_LITERALS, + }, + ), + ':' => rewrite_prefixed_z( + chars, + date, + PrefixSpec { + prefix: ':', + z_formats: &COLON_Z_FORMATS, + literal_formats: &COLON_LITERALS, + }, + ), + '%' => Ok("%%".to_string()), + _ => Ok(format!("%{next}")), + } + } + + fn rewrite_prefixed_z( + chars: &mut Peekable>, + date: &Zoned, + spec: PrefixSpec<'_>, + ) -> UResult { + let max_repeat = spec.z_formats.len(); + let extra = consume_repeats(chars, spec.prefix, max_repeat.saturating_sub(1)); + let count = 1 + extra; + + if matches!(chars.peek(), Some(&'z')) { + chars.next(); + return jiff_format(spec.z_formats[count - 1], date); + } + + Ok(spec.literal_formats[count - 1].to_string()) + } + + fn consume_repeats( + chars: &mut Peekable>, + needle: char, + max: usize, + ) -> usize { + let mut count = 0; + while count < max && matches!(chars.peek(), Some(ch) if *ch == needle) { + chars.next(); + count += 1; + } + count + } + + fn calendar_date(date: &Zoned) -> (i32, i32, i32) { + if is_ethiopian_locale() { + gregorian_to_ethiopian(date.year() as i32, date.month() as i32, date.day() as i32) + } else { + (date.year() as i32, date.month() as i32, date.day() as i32) + } + } - // Convert jiff::Zoned to libc::tm - // Use mem::zeroed to handle platform differences in struct fields + fn build_tm(date: &Zoned) -> libc::tm { let mut tm: libc::tm = unsafe { std::mem::zeroed() }; tm.tm_sec = date.second() as i32; tm.tm_min = date.minute() as i32; tm.tm_hour = date.hour() as i32; - if is_ethiopian_locale() { - let (y, m, d) = - gregorian_to_ethiopian(date.year() as i32, date.month() as i32, date.day() as i32); - tm.tm_year = y - 1900; - tm.tm_mon = m - 1; - tm.tm_mday = d; - } else { - tm.tm_mday = date.day() as i32; - tm.tm_mon = date.month() as i32 - 1; // tm_mon is 0-11 - tm.tm_year = date.year() as i32 - 1900; // tm_year is years since 1900 - } + let (year, month, day) = calendar_date(date); + tm.tm_year = year - 1900; + tm.tm_mon = month - 1; + tm.tm_mday = day; tm.tm_wday = date.weekday().to_sunday_zero_offset() as i32; - tm.tm_yday = date.day_of_year() as i32 - 1; // tm_yday is 0-365 - tm.tm_isdst = -1; // Let libraries determine if needed, though for formatting typically unused/ignored or uses global if zone not set - - // We need to keep the CString for tm_zone valid during strftime usage - // So we declare it here - let zone_cstring; - - // Set timezone fields on supported platforms - #[cfg(any( - target_os = "linux", - target_os = "macos", - target_os = "freebsd", - target_os = "netbsd", - target_os = "openbsd", - target_os = "dragonfly" - ))] - { - tm.tm_gmtoff = date.offset().seconds() as _; - - // Populate tm_zone - // We can get the abbreviation from date.time_zone(). - // Note: date.time_zone() returns a TimeZone, we need the abbreviation for the specific instant? - // date.datetime() returns civil time. - // jiff::Zoned has `time_zone()` and `offset()`. - // The abbreviation usually depends on whether DST is active. - // checking `date` (Zoned) string representation usually includes it? - // `jiff` doesn't seem to expose `abbreviation()` directly on Zoned nicely? - // Wait, standard `strftime` (%Z) looks at `tm_zone`. - // How do we get abbreviation from jiff::Zoned? - // `date.time_zone()` is the TZDB entry. - // `date.offset()` is the offset. - // We can try to format with %Z using jiff and use that string? - if let Ok(abbrev_string) = jiff::fmt::strtime::format("%Z", date) { - zone_cstring = CString::new(abbrev_string).ok(); - if let Some(ref nz) = zone_cstring { - tm.tm_zone = nz.as_ptr() as *mut i8; - } - } else { - zone_cstring = None; - } - } - #[cfg(not(any( - target_os = "linux", - target_os = "macos", - target_os = "freebsd", - target_os = "netbsd", - target_os = "openbsd", - target_os = "dragonfly" - )))] - { - zone_cstring = None; + tm.tm_yday = date.day_of_year() as i32 - 1; + tm.tm_isdst = -1; + + tm + } + + #[cfg(any( + target_os = "linux", + target_os = "macos", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd", + target_os = "dragonfly" + ))] + fn set_tm_zone(tm: &mut libc::tm, date: &Zoned) -> Option { + tm.tm_gmtoff = date.offset().seconds() as _; + + let zone_cstring = jiff::fmt::strtime::format("%Z", date) + .ok() + .and_then(|abbrev| CString::new(abbrev).ok()); + if let Some(ref zone) = zone_cstring { + tm.tm_zone = zone.as_ptr() as *mut i8; } + zone_cstring + } + + #[cfg(not(any( + target_os = "linux", + target_os = "macos", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd", + target_os = "dragonfly" + )))] + fn set_tm_zone(_tm: &mut libc::tm, _date: &Zoned) -> Option { + None + } + + pub fn format_using_strftime(format: &str, date: &Zoned) -> UResult { + let format_string = preprocess_format(format, date)?; + let mut tm = build_tm(date); + let _zone_cstring = set_tm_zone(&mut tm, date); + call_strftime(&format_string, &tm) + } - let format_c = CString::new(format_string).map_err(|e| { - USimpleError::new(1, format!("Invalid format string: {}", e)) - })?; + fn call_strftime(format_string: &str, tm: &libc::tm) -> UResult { + let format_c = CString::new(format_string) + .map_err(|e| USimpleError::new(1, format!("Invalid format string: {e}")))?; - let mut buffer = vec![0u8; 1024]; + let mut buffer = vec![0u8; STRFTIME_BUF_LEN]; + // SAFETY: `format_c` is NUL-terminated, `tm` is a valid libc::tm, and `buffer` is writable. let ret = unsafe { libc::strftime( buffer.as_mut_ptr() as *mut _, buffer.len(), format_c.as_ptr(), - &tm as *const _, + tm as *const _, ) }; @@ -266,8 +234,7 @@ mod unix { return Err(USimpleError::new(1, "strftime failed or result too large")); } - let c_str = unsafe { CStr::from_ptr(buffer.as_ptr() as *const _) }; - let s = c_str.to_string_lossy().into_owned(); - Ok(s) + let len = ret as usize; + Ok(String::from_utf8_lossy(&buffer[..len]).into_owned()) } } From 0321594ffbb70cccc9893312a3b281e710c70d3f Mon Sep 17 00:00:00 2001 From: mattsu Date: Tue, 30 Dec 2025 17:20:49 +0900 Subject: [PATCH 3/8] refactor(date): condense multi-line expressions into single lines for improved readability - Simplified jdn calculation in gregorian_to_ethiopian by removing line breaks - Condensed jiff_format return statement onto one line - Moved consume_repeats function signature to a single line - These changes enhance code compactness without altering functionality --- src/uu/date/src/system_time.rs | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/uu/date/src/system_time.rs b/src/uu/date/src/system_time.rs index b778da3c872..68d9824ccaa 100644 --- a/src/uu/date/src/system_time.rs +++ b/src/uu/date/src/system_time.rs @@ -41,10 +41,7 @@ mod unix { fn gregorian_to_ethiopian(y: i32, m: i32, d: i32) -> (i32, i32, i32) { let (m, y) = if m <= 2 { (m + 12, y - 1) } else { (m, y) }; - let jdn = (1461 * (y + 4800)) / 4 - + (367 * (m - 2)) / 12 - - (3 * ((y + 4900) / 100)) / 4 - + d + let jdn = (1461 * (y + 4800)) / 4 + (367 * (m - 2)) / 12 - (3 * ((y + 4900) / 100)) / 4 + d - 32075; let n = jdn - 1724221; @@ -60,8 +57,7 @@ mod unix { } fn jiff_format(fmt: &str, date: &Zoned) -> UResult { - jiff::fmt::strtime::format(fmt, date) - .map_err(|e| USimpleError::new(1, e.to_string())) + jiff::fmt::strtime::format(fmt, date).map_err(|e| USimpleError::new(1, e.to_string())) } fn preprocess_format(format: &str, date: &Zoned) -> UResult { @@ -136,11 +132,7 @@ mod unix { Ok(spec.literal_formats[count - 1].to_string()) } - fn consume_repeats( - chars: &mut Peekable>, - needle: char, - max: usize, - ) -> usize { + fn consume_repeats(chars: &mut Peekable>, needle: char, max: usize) -> usize { let mut count = 0; while count < max && matches!(chars.peek(), Some(ch) if *ch == needle) { chars.next(); From 2c977c50261744cddb7d19ceea9b616e3a0e34e5 Mon Sep 17 00:00:00 2001 From: mattsu Date: Tue, 30 Dec 2025 18:58:25 +0900 Subject: [PATCH 4/8] feat(date): add %-N format specifier for trimmed nanosecond display - Introduce format_nanos_padded() and format_nanos_trimmed() helper functions - Modify preprocess_format() to handle %-N, outputting nanoseconds without trailing zeros - Retain %N for padded nanoseconds (09 digits) to maintain backward compatibility --- src/uu/date/src/system_time.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/uu/date/src/system_time.rs b/src/uu/date/src/system_time.rs index 68d9824ccaa..2b74c5086b8 100644 --- a/src/uu/date/src/system_time.rs +++ b/src/uu/date/src/system_time.rs @@ -60,6 +60,14 @@ mod unix { jiff::fmt::strtime::format(fmt, date).map_err(|e| USimpleError::new(1, e.to_string())) } + fn format_nanos_padded(nanos: u32) -> String { + format!("{:09}", nanos) + } + + fn format_nanos_trimmed(nanos: u32) -> String { + format_nanos_padded(nanos).trim_end_matches('0').to_string() + } + fn preprocess_format(format: &str, date: &Zoned) -> UResult { let mut output = String::with_capacity(format.len()); let mut chars = format.chars().peekable(); @@ -84,7 +92,17 @@ mod unix { match next { 'N' => { let nanos = date.timestamp().subsec_nanosecond(); - Ok(format!("{:09}", nanos)) + Ok(format_nanos_padded(nanos)) + } + '-' => { + let Some(flagged) = chars.next() else { + return Ok("%-".to_string()); + }; + if flagged == 'N' { + let nanos = date.timestamp().subsec_nanosecond(); + return Ok(format_nanos_trimmed(nanos)); + } + Ok(format!("%-{flagged}")) } 's' => Ok(date.timestamp().as_second().to_string()), 'q' => { From bae10fe371dbc26865bbc0e7bbbea9a8b4224697 Mon Sep 17 00:00:00 2001 From: mattsu Date: Tue, 30 Dec 2025 19:09:40 +0900 Subject: [PATCH 5/8] refactor(date): enhance code clarity, safety, and nanosecond validation - Rename variables in gregorian_to_ethiopian for better readability - Add nanos_to_u32 function to validate nanosecond values and prevent out-of-range errors - Replace unsafe pointer casts with safe alternatives using cast_mut and from_ref - Update format! calls to use named parameters for consistency --- src/uu/date/src/system_time.rs | 43 +++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/src/uu/date/src/system_time.rs b/src/uu/date/src/system_time.rs index 2b74c5086b8..12b058493a5 100644 --- a/src/uu/date/src/system_time.rs +++ b/src/uu/date/src/system_time.rs @@ -39,18 +39,25 @@ mod unix { false } - fn gregorian_to_ethiopian(y: i32, m: i32, d: i32) -> (i32, i32, i32) { - let (m, y) = if m <= 2 { (m + 12, y - 1) } else { (m, y) }; - let jdn = (1461 * (y + 4800)) / 4 + (367 * (m - 2)) / 12 - (3 * ((y + 4900) / 100)) / 4 + d + fn gregorian_to_ethiopian(year: i32, month: i32, day: i32) -> (i32, i32, i32) { + let (adj_month, adj_year) = if month <= 2 { + (month + 12, year - 1) + } else { + (month, year) + }; + let jdn = (1461 * (adj_year + 4800)) / 4 + + (367 * (adj_month - 2)) / 12 + - (3 * ((adj_year + 4900) / 100)) / 4 + + day - 32075; - let n = jdn - 1724221; - let n_cycle = n / 1461; - let r = n % 1461; - let y_rel = r / 365; - let y_rel = if r == 1460 { 3 } else { y_rel }; - let year = 4 * n_cycle + y_rel + 1; - let day_of_year = r - y_rel * 365; + let days_since_epoch = jdn - 1724221; + let cycle = days_since_epoch / 1461; + let remainder = days_since_epoch % 1461; + let year_in_cycle = remainder / 365; + let year_in_cycle = if remainder == 1460 { 3 } else { year_in_cycle }; + let year = 4 * cycle + year_in_cycle + 1; + let day_of_year = remainder - year_in_cycle * 365; let month = day_of_year / 30 + 1; let day = day_of_year % 30 + 1; (year, month, day) @@ -61,13 +68,17 @@ mod unix { } fn format_nanos_padded(nanos: u32) -> String { - format!("{:09}", nanos) + format!("{nanos:09}") } fn format_nanos_trimmed(nanos: u32) -> String { format_nanos_padded(nanos).trim_end_matches('0').to_string() } + fn nanos_to_u32(nanos: i32) -> UResult { + u32::try_from(nanos).map_err(|_| USimpleError::new(1, "nanoseconds out of range")) + } + fn preprocess_format(format: &str, date: &Zoned) -> UResult { let mut output = String::with_capacity(format.len()); let mut chars = format.chars().peekable(); @@ -91,7 +102,7 @@ mod unix { match next { 'N' => { - let nanos = date.timestamp().subsec_nanosecond(); + let nanos = nanos_to_u32(date.timestamp().subsec_nanosecond())?; Ok(format_nanos_padded(nanos)) } '-' => { @@ -99,7 +110,7 @@ mod unix { return Ok("%-".to_string()); }; if flagged == 'N' { - let nanos = date.timestamp().subsec_nanosecond(); + let nanos = nanos_to_u32(date.timestamp().subsec_nanosecond())?; return Ok(format_nanos_trimmed(nanos)); } Ok(format!("%-{flagged}")) @@ -201,7 +212,7 @@ mod unix { .ok() .and_then(|abbrev| CString::new(abbrev).ok()); if let Some(ref zone) = zone_cstring { - tm.tm_zone = zone.as_ptr() as *mut i8; + tm.tm_zone = zone.as_ptr().cast_mut(); } zone_cstring } @@ -233,10 +244,10 @@ mod unix { // SAFETY: `format_c` is NUL-terminated, `tm` is a valid libc::tm, and `buffer` is writable. let ret = unsafe { libc::strftime( - buffer.as_mut_ptr() as *mut _, + buffer.as_mut_ptr().cast(), buffer.len(), format_c.as_ptr(), - tm as *const _, + std::ptr::from_ref(tm), ) }; From 7876354e507fc525fcd6f08ed9f038f317ece6da Mon Sep 17 00:00:00 2001 From: mattsu Date: Tue, 30 Dec 2025 19:10:54 +0900 Subject: [PATCH 6/8] refactor(date): combine JDN calculation terms for better readability - Merge the first two terms of the Julian Day Number formula into a single line to improve code formatting and consistency without altering functionality. --- src/uu/date/src/system_time.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/uu/date/src/system_time.rs b/src/uu/date/src/system_time.rs index 12b058493a5..a46ba43568f 100644 --- a/src/uu/date/src/system_time.rs +++ b/src/uu/date/src/system_time.rs @@ -45,8 +45,7 @@ mod unix { } else { (month, year) }; - let jdn = (1461 * (adj_year + 4800)) / 4 - + (367 * (adj_month - 2)) / 12 + let jdn = (1461 * (adj_year + 4800)) / 4 + (367 * (adj_month - 2)) / 12 - (3 * ((adj_year + 4900) / 100)) / 4 + day - 32075; From 66f3133b5c7e108ce431bf8ecb92c614b0b3ccfc Mon Sep 17 00:00:00 2001 From: mattsu Date: Tue, 30 Dec 2025 19:17:37 +0900 Subject: [PATCH 7/8] refactor(date): extract functions for Ethiopian date conversion and nanosecond formatting Extract julian_day_number and ethiopian_year_and_day functions to improve readability and modularity in gregorian_to_ethiopian. Introduce nanos_from and format_nanos_for_flag to simplify nanosecond handling in preprocess_format, reducing code duplication. --- src/uu/date/src/system_time.rs | 43 ++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/src/uu/date/src/system_time.rs b/src/uu/date/src/system_time.rs index a46ba43568f..ad987f99bb1 100644 --- a/src/uu/date/src/system_time.rs +++ b/src/uu/date/src/system_time.rs @@ -40,26 +40,36 @@ mod unix { } fn gregorian_to_ethiopian(year: i32, month: i32, day: i32) -> (i32, i32, i32) { + let julian_day = julian_day_number(year, month, day); + let days_since_epoch = julian_day - 1724221; + let (year, day_of_year) = ethiopian_year_and_day(days_since_epoch); + let month = day_of_year / 30 + 1; + let day = day_of_year % 30 + 1; + (year, month, day) + } + + fn julian_day_number(year: i32, month: i32, day: i32) -> i32 { let (adj_month, adj_year) = if month <= 2 { (month + 12, year - 1) } else { (month, year) }; - let jdn = (1461 * (adj_year + 4800)) / 4 + (367 * (adj_month - 2)) / 12 + + (1461 * (adj_year + 4800)) / 4 + + (367 * (adj_month - 2)) / 12 - (3 * ((adj_year + 4900) / 100)) / 4 + day - - 32075; + - 32075 + } - let days_since_epoch = jdn - 1724221; + fn ethiopian_year_and_day(days_since_epoch: i32) -> (i32, i32) { let cycle = days_since_epoch / 1461; let remainder = days_since_epoch % 1461; let year_in_cycle = remainder / 365; let year_in_cycle = if remainder == 1460 { 3 } else { year_in_cycle }; let year = 4 * cycle + year_in_cycle + 1; let day_of_year = remainder - year_in_cycle * 365; - let month = day_of_year / 30 + 1; - let day = day_of_year % 30 + 1; - (year, month, day) + (year, day_of_year) } fn jiff_format(fmt: &str, date: &Zoned) -> UResult { @@ -78,6 +88,19 @@ mod unix { u32::try_from(nanos).map_err(|_| USimpleError::new(1, "nanoseconds out of range")) } + fn nanos_from(date: &Zoned) -> UResult { + nanos_to_u32(date.timestamp().subsec_nanosecond()) + } + + fn format_nanos_for_flag(flag: Option, date: &Zoned) -> UResult { + let nanos = nanos_from(date)?; + if matches!(flag, Some('-')) { + Ok(format_nanos_trimmed(nanos)) + } else { + Ok(format_nanos_padded(nanos)) + } + } + fn preprocess_format(format: &str, date: &Zoned) -> UResult { let mut output = String::with_capacity(format.len()); let mut chars = format.chars().peekable(); @@ -100,17 +123,13 @@ mod unix { }; match next { - 'N' => { - let nanos = nanos_to_u32(date.timestamp().subsec_nanosecond())?; - Ok(format_nanos_padded(nanos)) - } + 'N' => format_nanos_for_flag(None, date), '-' => { let Some(flagged) = chars.next() else { return Ok("%-".to_string()); }; if flagged == 'N' { - let nanos = nanos_to_u32(date.timestamp().subsec_nanosecond())?; - return Ok(format_nanos_trimmed(nanos)); + return format_nanos_for_flag(Some('-'), date); } Ok(format!("%-{flagged}")) } From c94c8a4f3bd2a9293341a4e0dcf10094ef1b5230 Mon Sep 17 00:00:00 2001 From: mattsu Date: Tue, 30 Dec 2025 19:19:48 +0900 Subject: [PATCH 8/8] style(date): combine multi-line arithmetic expression into single line for conciseness Refactor the day-of-year calculation in system_time.rs to merge the addition of the two main terms onto one line, improving code readability without altering functionality. --- src/uu/date/src/system_time.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/uu/date/src/system_time.rs b/src/uu/date/src/system_time.rs index ad987f99bb1..2b47e185f35 100644 --- a/src/uu/date/src/system_time.rs +++ b/src/uu/date/src/system_time.rs @@ -55,8 +55,7 @@ mod unix { (month, year) }; - (1461 * (adj_year + 4800)) / 4 - + (367 * (adj_month - 2)) / 12 + (1461 * (adj_year + 4800)) / 4 + (367 * (adj_month - 2)) / 12 - (3 * ((adj_year + 4900) / 100)) / 4 + day - 32075