Skip to content
Closed
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
75 changes: 73 additions & 2 deletions src/uu/date/src/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -870,6 +870,74 @@ fn try_parse_with_abbreviation<S: AsRef<str>>(date_str: S) -> Option<Zoned> {
None
}

/// Normalize ISO-8601 date with 12-hour time to separate date and time items.
///
/// The parse_datetime crate's combined datetime parser only accepts 24-hour time format.
/// To support ISO-8601 dates with 12-hour time (e.g., "2025-01-01 12:00am"), we need to
/// convert the format to use slashes instead of dashes, which forces parse_datetime to
/// parse date and time as separate items.
///
/// Pattern: YYYY-MM-DD HH:MM[am|pm] or YYYY-MM-DD HH:MM:SS[am|pm]
/// Converts to: YYYY/MM/DD HH:MM[am|pm] (slash format prevents combined parsing)
fn normalize_iso8601_12hour_time(date_str: &str) -> String {
let s = date_str.trim();

// Quick check: must contain at least one dash and end with meridiem indicator
if !s.contains('-') {
return s.to_string();
}

// Check if string ends with am/pm (case-insensitive) in one pass
let s_lower = s.to_ascii_lowercase();
if !(s_lower.ends_with("am")
|| s_lower.ends_with("pm")
|| s_lower.ends_with("a.m.")
|| s_lower.ends_with("p.m."))
{
return s.to_string();
}

// Split on first space to separate date from time+meridiem
let Some(space_pos) = s.find(' ') else {
return s.to_string();
};

let date_part = &s[..space_pos];
let time_part = &s[space_pos + 1..];

// Parse ISO-8601 date: YYYY-MM-DD (must have exactly 2 dashes)
let mut dash_iter = date_part.match_indices('-');
let Some((first_dash, _)) = dash_iter.next() else {
return s.to_string();
};
let Some((second_dash, _)) = dash_iter.next() else {
return s.to_string();
};

// Ensure no more dashes
if dash_iter.next().is_some() {
return s.to_string();
}

let year = &date_part[..first_dash];
let month = &date_part[first_dash + 1..second_dash];
let day = &date_part[second_dash + 1..];

// Validate components: year must be 4 digits, month and day must be 1-2 digits
if year.len() == 4
&& year.bytes().all(|b| b.is_ascii_digit())
&& (1..=2).contains(&month.len())
&& month.bytes().all(|b| b.is_ascii_digit())
&& (1..=2).contains(&day.len())
&& day.bytes().all(|b| b.is_ascii_digit())
{
// Convert YYYY-MM-DD to YYYY/MM/DD to prevent combined datetime parsing
format!("{year}/{month}/{day} {time_part}")
} else {
s.to_string()
}
}

