From a3966a99407b50e8c3bbfbfbf1385acbaa2cd22c Mon Sep 17 00:00:00 2001 From: naoNao89 <90588855+naoNao89@users.noreply.github.com> Date: Fri, 14 Nov 2025 21:02:44 +0700 Subject: [PATCH 1/2] fix(date): support ISO-8601 with 12-hour time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parse_datetime crate's combined parser only accepts 24-hour time. When parsing "YYYY-MM-DD HH:MMam/pm", it fails because the ISO time parser doesn't support meridiem indicators. Normalize by converting dashes to slashes (YYYY-MM-DD → YYYY/MM/DD) which forces separate parsing where 12-hour time is supported. Implementation uses zero-allocation string slicing for efficiency. Fixes #9253 --- src/uu/date/src/date.rs | 75 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index 91e72747a36..6b25d59a1ec 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -870,6 +870,74 @@ fn try_parse_with_abbreviation>(date_str: S) -> Option { 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) @@ -903,12 +971,15 @@ fn parse_date + Clone>( ) -> Result { 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) {}", @@ -924,7 +995,7 @@ fn parse_date + 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) => { From aea20c6662a0fc10296e78a437edc00a5ba809d6 Mon Sep 17 00:00:00 2001 From: naoNao89 Date: Wed, 18 Feb 2026 15:34:17 +0000 Subject: [PATCH 2/2] test(date): add tests for ISO-8601 dates with 12-hour time format Add comprehensive tests for issue #9253 to verify ISO-8601 date formats combined with 12-hour time (am/pm) parsing: - Basic am/pm with ISO-8601 dates (YYYY-MM-DD) - Time with seconds - Case variations (am/AM, pm/PM) - a.m./p.m. format - Various hour values (1:30am, 11:59pm) - Edge cases (noon, midnight, single digit hours) - Non-ISO-8601 dates to ensure they still work --- tests/by-util/test_date.rs | 154 +++++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index 1471634df37..a833688bf83 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -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"); +}