diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index c72b1c3048b..b4bf12ff35a 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -12,6 +12,7 @@ use jiff::fmt::strtime; use jiff::tz::{TimeZone, TimeZoneDatabase}; use jiff::{Timestamp, Zoned}; use std::collections::HashMap; +use std::ffi::{CStr, CString}; use std::fs::File; use std::io::{BufRead, BufReader}; use std::path::PathBuf; @@ -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) = 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) @@ -830,3 +840,260 @@ mod tests { assert_eq!(parse_military_timezone_with_offset("9m"), None); // Starts with digit } } +#[cfg(unix)] +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 +} + +#[cfg(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 + - 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) +} + +#[cfg(unix)] +fn format_using_strftime(format: &str, date: &Zoned) -> UResult { + use nix::libc; + + // 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(); + // eprintln!("DEBUG: Nanos: {}, Full Date: {:?}", nanos, date); + 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..7d27e51f8d2 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();