/// Parse a `String` into a `DateTime`.
/// If it fails, return a tuple of the `String` along with its `ParseError`.
/// Helper function to parse dates from a line-based reader (stdin or file)
Expand Down Expand Up @@ -903,12 +971,15 @@ fn parse_date<S: AsRef<str> + Clone>(
) -> Result<Zoned, (String, parse_datetime::ParseDateTimeError)> {
let input_str = s.as_ref();

// First, normalize ISO-8601 dates with 12-hour time (issue #9253)
let normalized = normalize_iso8601_12hour_time(input_str);

if dbg_opts.debug {
eprintln!("date: input string: {input_str}");
}

// First, try to parse any timezone abbreviations
if let Some(zoned) = try_parse_with_abbreviation(input_str) {
if let Some(zoned) = try_parse_with_abbreviation(&normalized) {
if dbg_opts.debug {
eprintln!(
"date: parsed date part: (Y-M-D) {}",
Expand All @@ -924,7 +995,7 @@ fn parse_date<S: AsRef<str> + Clone>(
return Ok(zoned);
}

match parse_datetime::parse_datetime_at_date(now.clone(), input_str) {
match parse_datetime::parse_datetime_at_date(now.clone(), &normalized) {
// Convert to system timezone for display
// (parse_datetime 0.13 returns Zoned in the input's timezone)
Ok(date) => {
Expand Down
154 changes: 154 additions & 0 deletions tests/by-util/test_date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2613,3 +2613,157 @@ fn test_date_debug_current_time() {
// No parsing happens for "now", so no debug output
assert_eq!(stderr, "");
}

#[test]
fn test_date_iso8601_12hour_time() {
// Test ISO-8601 dates with 12-hour time format (issue #9253)
// The parse_datetime crate's combined parser only accepts 24-hour time,
// so we normalize by converting dashes to slashes to force separate parsing

// Test basic am/pm with ISO-8601 date
new_ucmd!()
.env("TZ", "UTC")
.arg("-d")
.arg("2025-01-01 12:00am")
.arg("+%Y-%m-%d %H:%M")
.succeeds()
.stdout_is("2025-01-01 00:00\n");

new_ucmd!()
.env("TZ", "UTC")
.arg("-d")
.arg("2025-01-01 12:00pm")
.arg("+%Y-%m-%d %H:%M")
.succeeds()
.stdout_is("2025-01-01 12:00\n");

// Test with seconds
new_ucmd!()
.env("TZ", "UTC")
.arg("-d")
.arg("2025-01-01 12:00:30am")
.arg("+%Y-%m-%d %H:%M:%S")
.succeeds()
.stdout_is("2025-01-01 00:00:30\n");

new_ucmd!()
.env("TZ", "UTC")
.arg("-d")
.arg("2025-01-01 12:00:30pm")
.arg("+%Y-%m-%d %H:%M:%S")
.succeeds()
.stdout_is("2025-01-01 12:00:30\n");

// Test case variations
new_ucmd!()
.env("TZ", "UTC")
.arg("-d")
.arg("2025-01-01 12:00AM")
.arg("+%Y-%m-%d %H:%M")
.succeeds()
.stdout_is("2025-01-01 00:00\n");

new_ucmd!()
.env("TZ", "UTC")
.arg("-d")
.arg("2025-01-01 12:00PM")
.arg("+%Y-%m-%d %H:%M")
.succeeds()
.stdout_is("2025-01-01 12:00\n");

// Test with a.m./p.m. format
new_ucmd!()
.env("TZ", "UTC")
.arg("-d")
.arg("2025-01-01 12:00 a.m.")
.arg("+%Y-%m-%d %H:%M")
.succeeds()
.stdout_is("2025-01-01 00:00\n");

new_ucmd!()
.env("TZ", "UTC")
.arg("-d")
.arg("2025-01-01 12:00 p.m.")
.arg("+%Y-%m-%d %H:%M")
.succeeds()
.stdout_is("2025-01-01 12:00\n");

// Test various hours
new_ucmd!()
.env("TZ", "UTC")
.arg("-d")
.arg("2025-01-01 1:30am")
.arg("+%Y-%m-%d %H:%M")
.succeeds()
.stdout_is("2025-01-01 01:30\n");

new_ucmd!()
.env("TZ", "UTC")
.arg("-d")
.arg("2025-01-01 11:59pm")
.arg("+%Y-%m-%d %H:%M")
.succeeds()
.stdout_is("2025-01-01 23:59\n");
}

#[test]
fn test_date_iso8601_12hour_time_edge_cases() {
// Test edge cases for ISO-8601 12-hour time (issue #9253)

// Noon edge case
new_ucmd!()
.env("TZ", "UTC")
.arg("-d")
.arg("2025-01-01 12:00pm")
.arg("+%H:%M")
.succeeds()
.stdout_is("12:00\n");

// Midnight edge case
new_ucmd!()
.env("TZ", "UTC")
.arg("-d")
.arg("2025-01-01 12:00am")
.arg("+%H:%M")
.succeeds()
.stdout_is("00:00\n");

// Single digit hour
new_ucmd!()
.env("TZ", "UTC")
.arg("-d")
.arg("2025-01-01 1:05am")
.arg("+%H:%M")
.succeeds()
.stdout_is("01:05\n");

new_ucmd!()
.env("TZ", "UTC")
.arg("-d")
.arg("2025-01-01 1:05pm")
.arg("+%H:%M")
.succeeds()
.stdout_is("13:05\n");

// 11:59pm edge case (end of day)
new_ucmd!()
.env("TZ", "UTC")
.arg("-d")
.arg("2025-01-01 11:59pm")
.arg("+%H:%M")
.succeeds()
.stdout_is("23:59\n");
}

#[test]
fn test_date_iso8601_12hour_time_non_iso() {
// Ensure non-ISO-8601 dates with 12-hour time still work
// (these should not be affected by the normalization)
new_ucmd!()
.env("TZ", "UTC")
.arg("-d")
.arg("Jan 1 2025 12:00pm")
.arg("+%Y-%m-%d %H:%M")
.succeeds()
.stdout_is("2025-01-01 12:00\n");
}
Loading