diff --git a/Cargo.lock b/Cargo.lock index 52abc7465b..c9f22b7920 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -77,7 +77,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -88,7 +88,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -465,7 +465,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1016,7 +1016,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2031,7 +2031,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2251,13 +2251,13 @@ dependencies = [ [[package]] name = "parse_datetime" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413775a7eac2261d2211a79d10ef275e5b6f7b527eec42ad09adce2ffa92b6e5" +checksum = "f15dc0ed203652fed0fc0e2b4de76bca160565afe16507973df55e6407b84353" dependencies = [ "jiff", "num-traits", - "winnow 0.7.15", + "winnow", ] [[package]] @@ -2684,7 +2684,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2917,7 +2917,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3002,7 +3002,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3012,7 +3012,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3137,7 +3137,7 @@ dependencies = [ "indexmap", "toml_datetime", "toml_parser", - "winnow 1.0.1", + "winnow", ] [[package]] @@ -3146,7 +3146,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.1", + "winnow", ] [[package]] @@ -4768,7 +4768,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4992,15 +4992,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - [[package]] name = "winnow" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index 1ac7a0a76d..92c61de963 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -450,7 +450,7 @@ num-bigint = "0.4.4" num-prime = "0.5.0" num-traits = "0.2.19" onig = { version = "~6.5.1", default-features = false } -parse_datetime = "0.14.0" +parse_datetime = "0.15.0" phf = "0.14.0" phf_codegen = "0.14.0" platform-info = "2.0.3" diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index dfb9979007..f59484f90b 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -1410,9 +1410,9 @@ checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" [[package]] name = "parse_datetime" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413775a7eac2261d2211a79d10ef275e5b6f7b527eec42ad09adce2ffa92b6e5" +checksum = "f15dc0ed203652fed0fc0e2b4de76bca160565afe16507973df55e6407b84353" dependencies = [ "jiff", "num-traits", @@ -1793,7 +1793,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys", @@ -2405,9 +2405,9 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.15" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index 796ebb8b21..53266875e3 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore strtime ; (format) DATEFILE MMDDhhmm ; (vars) datetime datetimes getres AWST ACST AEST foobarbaz +// spell-checker:ignore strtime ; (format) DATEFILE MMDDhhmm ; (vars) datetime datetimes getres AWST ACST AEST foobarbaz unparseable mod format_modifiers; mod locale; @@ -708,6 +708,44 @@ pub fn uu_app() -> Command { .arg(Arg::new(OPT_FORMAT).num_args(0..)) } +/// Replace bare `%s` conversion specifiers in `fmt` with the Unix epoch second +/// using floor semantics. +/// +/// GNU `date` rounds `%s` toward negative infinity for negative fractional +/// timestamps, whereas jiff's `%s` truncates toward zero. `%%` escapes are +/// preserved and every other specifier is left untouched for jiff to render. +fn substitute_epoch_seconds(fmt: &str, date: &Zoned) -> String { + if !fmt.contains("%s") { + return fmt.to_string(); + } + + let seconds = parse_datetime::ParsedDateTime::InRange(date.clone()) + .unix_epoch_second() + .to_string(); + + let mut out = String::with_capacity(fmt.len()); + let mut chars = fmt.chars().peekable(); + while let Some(c) = chars.next() { + if c != '%' { + out.push(c); + continue; + } + match chars.peek() { + Some('s') => { + chars.next(); + out.push_str(&seconds); + } + // Keep `%%` intact so jiff still renders it as a literal percent. + Some('%') => { + chars.next(); + out.push_str("%%"); + } + _ => out.push('%'), + } + } + out +} + fn format_date_with_locale_aware_months( date: &Zoned, format_string: &str, @@ -727,6 +765,13 @@ fn format_date_with_locale_aware_months( #[cfg(not(feature = "i18n-datetime"))] let fmt = format_string; + // jiff renders `%s` by truncating toward zero, but GNU `date` floors toward + // negative infinity (e.g. `@-1.5` → `-2`, not `-1`). Every other field jiff + // produces already agrees with GNU, so only `%s` needs correcting; rewrite it + // to the floored epoch second before jiff sees the format string. + let fmt_owned = substitute_epoch_seconds(fmt, date); + let fmt = fmt_owned.as_str(); + // Check if format string has GNU modifiers (width/flags) and format if present if let Some(result) = format_modifiers::format_with_modifiers_if_present(date, fmt, config) { return result.map_err(|e| e.to_string()); @@ -880,7 +925,7 @@ fn try_parse_with_abbreviation>(date_str: S, now: &Zoned) -> Optio // Parse in the target timezone so "10:30 EDT" means 10:30 in EDT. let parsed = parse_datetime::parse_datetime_at_date(now.clone(), date_part).ok()?; - let zoned = parsed.datetime().to_zoned(tz).ok()?; + let zoned = parsed.into_zoned()?.datetime().to_zoned(tz).ok()?; // The trailing abbreviation only describes the *input* timezone. For display, // re-zone to the system timezone (i.e. `now`'s zone, which is UTC under `-u`). @@ -948,8 +993,16 @@ fn parse_date + Clone>( match parse_datetime::parse_datetime_at_date(now.clone(), input_str) { // Convert to system timezone for display - // (parse_datetime 0.13 returns Zoned in the input's timezone) - Ok(date) => { + // (parse_datetime returns a value in the input's timezone) + Ok(parsed) => { + // Out-of-range years (the `Extended` variant) can't be represented + // as a `Zoned`; treat them as an unparseable input. + let Some(date) = parsed.into_zoned() else { + return Err(( + input_str.into(), + parse_datetime::ParseDateTimeError::InvalidInput, + )); + }; let result = date.timestamp().to_zoned(now.time_zone().clone()); if dbg_opts.debug { // Show final parsed date and time diff --git a/src/uu/touch/src/touch.rs b/src/uu/touch/src/touch.rs index d492cbb5fc..d70e63faac 100644 --- a/src/uu/touch/src/touch.rs +++ b/src/uu/touch/src/touch.rs @@ -797,8 +797,10 @@ fn parse_date(ref_zoned: Zoned, s: &str) -> Result { } } - if let Ok(zoned) = parse_datetime::parse_datetime_at_date(ref_zoned, s) { - return Ok(timestamp_to_filetime(zoned.timestamp())); + if let Ok(parsed) = parse_datetime::parse_datetime_at_date(ref_zoned, s) { + if let Some(zoned) = parsed.into_zoned() { + return Ok(timestamp_to_filetime(zoned.timestamp())); + } } Err(TouchError::InvalidDateFormat(s.to_owned())) diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index d99a9b4baa..9ef6086cec 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -1898,25 +1898,41 @@ fn test_date_strftime_narrow_width_on_wide_default() { } #[test] -#[ignore = "https://github.com/uutils/parse_datetime/issues/283 — GNU date floors negative fractional epochs (`@-1.5` -> -2); uutils truncates toward zero (-> -1)."] fn test_date_negative_fractional_epoch_flooring() { - new_ucmd!() - .env("LC_ALL", "C") - .env("TZ", "UTC") - .arg("-d") - .arg("@-1.5") - .arg("+%s") - .succeeds() - .stdout_is("-2\n"); + // GNU date floors `%s` toward negative infinity for negative fractional + // epochs, while jiff truncates toward zero. See parse_datetime issue #283. + for (input, format, expected) in [ + ("@-1.5", "+%s", "-2\n"), + ("@-0.25", "+%s", "-1\n"), + ("@-2.75", "+%s.%N", "-3.250000000\n"), + ("@-100.5", "+%s", "-101\n"), + // Positive fractions and whole seconds are unaffected. + ("@42.9", "+%s", "42\n"), + ("@-7", "+%s", "-7\n"), + // `%%s` stays a literal specifier and must not be substituted. + ("@-1.5", "+%%s=%s", "%s=-2\n"), + ] { + new_ucmd!() + .env("LC_ALL", "C") + .env("TZ", "UTC") + .arg("-d") + .arg(input) + .arg(format) + .succeeds() + .stdout_is(expected); + } } #[test] -#[ignore = "https://github.com/uutils/parse_datetime/issues/282 — parse_datetime rejects `HH:MM am/pm` forms (e.g. `2024-06-15 12:00 PM`, `2024-06-15 11:30am`). GNU date accepts them."] fn test_date_input_hhmm_ampm() { - for input in [ - "2024-06-15 12:00 PM", - "2024-06-15 11:30am", - "2024-06-15 3:00 PM", + // GNU date accepts a 12-hour meridiem suffix on a combined date+time. + // Regression test for https://github.com/uutils/parse_datetime/issues/282. + for (input, expected) in [ + ("2024-06-15 12:00 PM", "12:00\n"), + ("2024-06-15 11:30am", "11:30\n"), + ("2024-06-15 3:00 PM", "15:00\n"), + ("2024-06-15 12:00 AM", "00:00\n"), + ("2024-06-15 3:00 p.m.", "15:00\n"), ] { new_ucmd!() .env("LC_ALL", "C") @@ -1924,7 +1940,8 @@ fn test_date_input_hhmm_ampm() { .arg("-d") .arg(input) .arg("+%H:%M") - .succeeds(); + .succeeds() + .stdout_is(expected); } }