From 64e4eef3c08fbce1f5ba19eec6a7ec5268e7268d Mon Sep 17 00:00:00 2001 From: cerdelen Date: Mon, 22 Dec 2025 19:23:37 +0100 Subject: [PATCH 1/9] date: fix military tz parsing not calculating day delta --- src/uu/date/src/date.rs | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index d02ca4a472b..f9b5f5b4ade 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -115,6 +115,12 @@ impl From<&str> for Rfc3339Format { } } +enum DayDelta { + Same, + Previous, + Next +} + /// 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. @@ -127,7 +133,7 @@ impl From<&str> for Rfc3339Format { /// /// 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 { +fn parse_military_timezone_with_offset(s: &str) -> Option<(i32, DayDelta)> { if s.is_empty() || s.len() > 3 { return None; } @@ -159,11 +165,17 @@ fn parse_military_timezone_with_offset(s: &str) -> Option { _ => return None, }; + let day_delta = match additional_hours - tz_offset { + h if h < 0 => DayDelta::Previous, + h if h >= 24 => DayDelta::Next, + _ => DayDelta::Same, + }; + // 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) + Some((total_hours, day_delta)) } #[uucore::main] @@ -305,11 +317,20 @@ 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 { + } else if let Some((total_hours, day_delta)) = 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 date_part = match day_delta { + DayDelta::Same => { + strtime::format("%F", &now).unwrap_or_else(|_| String::from("1970-01-01")) + }, + DayDelta::Next => { + strtime::format("%F", &now.tomorrow().unwrap()).unwrap_or_else(|_| String::from("1970-01-01")) + }, + DayDelta::Previous => { + strtime::format("%F", &now.yesterday().unwrap()).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 { From 12f4cce118e342268e751a48958fdb6e9cf55183 Mon Sep 17 00:00:00 2001 From: cerdelen Date: Mon, 22 Dec 2025 20:14:46 +0100 Subject: [PATCH 2/9] date: add regression test for military time parsing tz correctly --- tests/by-util/test_date.rs | 46 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index 319e3ab0397..ce7c101254b 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -1132,6 +1132,52 @@ fn test_date_military_timezone_with_offset_variations() { } } +#[test] +fn test_date_military_timezone_with_offset_and_date() { + use chrono::{Duration, Utc}; + + let today = Utc::now().date_naive(); + + let test_cases = vec![ + ("m", -1), // M = UTC+12 + ("a", -1), // A = UTC+1 + ("n", 0), // N = UTC-1 + ("y", 0), // N = UTC-12 + ("z", 0), // Z = UTC + + // same day hour offsets + ("n2", 0), + + // midnight crossings with hour offsets back to today + ("a1", 0), // exactly to midnight + ("a5", 0), // "overflow" midnight + ("m23", 0), + + // midnight crossings with hour offsets to tomorrow + ("n23", 1), + ("y23", 1), + ]; + + for (input, day_delta) in test_cases { + let expected_date = today + .checked_add_signed(Duration::days(day_delta)) + .unwrap(); + + let expected = format!( + "{}\n", + expected_date.format("%F"), + ); + + new_ucmd!() + .env("TZ", "UTC") + .arg("-d") + .arg(input) + .arg("+%F") + .succeeds() + .stdout_is(expected); + } +} + // Locale-aware hour formatting tests #[test] #[cfg(unix)] From ce6f41ca7f39431ca3ac5d30c197d8e6ead02545 Mon Sep 17 00:00:00 2001 From: cerdelen Date: Mon, 22 Dec 2025 20:53:13 +0100 Subject: [PATCH 3/9] date: fix test in same file --- src/uu/date/src/date.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index f9b5f5b4ade..12cc02a872b 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -115,6 +115,7 @@ impl From<&str> for Rfc3339Format { } } +#[derive(PartialEq, Debug)] enum DayDelta { Same, Previous, @@ -837,11 +838,11 @@ mod tests { #[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 + assert_eq!(parse_military_timezone_with_offset("m"), Some((12, DayDelta::Previous))); // UTC+12 -> 12:00 UTC + assert_eq!(parse_military_timezone_with_offset("m9"), Some((21, DayDelta::Previous))); // 12 + 9 = 21 + assert_eq!(parse_military_timezone_with_offset("a5"), Some((4, DayDelta::Same))); // 23 + 5 = 28 % 24 = 4 + assert_eq!(parse_military_timezone_with_offset("z"), Some((0, DayDelta::Same))); // UTC+0 -> 00:00 UTC + assert_eq!(parse_military_timezone_with_offset("M9"), Some((21, DayDelta::Previous))); // 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 From 38b71d4ac7da72b75eb2b464ad16320b502d0131 Mon Sep 17 00:00:00 2001 From: cerdelen Date: Tue, 23 Dec 2025 10:00:02 +0100 Subject: [PATCH 4/9] date: remove the unwrap() --- src/uu/date/src/date.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index 12cc02a872b..4bc2e616702 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -326,10 +326,14 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { strtime::format("%F", &now).unwrap_or_else(|_| String::from("1970-01-01")) }, DayDelta::Next => { - strtime::format("%F", &now.tomorrow().unwrap()).unwrap_or_else(|_| String::from("1970-01-01")) + now.tomorrow() + .and_then(|d| strtime::format("%F", &d)) + .unwrap_or_else(|_| String::from("1970-01-01")) }, DayDelta::Previous => { - strtime::format("%F", &now.yesterday().unwrap()).unwrap_or_else(|_| String::from("1970-01-01")) + now.yesterday() + .and_then(|d| strtime::format("%F", &d)) + .unwrap_or_else(|_| String::from("1970-01-01")) }, }; let composed = format!("{date_part} {total_hours:02}:00:00 +00:00"); From 595f1665a0bf4a1c3c9a05005c3ddd3982feba04 Mon Sep 17 00:00:00 2001 From: cerdelen Date: Tue, 23 Dec 2025 10:01:19 +0100 Subject: [PATCH 5/9] cargo fmt --- src/uu/date/src/date.rs | 47 ++++++++++++++++++++++++-------------- tests/by-util/test_date.rs | 18 ++++----------- 2 files changed, 35 insertions(+), 30 deletions(-) diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index 4bc2e616702..c25904bde92 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -119,7 +119,7 @@ impl From<&str> for Rfc3339Format { enum DayDelta { Same, Previous, - Next + Next, } /// Parse military timezone with optional hour offset. @@ -324,17 +324,15 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let date_part = match day_delta { DayDelta::Same => { strtime::format("%F", &now).unwrap_or_else(|_| String::from("1970-01-01")) - }, - DayDelta::Next => { - now.tomorrow() - .and_then(|d| strtime::format("%F", &d)) - .unwrap_or_else(|_| String::from("1970-01-01")) - }, - DayDelta::Previous => { - now.yesterday() - .and_then(|d| strtime::format("%F", &d)) - .unwrap_or_else(|_| String::from("1970-01-01")) - }, + } + DayDelta::Next => now + .tomorrow() + .and_then(|d| strtime::format("%F", &d)) + .unwrap_or_else(|_| String::from("1970-01-01")), + DayDelta::Previous => now + .yesterday() + .and_then(|d| strtime::format("%F", &d)) + .unwrap_or_else(|_| String::from("1970-01-01")), }; let composed = format!("{date_part} {total_hours:02}:00:00 +00:00"); parse_date(composed) @@ -842,11 +840,26 @@ mod tests { #[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, DayDelta::Previous))); // UTC+12 -> 12:00 UTC - assert_eq!(parse_military_timezone_with_offset("m9"), Some((21, DayDelta::Previous))); // 12 + 9 = 21 - assert_eq!(parse_military_timezone_with_offset("a5"), Some((4, DayDelta::Same))); // 23 + 5 = 28 % 24 = 4 - assert_eq!(parse_military_timezone_with_offset("z"), Some((0, DayDelta::Same))); // UTC+0 -> 00:00 UTC - assert_eq!(parse_military_timezone_with_offset("M9"), Some((21, DayDelta::Previous))); // Uppercase works + assert_eq!( + parse_military_timezone_with_offset("m"), + Some((12, DayDelta::Previous)) + ); // UTC+12 -> 12:00 UTC + assert_eq!( + parse_military_timezone_with_offset("m9"), + Some((21, DayDelta::Previous)) + ); // 12 + 9 = 21 + assert_eq!( + parse_military_timezone_with_offset("a5"), + Some((4, DayDelta::Same)) + ); // 23 + 5 = 28 % 24 = 4 + assert_eq!( + parse_military_timezone_with_offset("z"), + Some((0, DayDelta::Same)) + ); // UTC+0 -> 00:00 UTC + assert_eq!( + parse_military_timezone_with_offset("M9"), + Some((21, DayDelta::Previous)) + ); // 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 diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index ce7c101254b..8bd8ba5d27d 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -1141,32 +1141,24 @@ fn test_date_military_timezone_with_offset_and_date() { let test_cases = vec![ ("m", -1), // M = UTC+12 ("a", -1), // A = UTC+1 - ("n", 0), // N = UTC-1 - ("y", 0), // N = UTC-12 - ("z", 0), // Z = UTC - + ("n", 0), // N = UTC-1 + ("y", 0), // N = UTC-12 + ("z", 0), // Z = UTC // same day hour offsets ("n2", 0), - // midnight crossings with hour offsets back to today ("a1", 0), // exactly to midnight ("a5", 0), // "overflow" midnight ("m23", 0), - // midnight crossings with hour offsets to tomorrow ("n23", 1), ("y23", 1), ]; for (input, day_delta) in test_cases { - let expected_date = today - .checked_add_signed(Duration::days(day_delta)) - .unwrap(); + let expected_date = today.checked_add_signed(Duration::days(day_delta)).unwrap(); - let expected = format!( - "{}\n", - expected_date.format("%F"), - ); + let expected = format!("{}\n", expected_date.format("%F"),); new_ucmd!() .env("TZ", "UTC") From c459c09d3cfe90463134fc8c7b45d176d06e8d16 Mon Sep 17 00:00:00 2001 From: cerdelen Date: Tue, 23 Dec 2025 10:35:44 +0100 Subject: [PATCH 6/9] date: document military time parsing DayDelta --- src/uu/date/src/date.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index c25904bde92..78406bd46f8 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -115,10 +115,17 @@ impl From<&str> for Rfc3339Format { } } +/// Indicates whether parsing a military timezone causes the date to remain the same, roll back to the previous day, or +/// advance to the next day. +/// This can occur when applying a military timezone with an optional hour offset crosses midnight +/// in eihter direction. #[derive(PartialEq, Debug)] enum DayDelta { + /// The date does not change Same, + /// The date rolls back to the previous day. Previous, + /// The date advances to the next day. Next, } @@ -321,6 +328,10 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } else if let Some((total_hours, day_delta)) = military_tz_with_offset { // Military timezone with optional hour offset // Convert to UTC time: midnight + military_tz_offset + additional_hours + + // When calculating a military timezone with an optional hour offset, midgnight may + // be crossed in either direction. `day_delta` indicates wether the date remains + // the same, moves to the previous day, or advances to the next day. let date_part = match day_delta { DayDelta::Same => { strtime::format("%F", &now).unwrap_or_else(|_| String::from("1970-01-01")) From 15971b72b7b8bc30ed6bf341073a58bd4c05d865 Mon Sep 17 00:00:00 2001 From: cerdelen <95369756+cerdelen@users.noreply.github.com> Date: Tue, 23 Dec 2025 10:44:28 +0100 Subject: [PATCH 7/9] Update src/uu/date/src/date.rs Co-authored-by: Sylvestre Ledru --- src/uu/date/src/date.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index 78406bd46f8..f9846147b00 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -118,7 +118,7 @@ impl From<&str> for Rfc3339Format { /// Indicates whether parsing a military timezone causes the date to remain the same, roll back to the previous day, or /// advance to the next day. /// This can occur when applying a military timezone with an optional hour offset crosses midnight -/// in eihter direction. +/// in either direction. #[derive(PartialEq, Debug)] enum DayDelta { /// The date does not change From 32c6c78e6b09f84d3b547df802b5eb67ccf8b94c Mon Sep 17 00:00:00 2001 From: cerdelen <95369756+cerdelen@users.noreply.github.com> Date: Tue, 23 Dec 2025 10:44:34 +0100 Subject: [PATCH 8/9] Update src/uu/date/src/date.rs Co-authored-by: Sylvestre Ledru --- src/uu/date/src/date.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index f9846147b00..aa44c3889cd 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -329,7 +329,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // Military timezone with optional hour offset // Convert to UTC time: midnight + military_tz_offset + additional_hours - // When calculating a military timezone with an optional hour offset, midgnight may + // When calculating a military timezone with an optional hour offset, midnight may // be crossed in either direction. `day_delta` indicates wether the date remains // the same, moves to the previous day, or advances to the next day. let date_part = match day_delta { From df7f94cc2a6a21a5aca8817f2378ff6e0670db69 Mon Sep 17 00:00:00 2001 From: cerdelen Date: Wed, 24 Dec 2025 11:41:31 +0100 Subject: [PATCH 9/9] date: simplified error handling --- src/uu/date/src/date.rs | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index aa44c3889cd..56af84b399f 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -332,18 +332,16 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // When calculating a military timezone with an optional hour offset, midnight may // be crossed in either direction. `day_delta` indicates wether the date remains // the same, moves to the previous day, or advances to the next day. + // Changing day can result in error, this closure will help handle these errors + // gracefully. + let format_date_closure = |date: Result| -> String { + date.and_then(|d| strtime::format("%F", &d)) + .unwrap_or_else(|_| String::from("1970-01-01")) + }; let date_part = match day_delta { - DayDelta::Same => { - strtime::format("%F", &now).unwrap_or_else(|_| String::from("1970-01-01")) - } - DayDelta::Next => now - .tomorrow() - .and_then(|d| strtime::format("%F", &d)) - .unwrap_or_else(|_| String::from("1970-01-01")), - DayDelta::Previous => now - .yesterday() - .and_then(|d| strtime::format("%F", &d)) - .unwrap_or_else(|_| String::from("1970-01-01")), + DayDelta::Same => format_date_closure(Ok(now)), + DayDelta::Next => format_date_closure(now.tomorrow()), + DayDelta::Previous => format_date_closure(now.yesterday()), }; let composed = format!("{date_part} {total_hours:02}:00:00 +00:00"); parse_date(composed)