From b17129d8ee50b83001092d75d8abd23cbff04579 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 25 Oct 2025 21:21:12 +0200 Subject: [PATCH 1/4] date: add ignored tests that passes with GNU and fails with Rust --- tests/by-util/test_date.rs | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index eb7a5e0fc..c3e7a132d 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -944,3 +944,41 @@ fn test_date_tz_abbreviation_unknown() { .fails() .stderr_contains("invalid date"); } + +#[test] +#[ignore = "we reject 'J', GNU treats as midnight"] +fn test_date_fuzz_military_timezone_j() { + // J is reserved for local time in military timezones + // GNU date treats it as midnight, we reject it + new_ucmd!() + .env("TZ", "UTC+1") + .arg("-d") + .arg("J") + .arg("+%F %T %Z") + .succeeds() + .stdout_contains("00:00:00"); +} + +#[test] +#[ignore = "we use current time, GNU uses midnight"] +fn test_date_fuzz_empty_string() { + // Empty string should be treated as midnight today + new_ucmd!() + .env("TZ", "UTC+1") + .arg("-d") + .arg("") + .succeeds() + .stdout_contains("00:00:00"); +} + +#[test] +#[ignore = "we produce year 0008, GNU gives today 12:00"] +fn test_date_fuzz_relative_m9() { + // Relative date string "m9" should be parsed correctly + new_ucmd!() + .env("TZ", "UTC+9") + .arg("-d") + .arg("m9") + .succeeds() + .stdout_contains("12:00:00"); +} From 284554658d9c1acc7a855ca4327089b33ecbd9a5 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 25 Oct 2025 22:00:02 +0200 Subject: [PATCH 2/4] date: add support for the 'J' military timezone --- src/uu/date/src/date.rs | 24 ++++++++++++++++++++++-- tests/by-util/test_date.rs | 27 ++++++++++++++++++++------- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index e4c2b286d..3052d1c5e 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -205,15 +205,35 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // Iterate over all dates - whether it's a single date or a file. let dates: Box> = match settings.date_source { DateSource::Human(ref input) => { + // GNU compatibility (Military timezone 'J'): + // 'J' is reserved for local time in military timezones. + // GNU date accepts it and treats it as midnight today (00:00:00). + let is_military_j = input.eq_ignore_ascii_case("j"); + // GNU compatibility (Pure numbers in date strings): // - Manual: https://www.gnu.org/software/coreutils/manual/html_node/Pure-numbers-in-date-strings.html - // - Semantics: a pure decimal number denotes today’s time-of-day (HH or HHMM). + // - Semantics: a pure decimal number denotes today's time-of-day (HH or HHMM). // Examples: "0"/"00" => 00:00 today; "7"/"07" => 07:00 today; "0700" => 07:00 today. // For all other forms, fall back to the general parser. let is_pure_digits = !input.is_empty() && input.len() <= 4 && input.chars().all(|c| c.is_ascii_digit()); - let date = if is_pure_digits { + let date = if is_military_j { + // Treat 'J' as midnight today (00:00:00) in local time + let date_part = + strtime::format("%F", &now).unwrap_or_else(|_| String::from("1970-01-01")); + let offset = if settings.utc { + String::from("+00:00") + } else { + strtime::format("%:z", &now).unwrap_or_default() + }; + let composed = if offset.is_empty() { + format!("{date_part} 00:00") + } else { + format!("{date_part} 00:00 {offset}") + }; + parse_date(composed) + } else if is_pure_digits { // Derive HH and MM from the input let (hh_opt, mm_opt) = if input.len() <= 2 { (input.parse::().ok(), Some(0u32)) diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index c3e7a132d..94492d765 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -946,17 +946,30 @@ fn test_date_tz_abbreviation_unknown() { } #[test] -#[ignore = "we reject 'J', GNU treats as midnight"] -fn test_date_fuzz_military_timezone_j() { - // J is reserved for local time in military timezones - // GNU date treats it as midnight, we reject it +fn test_date_military_timezone_j_variations() { + // Test multiple variations of 'J' input (case insensitive, with whitespace) + // All should produce midnight (00:00:00) + let test_cases = vec!["J", "j", " J ", " j ", "\tJ\t"]; + + for input in test_cases { + new_ucmd!() + .env("TZ", "UTC") + .arg("-d") + .arg(input) + .arg("+%T") + .succeeds() + .stdout_is("00:00:00\n"); + } + + // Test with -u flag to verify UTC behavior new_ucmd!() - .env("TZ", "UTC+1") + .arg("-u") .arg("-d") .arg("J") - .arg("+%F %T %Z") + .arg("+%T %Z") .succeeds() - .stdout_contains("00:00:00"); + .stdout_contains("00:00:00") + .stdout_contains("UTC"); } #[test] From 5cae9ae53e0e29f53c2dd9df2d8bdfa7cd15c98d Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 25 Oct 2025 22:06:13 +0200 Subject: [PATCH 3/4] date: -d empty string should be treated as midnight today --- src/uu/date/src/date.rs | 9 +++++++-- tests/by-util/test_date.rs | 30 ++++++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index 3052d1c5e..1bbaaa02f 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -205,6 +205,11 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // Iterate over all dates - whether it's a single date or a file. let dates: Box> = match settings.date_source { DateSource::Human(ref input) => { + let input = input.trim(); + // GNU compatibility (Empty string): + // An empty string (or whitespace-only) should be treated as midnight today. + let is_empty_or_whitespace = input.is_empty(); + // GNU compatibility (Military timezone 'J'): // 'J' is reserved for local time in military timezones. // GNU date accepts it and treats it as midnight today (00:00:00). @@ -218,8 +223,8 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let is_pure_digits = !input.is_empty() && input.len() <= 4 && input.chars().all(|c| c.is_ascii_digit()); - let date = if is_military_j { - // Treat 'J' as midnight today (00:00:00) in local time + let date = if is_empty_or_whitespace || is_military_j { + // Treat empty string or 'J' as midnight today (00:00:00) in local time let date_part = strtime::format("%F", &now).unwrap_or_else(|_| String::from("1970-01-01")); let offset = if settings.utc { diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index 94492d765..8fc5ca126 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -973,8 +973,7 @@ fn test_date_military_timezone_j_variations() { } #[test] -#[ignore = "we use current time, GNU uses midnight"] -fn test_date_fuzz_empty_string() { +fn test_date_empty_string() { // Empty string should be treated as midnight today new_ucmd!() .env("TZ", "UTC+1") @@ -984,6 +983,33 @@ fn test_date_fuzz_empty_string() { .stdout_contains("00:00:00"); } +#[test] +fn test_date_empty_string_variations() { + // Test multiple variations of empty/whitespace strings + // All should produce midnight (00:00:00) + let test_cases = vec!["", " ", " ", "\t", "\n", " \t ", "\t\n\t"]; + + for input in test_cases { + new_ucmd!() + .env("TZ", "UTC") + .arg("-d") + .arg(input) + .arg("+%T") + .succeeds() + .stdout_is("00:00:00\n"); + } + + // Test with -u flag to verify UTC behavior + new_ucmd!() + .arg("-u") + .arg("-d") + .arg("") + .arg("+%T %Z") + .succeeds() + .stdout_contains("00:00:00") + .stdout_contains("UTC"); +} + #[test] #[ignore = "we produce year 0008, GNU gives today 12:00"] fn test_date_fuzz_relative_m9() { From d87a5b88a3bc97febe890c50aced79f94e20346d Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 25 Oct 2025 22:25:34 +0200 Subject: [PATCH 4/4] date: support(Military timezone with optional hour offset --- src/uu/date/src/date.rs | 86 ++++++++++++++++++++++++++++++++++++++ tests/by-util/test_date.rs | 34 +++++++++++++-- 2 files changed, 117 insertions(+), 3 deletions(-) diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index 1bbaaa02f..e6e57c678 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -117,6 +117,57 @@ impl From<&str> for Rfc3339Format { } } +/// Parse military timezone with optional hour offset. +/// Pattern: single letter (a-z except j) optionally followed by 1-2 digits. +/// Returns Some(total_hours_in_utc) or None if pattern doesn't match. +/// +/// Military timezone mappings: +/// - A-I: UTC+1 to UTC+9 (J is skipped for local time) +/// - K-M: UTC+10 to UTC+12 +/// - N-Y: UTC-1 to UTC-12 +/// - Z: UTC+0 +/// +/// The hour offset from digits is added to the base military timezone offset. +/// Examples: "m" -> 12 (noon UTC), "m9" -> 21 (9pm UTC), "a5" -> 4 (4am UTC next day) +fn parse_military_timezone_with_offset(s: &str) -> Option { + if s.is_empty() || s.len() > 3 { + return None; + } + + let mut chars = s.chars(); + let letter = chars.next()?.to_ascii_lowercase(); + + // Check if first character is a letter (a-z, except j which is handled separately) + if !letter.is_ascii_lowercase() || letter == 'j' { + return None; + } + + // Parse optional digits (1-2 digits for hour offset) + let additional_hours: i32 = if let Some(rest) = chars.as_str().chars().next() { + if !rest.is_ascii_digit() { + return None; + } + chars.as_str().parse().ok()? + } else { + 0 + }; + + // Map military timezone letter to UTC offset + let tz_offset = match letter { + 'a'..='i' => (letter as i32 - 'a' as i32) + 1, // A=+1, B=+2, ..., I=+9 + 'k'..='m' => (letter as i32 - 'k' as i32) + 10, // K=+10, L=+11, M=+12 + 'n'..='y' => -((letter as i32 - 'n' as i32) + 1), // N=-1, O=-2, ..., Y=-12 + 'z' => 0, // Z=+0 + _ => return None, + }; + + // Calculate total hours: midnight (0) + tz_offset + additional_hours + // Midnight in timezone X converted to UTC + let total_hours = (0 - tz_offset + additional_hours).rem_euclid(24); + + Some(total_hours) +} + #[uucore::main] #[allow(clippy::cognitive_complexity)] pub fn uumain(args: impl uucore::Args) -> UResult<()> { @@ -215,6 +266,13 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // GNU date accepts it and treats it as midnight today (00:00:00). let is_military_j = input.eq_ignore_ascii_case("j"); + // GNU compatibility (Military timezone with optional hour offset): + // Single letter (a-z except j) optionally followed by 1-2 digits. + // Letter represents midnight in that military timezone (UTC offset). + // Digits represent additional hours to add. + // Examples: "m" -> noon UTC (12:00); "m9" -> 21:00 UTC; "a5" -> 04:00 UTC + let military_tz_with_offset = parse_military_timezone_with_offset(input); + // GNU compatibility (Pure numbers in date strings): // - Manual: https://www.gnu.org/software/coreutils/manual/html_node/Pure-numbers-in-date-strings.html // - Semantics: a pure decimal number denotes today's time-of-day (HH or HHMM). @@ -238,6 +296,13 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { format!("{date_part} 00:00 {offset}") }; parse_date(composed) + } else if let Some(total_hours) = military_tz_with_offset { + // Military timezone with optional hour offset + // Convert to UTC time: midnight + military_tz_offset + additional_hours + let date_part = + strtime::format("%F", &now).unwrap_or_else(|_| String::from("1970-01-01")); + let composed = format!("{date_part} {total_hours:02}:00:00 +00:00"); + parse_date(composed) } else if is_pure_digits { // Derive HH and MM from the input let (hh_opt, mm_opt) = if input.len() <= 2 { @@ -742,3 +807,24 @@ fn set_system_datetime(date: Zoned) -> UResult<()> { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_military_timezone_with_offset() { + // Valid cases: letter only, letter + digit, uppercase + assert_eq!(parse_military_timezone_with_offset("m"), Some(12)); // UTC+12 -> 12:00 UTC + assert_eq!(parse_military_timezone_with_offset("m9"), Some(21)); // 12 + 9 = 21 + assert_eq!(parse_military_timezone_with_offset("a5"), Some(4)); // 23 + 5 = 28 % 24 = 4 + assert_eq!(parse_military_timezone_with_offset("z"), Some(0)); // UTC+0 -> 00:00 UTC + assert_eq!(parse_military_timezone_with_offset("M9"), Some(21)); // Uppercase works + + // Invalid cases: 'j' reserved, empty, too long, starts with digit + assert_eq!(parse_military_timezone_with_offset("j"), None); // Reserved for local time + assert_eq!(parse_military_timezone_with_offset(""), None); // Empty + assert_eq!(parse_military_timezone_with_offset("m999"), None); // Too long + assert_eq!(parse_military_timezone_with_offset("9m"), None); // Starts with digit + } +} diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index 8fc5ca126..372e91937 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -1011,9 +1011,9 @@ fn test_date_empty_string_variations() { } #[test] -#[ignore = "we produce year 0008, GNU gives today 12:00"] -fn test_date_fuzz_relative_m9() { - // Relative date string "m9" should be parsed correctly +fn test_date_relative_m9() { + // Military timezone "m9" should be parsed as noon + 9 hours = 21:00 UTC + // When displayed in TZ=UTC+9 (which is UTC-9), this shows as 12:00 local time new_ucmd!() .env("TZ", "UTC+9") .arg("-d") @@ -1021,3 +1021,31 @@ fn test_date_fuzz_relative_m9() { .succeeds() .stdout_contains("12:00:00"); } + +#[test] +fn test_date_military_timezone_with_offset_variations() { + // Test various military timezone + offset combinations + // Format: single letter (a-z except j) optionally followed by 1-2 digits + + // Test cases: (input, expected_time_utc) + let test_cases = vec![ + ("a", "23:00:00"), // A = UTC+1, midnight in UTC+1 = 23:00 UTC + ("m", "12:00:00"), // M = UTC+12, midnight in UTC+12 = 12:00 UTC + ("z", "00:00:00"), // Z = UTC+0, midnight in UTC+0 = 00:00 UTC + ("m9", "21:00:00"), // M + 9 hours = 12 + 9 = 21:00 UTC + ("a5", "04:00:00"), // A + 5 hours = 23 + 5 = 04:00 UTC (next day) + ("z3", "03:00:00"), // Z + 3 hours = 00 + 3 = 03:00 UTC + ("M", "12:00:00"), // Uppercase should work too + ("A5", "04:00:00"), // Uppercase with offset + ]; + + for (input, expected) in test_cases { + new_ucmd!() + .env("TZ", "UTC") + .arg("-d") + .arg(input) + .arg("+%T") + .succeeds() + .stdout_is(format!("{expected}\n")); + } +}