Skip to content

Commit 84e6f03

Browse files
authored
Merge pull request #9785 from cerdelen/fix_date_military_parsing
Fix military date parsing not adjusting date
1 parent f60072d commit 84e6f03

File tree

2 files changed

+99
-11
lines changed

2 files changed

+99
-11
lines changed

src/uu/date/src/date.rs

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,20 @@ impl From<&str> for Rfc3339Format {
116116
}
117117
}
118118

119+
/// Indicates whether parsing a military timezone causes the date to remain the same, roll back to the previous day, or
120+
/// advance to the next day.
121+
/// This can occur when applying a military timezone with an optional hour offset crosses midnight
122+
/// in either direction.
123+
#[derive(PartialEq, Debug)]
124+
enum DayDelta {
125+
/// The date does not change
126+
Same,
127+
/// The date rolls back to the previous day.
128+
Previous,
129+
/// The date advances to the next day.
130+
Next,
131+
}
132+
119133
/// Parse military timezone with optional hour offset.
120134
/// Pattern: single letter (a-z except j) optionally followed by 1-2 digits.
121135
/// Returns Some(total_hours_in_utc) or None if pattern doesn't match.
@@ -128,7 +142,7 @@ impl From<&str> for Rfc3339Format {
128142
///
129143
/// The hour offset from digits is added to the base military timezone offset.
130144
/// Examples: "m" -> 12 (noon UTC), "m9" -> 21 (9pm UTC), "a5" -> 4 (4am UTC next day)
131-
fn parse_military_timezone_with_offset(s: &str) -> Option<i32> {
145+
fn parse_military_timezone_with_offset(s: &str) -> Option<(i32, DayDelta)> {
132146
if s.is_empty() || s.len() > 3 {
133147
return None;
134148
}
@@ -160,11 +174,17 @@ fn parse_military_timezone_with_offset(s: &str) -> Option<i32> {
160174
_ => return None,
161175
};
162176

177+
let day_delta = match additional_hours - tz_offset {
178+
h if h < 0 => DayDelta::Previous,
179+
h if h >= 24 => DayDelta::Next,
180+
_ => DayDelta::Same,
181+
};
182+
163183
// Calculate total hours: midnight (0) + tz_offset + additional_hours
164184
// Midnight in timezone X converted to UTC
165-
let total_hours = (0 - tz_offset + additional_hours).rem_euclid(24);
185+
let hours_from_midnight = (0 - tz_offset + additional_hours).rem_euclid(24);
166186

167-
Some(total_hours)
187+
Some((hours_from_midnight, day_delta))
168188
}
169189

170190
#[uucore::main]
@@ -306,11 +326,24 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
306326
format!("{date_part} 00:00 {offset}")
307327
};
308328
parse_date(composed)
309-
} else if let Some(total_hours) = military_tz_with_offset {
329+
} else if let Some((total_hours, day_delta)) = military_tz_with_offset {
310330
// Military timezone with optional hour offset
311331
// Convert to UTC time: midnight + military_tz_offset + additional_hours
312-
let date_part =
313-
strtime::format("%F", &now).unwrap_or_else(|_| String::from("1970-01-01"));
332+
333+
// When calculating a military timezone with an optional hour offset, midnight may
334+
// be crossed in either direction. `day_delta` indicates whether the date remains
335+
// the same, moves to the previous day, or advances to the next day.
336+
// Changing day can result in error, this closure will help handle these errors
337+
// gracefully.
338+
let format_date_with_epoch_fallback = |date: Result<Zoned, _>| -> String {
339+
date.and_then(|d| strtime::format("%F", &d))
340+
.unwrap_or_else(|_| String::from("1970-01-01"))
341+
};
342+
let date_part = match day_delta {
343+
DayDelta::Same => format_date_with_epoch_fallback(Ok(now)),
344+
DayDelta::Next => format_date_with_epoch_fallback(now.tomorrow()),
345+
DayDelta::Previous => format_date_with_epoch_fallback(now.yesterday()),
346+
};
314347
let composed = format!("{date_part} {total_hours:02}:00:00 +00:00");
315348
parse_date(composed)
316349
} else if is_pure_digits {
@@ -817,11 +850,26 @@ mod tests {
817850
#[test]
818851
fn test_parse_military_timezone_with_offset() {
819852
// Valid cases: letter only, letter + digit, uppercase
820-
assert_eq!(parse_military_timezone_with_offset("m"), Some(12)); // UTC+12 -> 12:00 UTC
821-
assert_eq!(parse_military_timezone_with_offset("m9"), Some(21)); // 12 + 9 = 21
822-
assert_eq!(parse_military_timezone_with_offset("a5"), Some(4)); // 23 + 5 = 28 % 24 = 4
823-
assert_eq!(parse_military_timezone_with_offset("z"), Some(0)); // UTC+0 -> 00:00 UTC
824-
assert_eq!(parse_military_timezone_with_offset("M9"), Some(21)); // Uppercase works
853+
assert_eq!(
854+
parse_military_timezone_with_offset("m"),
855+
Some((12, DayDelta::Previous))
856+
); // UTC+12 -> 12:00 UTC
857+
assert_eq!(
858+
parse_military_timezone_with_offset("m9"),
859+
Some((21, DayDelta::Previous))
860+
); // 12 + 9 = 21
861+
assert_eq!(
862+
parse_military_timezone_with_offset("a5"),
863+
Some((4, DayDelta::Same))
864+
); // 23 + 5 = 28 % 24 = 4
865+
assert_eq!(
866+
parse_military_timezone_with_offset("z"),
867+
Some((0, DayDelta::Same))
868+
); // UTC+0 -> 00:00 UTC
869+
assert_eq!(
870+
parse_military_timezone_with_offset("M9"),
871+
Some((21, DayDelta::Previous))
872+
); // Uppercase works
825873

826874
// Invalid cases: 'j' reserved, empty, too long, starts with digit
827875
assert_eq!(parse_military_timezone_with_offset("j"), None); // Reserved for local time

tests/by-util/test_date.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1132,6 +1132,46 @@ fn test_date_military_timezone_with_offset_variations() {
11321132
}
11331133
}
11341134

1135+
#[test]
1136+
fn test_date_military_timezone_with_offset_and_date() {
1137+
use chrono::{Duration, Utc};
1138+
1139+
let today = Utc::now().date_naive();
1140+
1141+
let test_cases = vec![
1142+
("m", -1), // M = UTC+12
1143+
("a", -1), // A = UTC+1
1144+
("n", 0), // N = UTC-1
1145+
("y", 0), // Y = UTC-12
1146+
("z", 0), // Z = UTC
1147+
// same day hour offsets
1148+
("n2", 0),
1149+
// midnight crossings with hour offsets back to today
1150+
("a1", 0), // exactly to midnight
1151+
("a5", 0), // "overflow" midnight
1152+
("m23", 0),
1153+
// midnight crossings with hour offsets to tomorrow
1154+
("n23", 1),
1155+
("y23", 1),
1156+
// midnight crossing to yesterday even with positive offset
1157+
("m9", -1), // M = UTC+12 (-12 h + 9h is still `yesterday`)
1158+
];
1159+
1160+
for (input, day_delta) in test_cases {
1161+
let expected_date = today.checked_add_signed(Duration::days(day_delta)).unwrap();
1162+
1163+
let expected = format!("{}\n", expected_date.format("%F"));
1164+
1165+
new_ucmd!()
1166+
.env("TZ", "UTC")
1167+
.arg("-d")
1168+
.arg(input)
1169+
.arg("+%F")
1170+
.succeeds()
1171+
.stdout_is(expected);
1172+
}
1173+
}
1174+
11351175
// Locale-aware hour formatting tests
11361176
#[test]
11371177
#[cfg(unix)]

0 commit comments

Comments
 (0)