Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 58 additions & 10 deletions src/uu/date/src/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<i32> {
fn parse_military_timezone_with_offset(s: &str) -> Option<(i32, DayDelta)> {
if s.is_empty() || s.len() > 3 {
return None;
}
Expand Down Expand Up @@ -159,11 +173,17 @@ fn parse_military_timezone_with_offset(s: &str) -> Option<i32> {
_ => 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]
Expand Down Expand Up @@ -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<Zoned, _>| -> 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 {
Expand Down Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions tests/by-util/test_date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
Loading