diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index d02ca4a472b..56af84b399f 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -115,6 +115,20 @@ 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 either 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, +} + /// 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 +141,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 +173,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 +325,24 @@ 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")); + + // 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 => 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) } else if is_pure_digits { @@ -816,11 +849,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)); // 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 diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index 319e3ab0397..8bd8ba5d27d 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -1132,6 +1132,44 @@ 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)]