Compare commits

..
36 Commits
Author SHA1 Message Date
renovate[bot] 6c2ab28fcb chore(deps): update rust crate codspeed-criterion-compat to v5.0.3 2026-10-14 14:32:48 +02:00
xtqqczze 0510844e44 build(deps): update rust crates 2026-10-11 23:01:37 +02:00
Sylvestre Ledru eb444cc124 prepare version 0.17.0 2026-10-11 16:23:43 +02:00
Ion f75711a932 fix(relative): add the fraction of a negative relative second count (#327)
* fix(relative): add the fraction of a negative relative second count

`Relative::Seconds` holds a floor decomposition, so -0.25 seconds is
`(-1, 750_000_000)`. A `jiff::Span` is sign-uniform, so combining those two
fields directly subtracted the fraction instead of adding it and the result
was off by `2 * (1 - fraction)` seconds. Rebalance the fraction onto the sign
of the whole seconds before building the span.

* fix(relative): shorten the fraction comment, align the GNU version to 9.11
2026-10-11 16:10:53 +02:00
Anas Awadalla de62667844 fix(weekday): accept a period after abbreviated weekday names (#334)
* fix(weekday): accept a period after abbreviated weekday names

* test(weekday): parse abbreviated weekdays with a period through the full parser

* test(weekday): cover mismatched abbreviated weekday with period
2026-10-11 15:52:32 +02:00
CodSpeed Bot 701f5a0200 bench: pin parse_weekday to a fixed reference date
The weekday benchmark parsed "wednesday" relative to the current date, so
its cost depended on which day of the week CI ran (no date shift on a
Wednesday, a multi-day shift otherwise), producing spurious regressions.
2026-10-11 09:41:30 +02:00
Asifur Rahaman Meeru 2df367552d fix(offset): correct ADT, AST, BST, GST and SST to GNU's values
The table is meant to cover the same abbreviations GNU date supports, but
five of them resolved to a different offset than GNU date gives, so
"2026-06-15 12:00 BST" parsed as +06:00 (Bangladesh) rather than +01:00
(British Summer Time). Offsets were measured with `date --debug` on GNU
coreutils 9.10: ADT is -3, AST -4, BST +1, GST +10 and SST -12.
2026-10-11 09:41:30 +02:00
renovate[bot] 3fc6d60400 chore(deps): update rust crate arbitrary to v1.5.0 2026-10-07 06:52:23 +02:00
renovate[bot] 1c92c879ed chore(deps): update rust crate jiff to v0.2.38 2026-10-07 06:51:33 +02:00
sap1110 5bc4b0a902 fixup! builder: ignore weekday when an explicit date is given 2026-10-05 22:20:33 +02:00
sap1110 a066a83ca4 builder: ignore weekday when an explicit date is given
When the input carries both a weekday and a calendar date, the date is
now authoritative and the weekday is dropped, matching GNU date. This
also covers ordinal forms such as "next friday sep 25 2026". Relative
items are still applied on top of the date, and a weekday without a
date behaves as before.

Closes #317
2026-10-05 22:20:33 +02:00
oech3 ed95575362 ci.yml: use prebuilt cargo-fuzz 2026-10-04 10:19:34 +02:00
Anas Awadalla c6bfe2d15a test(primitive): cover comments and ignored signs between items 2026-10-03 13:17:40 -04:00
Anas Awadalla 59336d8db1 test(offset): cover offsets followed by a relative item 2026-10-03 13:17:40 -04:00
Anas Awadalla dabd1eab31 refactor(offset): shorten the relative lookahead comments 2026-10-03 13:17:40 -04:00
Anas Awadalla f1523281a0 refactor(items): address review cleanups 2026-10-03 13:17:40 -04:00
Anas Awadalla 5b0b578a4c perf(items): parse an ISO date once and lowercase the input only when needed 2026-10-03 13:17:40 -04:00
Anas Awadalla 2788bf4e3d perf(offset): map timezone names to offsets directly and defer the relative lookahead 2026-10-03 13:17:40 -04:00
Anas Awadalla 04fa426eff perf(primitive): skip the whitespace combinator when no comment or sign follows 2026-10-03 13:17:40 -04:00
Anas Awadalla fef8bf0cfc fix(ordinal): accept eighth instead of eight as an ordinal (#333)
* fix(ordinal): accept eighth instead of eight as an ordinal

* test(ordinal): cover text ordinals and parse eighth through the full parser
2026-10-03 19:16:06 +02:00
Natsu e25bf1530c relative: accept hence after a relative time (#331) 2026-09-21 19:13:23 +02:00
renovate[bot] fdd87ce6ad chore(deps): update rust crate codspeed-criterion-compat to v5.0.2 2026-09-18 07:18:08 +02:00
renovate[bot] f451f94912 chore(deps): update rust crate jiff to v0.2.37 2026-09-13 09:51:25 +02:00
renovate[bot] d4bace0d65 chore(deps): update rust crate rstest to 0.27 2026-09-07 07:12:23 +02:00
Sylvestre Ledru 618dda7263 fix(builder): keep the time of day when a zone item is combined with a relative item (#324) 2026-08-29 17:39:35 +02:00
Sylvestre Ledru 9f2a054284 prepare 0.16.0 (#323) 2026-08-29 17:35:28 +02:00
Tsolmondorj Natsagdorj 390c747003 fix(offset): support the military timezone letter j (local time) (#320)
GNU `date` accepts every letter `a`-`y` as a military time zone, and all
of them are already in our table except `j`. It is missing because it is
the one letter that does not denote a fixed offset: `j` means *local
time*, so it cannot be expressed as an `Offset` at all.

    $ TZ=America/New_York date -d '8j'            # 08:00 -0400
    $ TZ=America/New_York date -d '2026-01-01 j'  # 00:00 -0500
    $ TZ=America/New_York date -d '8z'            # 04:00 -0400

Because it follows the base zone's DST rules rather than pinning an
offset, it is represented as its own item and recorded on the builder as
a flag rather than as an offset.

- `j` parses as its own item, so `8j`, `8 j`, `j 8` and `8J` all work.
- It is still a time zone item, so `8j utc`, `8 utc j` and `8 j j` are
  rejected as a repeated zone, matching GNU.
- The whole alphabetic word is consumed before comparing, so `8jan` still
  parses as a date and `jj` is rejected rather than matching a `j` prefix.
- Unknown words remain errors: `8foo` still fails, as it does in GNU.

Verified against GNU coreutils 9.4: all 26 letters `a`-`z` now resolve to
the same instant as GNU, in both a DST and a non-DST base zone.
2026-08-29 17:19:00 +02:00
Daniel Hofstetter 1c7fdea358 Bump crossbeam-epoch from 0.9.18 to 0.9.20 (#312)
https://rustsec.org/advisories/RUSTSEC-2026-0204
2026-08-28 17:00:38 +02:00
wtc e42c4f0e2e fix: accept vertical tab and form feed as whitespace between tokens (#322) 2026-08-28 16:50:43 +02:00
Asifur Rahaman Meeru 4cf4db33f0 fix(offset): accept MEZ, MESZ, MEST and KST abbreviations (#321)
The table is meant to cover the same abbreviations GNU date supports, but
these four were missing, so `date -d "2024-01-15 12:00 MEZ"` failed here
while GNU accepts it. Offsets were measured against GNU coreutils: MEZ is
+1, MESZ and MEST are +2, and KST is +9.

Downstream this is what pushes uutils/coreutils to keep its own abbreviation
table in date.rs and parse the string in fragments, which is the root of
uutils/coreutils#13865.
2026-08-27 08:02:49 +02:00
renovate[bot] 46fb737541 chore(deps): update codspeedhq/action action to v5 2026-07-29 13:12:51 +02:00
renovate[bot] 0562eec95b chore(deps): update rust crate jiff to v0.2.35 2026-07-26 10:21:10 +02:00
renovate[bot] d435b3727a chore(deps): update rust crate jiff to v0.2.34 2026-07-20 08:04:40 +02:00
renovate[bot] 01efbda22a chore(deps): update rust crate jiff to v0.2.33 2026-07-19 10:20:46 +02:00
renovate[bot] 0ded151ae6 chore(deps): update rust crate winnow to v1.0.4 2026-07-14 08:04:40 +02:00
renovate[bot] 75aaf908c0 chore(deps): update rust crate jiff to v0.2.32 2026-07-09 07:54:44 +02:00
19 changed files with 1015 additions and 457 deletions

No files matched your search

+8 -5
View File
@@ -147,22 +147,25 @@ jobs:
runs-on: ubuntu-latest
env:
RUN_FOR: 60
# cargo-fuzz uses nightly features
RUSTC_BOOTSTRAP: 1
steps:
- uses: actions/checkout@v7
- name: emulate nightly toolchain
run: echo "RUSTC_BOOTSTRAP=1" >> "${GITHUB_ENV}"
- name: Install `cargo-fuzz`
run: cargo install cargo-fuzz --locked
uses: taiki-e/install-action@v2
with:
tool: cargo-fuzz
- uses: Swatinem/rust-cache@v2
- name: Run fuzz_parse_datetime for XX seconds
shell: bash
run: |
## Run it
cd fuzz
cargo fuzz run fuzz_parse_datetime -- -max_total_time=${{ env.RUN_FOR }} -detect_leaks=0
# https://github.com/rust-fuzz/cargo-fuzz/issues/398
cargo fuzz run --target host-tuple fuzz_parse_datetime -- -max_total_time=${{ env.RUN_FOR }} -detect_leaks=0
- name: Run fuzz_large_year for XX seconds
shell: bash
run: |
## Run it
cd fuzz
cargo fuzz run fuzz_large_year -- -max_total_time=${{ env.RUN_FOR }} -detect_leaks=0
cargo fuzz run --target host-tuple fuzz_large_year -- -max_total_time=${{ env.RUN_FOR }} -detect_leaks=0
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
run: cargo codspeed build
- name: Run the benchmarks
uses: CodSpeedHQ/action@v4
uses: CodSpeedHQ/action@v5
with:
mode: simulation
run: cargo codspeed run
Generated
+158 -212
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -1,7 +1,7 @@
[package]
name = "parse_datetime"
description = "parsing human-readable time strings and converting them to a Zoned datetime"
version = "0.15.0"
version = "0.17.0"
edition = "2021"
license = "MIT"
repository = "https://github.com/uutils/parse_datetime"
@@ -14,7 +14,7 @@ num-traits = "0.2.19"
jiff = { version = "0.2.15", default-features = false, features = ["tz-system", "tzdb-bundle-platform", "tzdb-zoneinfo"] }
[dev-dependencies]
rstest = "0.26"
rstest = "0.27"
[dev-dependencies.codspeed-criterion-compat]
version = "5.0"
+1 -1
View File
@@ -77,7 +77,7 @@ The `parse_datetime` and `parse_datetime_at_date` functions support absolute dat
- "now" or "today"
- "yesterday"
- "tomorrow"
- use "ago" for the past
- use "ago" for the past or "hence" for the future
- use "next" or "last" with `unit` (e.g., "next week", "last year")
- unix timestamps (for example "@0" "@1344000")
+10 -1
View File
@@ -55,7 +55,16 @@ fn bench_relative_keywords(c: &mut Criterion) {
}
fn bench_weekday(c: &mut Criterion) {
c.bench_function("parse_weekday", |b| b.iter(|| parse_datetime("wednesday")));
// Use a fixed reference date: the cost of resolving a weekday depends on
// how many days separate it from "today", so relying on the current date
// makes the measurement vary with the day the benchmark runs.
// 2000-01-03 is a Monday, so "wednesday" resolves two days ahead.
let base = jiff::civil::DateTime::from(jiff::civil::date(2000, 1, 3))
.to_zoned(jiff::tz::TimeZone::UTC)
.unwrap();
c.bench_function("parse_weekday", |b| {
b.iter(|| parse_datetime_at_date(base.clone(), "wednesday"))
});
}
fn bench_timezone_offset(c: &mut Criterion) {
+86 -102
View File
@@ -4,9 +4,9 @@ version = 4
[[package]]
name = "arbitrary"
version = "1.4.2"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed"
dependencies = [
"derive_arbitrary",
]
@@ -25,9 +25,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "cc"
version = "1.2.62"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -37,15 +37,15 @@ dependencies = [
[[package]]
name = "cfg-if"
version = "1.0.4"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "defmt"
version = "1.1.0"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f"
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
dependencies = [
"bitflags",
"defmt-macros",
@@ -53,15 +53,14 @@ dependencies = [
[[package]]
name = "defmt-macros"
version = "1.1.0"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b"
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
dependencies = [
"defmt-parser",
"proc-macro-error2",
"proc-macro2",
"quote",
"syn",
"syn 2.0.119",
]
[[package]]
@@ -75,20 +74,20 @@ dependencies = [
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.8",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "fuzz_parse_datetime"
@@ -102,23 +101,23 @@ dependencies = [
[[package]]
name = "getrandom"
version = "0.3.4"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
]
[[package]]
name = "jiff"
version = "0.2.31"
version = "0.2.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccfe6121cbe750cf81efa362d85c0bde7ea298ec43092d3a193baca59cdbd634"
checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383"
dependencies = [
"defmt",
"jiff-core",
"jiff-static",
"jiff-tzdb-platform",
"log",
@@ -129,21 +128,32 @@ dependencies = [
]
[[package]]
name = "jiff-static"
version = "0.2.31"
name = "jiff-core"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e165e897f662d428f3cd3828a919dbe067c2d42bb1031eede74ef9d27ecdedd2"
checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
dependencies = [
"defmt",
"log",
]
[[package]]
name = "jiff-static"
version = "0.2.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c"
dependencies = [
"jiff-core",
"proc-macro2",
"quote",
"syn",
"syn 2.0.119",
]
[[package]]
name = "jiff-tzdb"
version = "0.1.6"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076"
checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7"
[[package]]
name = "jiff-tzdb-platform"
@@ -156,9 +166,9 @@ dependencies = [
[[package]]
name = "jobserver"
version = "0.1.34"
version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
dependencies = [
"getrandom",
"libc",
@@ -166,9 +176,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.186"
version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]]
name = "libfuzzer-sys"
@@ -182,15 +192,15 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.29"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "memchr"
version = "2.8.0"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "num-traits"
@@ -203,7 +213,7 @@ dependencies = [
[[package]]
name = "parse_datetime"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"jiff",
"num-traits",
@@ -212,96 +222,85 @@ dependencies = [
[[package]]
name = "portable-atomic"
version = "1.13.1"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
[[package]]
name = "portable-atomic-util"
version = "0.2.7"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
dependencies = [
"portable-atomic",
]
[[package]]
name = "proc-macro-error-attr2"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
dependencies = [
"proc-macro2",
"quote",
]
[[package]]
name = "proc-macro-error2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
dependencies = [
"proc-macro-error-attr2",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "serde_core"
version = "1.0.228"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.8",
]
[[package]]
name = "shlex"
version = "1.3.0"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "syn"
version = "2.0.117"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01016da373cd8f7ef12624f796309f5c31ba8d646dd08856c02cd741d823c622"
dependencies = [
"proc-macro2",
"quote",
@@ -310,38 +309,29 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.18"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.8",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "wasip2"
version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
"wit-bindgen",
]
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "windows-link"
@@ -351,15 +341,9 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "winnow"
version = "1.0.3"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+55 -12
View File
@@ -20,6 +20,10 @@ pub(crate) struct DateTimeBuilder {
time: Option<time::Time>,
weekday: Option<weekday::Weekday>,
offset: Option<offset::Offset>,
/// Whether the military timezone letter `j` was given. It is a timezone
/// item, so it excludes any other one, but it means local time and so
/// contributes no offset of its own.
local_zone: bool,
timezone: Option<jiff::tz::TimeZone>,
relative: Vec<relative::Relative>,
}
@@ -59,6 +63,7 @@ impl DateTimeBuilder {
|| self.time.is_some()
|| self.weekday.is_some()
|| self.offset.is_some()
|| self.local_zone
|| !self.relative.is_empty()
{
return Err("timestamp cannot be combined with other date/time items");
@@ -84,7 +89,7 @@ impl DateTimeBuilder {
return Err("timestamp cannot be combined with other date/time items");
} else if self.time.is_some() {
return Err("time cannot appear more than once");
} else if self.offset.is_some() && time.offset.is_some() {
} else if (self.offset.is_some() || self.local_zone) && time.offset.is_some() {
return Err("time offset and timezone are mutually exclusive");
}
@@ -107,6 +112,7 @@ impl DateTimeBuilder {
if self.timestamp.is_some() {
return Err("timestamp cannot be combined with other date/time items");
} else if self.offset.is_some()
|| self.local_zone
|| self.time.as_ref().and_then(|t| t.offset.as_ref()).is_some()
{
return Err("time offset cannot appear more than once");
@@ -116,6 +122,24 @@ impl DateTimeBuilder {
Ok(self)
}
/// Record the military timezone letter `j` (local time).
///
/// It occupies the same slot as a numeric offset, so GNU rejects `8j utc`,
/// `8 utc j` and `8 j j` as a repeated timezone, and so do we.
fn set_local_zone(mut self) -> Result<Self, &'static str> {
if self.timestamp.is_some() {
return Err("timestamp cannot be combined with other date/time items");
} else if self.offset.is_some()
|| self.local_zone
|| self.time.as_ref().and_then(|t| t.offset.as_ref()).is_some()
{
return Err("time offset cannot appear more than once");
}
self.local_zone = true;
Ok(self)
}
fn push_relative(mut self, relative: relative::Relative) -> Result<Self, &'static str> {
if self.timestamp.is_some() {
return Err("timestamp cannot be combined with other date/time items");
@@ -188,10 +212,15 @@ impl DateTimeBuilder {
/// from the base instant.
/// - b. Apply time. If time carries an explicit numeric offset, apply the
/// offset before setting time.
/// - c. Apply weekday (e.g., "next Friday" or "last Monday").
/// - c. Apply weekday (e.g., "next Friday" or "last Monday"). A weekday
/// is ignored when an explicit calendar date is given.
/// - d. Apply fixed offset if present (anchors the instant).
/// - e. Apply relative adjustments (e.g., "+3 days", "-2 months").
pub(super) fn build(self) -> Result<ParsedDateTime, error::Error> {
pub(super) fn build(mut self) -> Result<ParsedDateTime, error::Error> {
if self.date.is_some() {
self.weekday = None;
}
if let Some(date) = self.date.as_ref() {
if date.year.unwrap_or(0) > 9999 {
return self.build_extended();
@@ -240,12 +269,15 @@ impl DateTimeBuilder {
return Ok(ts.to_zoned(base.offset().to_time_zone()));
}
// 3. Determine whether to truncate the time of day.
// 3. Determine whether to truncate the time of day. Like GNU, a
// relative item on its own keeps the current time of day, while a date,
// a weekday, or a timezone item without any relative item resets it to
// midnight.
let zone_seen = self.offset.is_some() || self.local_zone || has_timezone;
let need_midnight = self.date.is_some()
|| self.time.is_some()
|| self.weekday.is_some()
|| self.offset.is_some()
|| has_timezone;
|| (zone_seen && self.relative.is_empty());
let mut dt = if need_midnight {
base.with().time(civil::time(0, 0, 0, 0)).build()?
@@ -362,6 +394,7 @@ impl DateTimeBuilder {
time,
weekday,
offset,
local_zone,
timezone,
relative,
} = self;
@@ -375,11 +408,13 @@ impl DateTimeBuilder {
};
let rule_tz = base.time_zone().clone();
// See `build_in_range` for why a relative item alone keeps the
// current time of day.
let zone_seen = offset.is_some() || local_zone || has_timezone;
let need_midnight = date.is_some()
|| time.is_some()
|| weekday.is_some()
|| offset.is_some()
|| has_timezone;
|| (zone_seen && relative.is_empty());
let mut dt = ExtendedDateTime::new(
DateParts {
year: u32::try_from(base.year()).map_err(|_| "year must be non-negative")?,
@@ -528,6 +563,7 @@ impl TryFrom<Vec<Item>> for DateTimeBuilder {
Item::Time(t) => builder.set_time(t)?,
Item::Weekday(weekday) => builder.set_weekday(weekday)?,
Item::Offset(offset) => builder.set_offset(offset)?,
Item::LocalZone => builder.set_local_zone()?,
Item::Relative(rel) => builder.push_relative(rel)?,
Item::TimeZone(tz) => builder.set_timezone(tz)?,
Item::Pure(pure) => builder.set_pure(pure)?,
@@ -550,11 +586,11 @@ mod tests {
fn date() -> date::Date {
let mut input = "2023-06-15";
date::parse(&mut input).unwrap()
date::iso1(&mut input).unwrap()
}
fn date_large(mut input: &str) -> date::Date {
date::parse(&mut input).unwrap()
date::iso1(&mut input).unwrap()
}
fn time() -> time::Time {
@@ -681,6 +717,7 @@ mod tests {
DateTimeBuilder::new().set_time(time()).unwrap(),
DateTimeBuilder::new().set_weekday(weekday()).unwrap(),
DateTimeBuilder::new().set_offset(offset()).unwrap(),
DateTimeBuilder::new().set_local_zone().unwrap(),
DateTimeBuilder::new()
.push_relative(relative_day())
.unwrap(),
@@ -715,6 +752,10 @@ mod tests {
ts_builder().set_offset(offset()).unwrap_err(),
"timestamp cannot be combined with other date/time items"
);
assert_eq!(
ts_builder().set_local_zone().unwrap_err(),
"timestamp cannot be combined with other date/time items"
);
assert_eq!(
ts_builder().set_pure("2023".to_string()).unwrap_err(),
"timestamp cannot be combined with other date/time items"
@@ -866,7 +907,7 @@ mod tests {
}
#[test]
fn build_extended_applies_relative_units_and_weekday() {
fn build_extended_applies_relative_units_and_ignores_weekday_with_date() {
let base = "2000-01-01 00:00:00"
.parse::<DateTime>()
.unwrap()
@@ -882,7 +923,9 @@ mod tests {
])
.unwrap();
let dt = expect_extended_datetime(builder.set_base(base).build().unwrap());
assert_eq!((dt.year, dt.month, dt.day), (10000, 1, 4));
// The weekday is ignored because a date is given, so only the relative
// items move it (GNU: `next mon 10000-01-01 +1 day` is 10000-01-02).
assert_eq!((dt.year, dt.month, dt.day), (10000, 1, 2));
assert_eq!((dt.hour, dt.minute, dt.second), (2, 3, 4));
}
+12 -4
View File
@@ -37,8 +37,9 @@ fn remaining_starts_with_meridiem(input: &str) -> bool {
|| trimmed.starts_with("p.m.")
}
pub(crate) fn parse(input: &mut &str) -> ModalResult<DateTime> {
let date = trace("iso_date", alt((date::iso1, date::iso2))).parse_next(input)?;
/// Parse the separator and time of a combined item whose ISO date has already
/// been parsed.
pub(crate) fn parse_time_after_date(input: &mut &str, date: date::Date) -> ModalResult<DateTime> {
// Note: the `T` is lowercased by the main parse function
alt((s('t').void(), (' ', space).void())).parse_next(input)?;
@@ -59,8 +60,15 @@ pub(crate) fn parse(input: &mut &str) -> ModalResult<DateTime> {
#[cfg(test)]
mod tests {
use super::{parse, DateTime};
use crate::items::{date::Date, time::Time};
use winnow::{combinator::alt, ModalResult, Parser};
use super::{parse_time_after_date, DateTime};
use crate::items::{date, date::Date, time::Time};
fn parse(input: &mut &str) -> ModalResult<DateTime> {
let date = alt((date::iso1, date::iso2)).parse_next(input)?;
parse_time_after_date(input, date)
}
#[test]
fn some_date() {
+13 -5
View File
@@ -27,7 +27,7 @@
//! > ‘September’.
use winnow::{
ascii::{alpha1, multispace1},
ascii::alpha1,
combinator::{alt, eof, opt, preceded, terminated},
error::ErrMode,
stream::AsChar,
@@ -36,7 +36,7 @@ use winnow::{
};
use super::{
primitive::{ctx_err, dec_uint, s},
primitive::{ctx_err, dec_uint, multispace1, s},
year::{year_from_str, year_str},
};
@@ -127,8 +127,10 @@ impl TryFrom<Date> for jiff::civil::Date {
}
}
pub(super) fn parse(input: &mut &str) -> ModalResult<Date> {
alt((iso1, iso2, us, literal1, literal2, literal3)).parse_next(input)
/// Parse a date that is not in ISO format. ISO dates are parsed by [`iso1`]
/// and [`iso2`], which the caller tries first.
pub(super) fn parse_non_iso(input: &mut &str) -> ModalResult<Date> {
alt((us, literal1, literal2, literal3)).parse_next(input)
}
/// Parse `[year]-[month]-[day]`
@@ -299,7 +301,13 @@ fn day_from_str(s: &str) -> ModalResult<u8> {
#[cfg(test)]
mod tests {
use super::{parse, Date};
use winnow::{combinator::alt, ModalResult, Parser};
use super::Date;
fn parse(input: &mut &str) -> ModalResult<Date> {
alt((super::iso1, super::iso2, super::parse_non_iso)).parse_next(input)
}
// Test cases from the GNU docs:
//
+102 -5
View File
@@ -48,6 +48,8 @@ mod primitive;
pub(crate) mod error;
use std::borrow::Cow;
use crate::ParsedDateTime;
use jiff::Zoned;
use primitive::space;
@@ -69,6 +71,10 @@ enum Item {
Weekday(weekday::Weekday),
Relative(relative::Relative),
Offset(offset::Offset),
/// The military timezone letter `j`, which GNU `date` defines as local
/// time. It is a timezone item like [`Item::Offset`], but carries no
/// offset, so it cannot be represented as one.
LocalZone,
TimeZone(jiff::tz::TimeZone),
Pure(String),
}
@@ -155,7 +161,7 @@ pub(crate) fn parse_at_local<S: AsRef<str> + Clone>(input: S) -> Result<ParsedDa
///
/// time_offset = ( "+" | "-" ) , dec_uint , [ ":" , dec_uint ] ;
///
/// relative = [ numeric_ordinal ] , unit , [ "ago" ] | day_shift ;
/// relative = [ numeric_ordinal ] , unit , [ "ago" | "hence" ] | day_shift ;
///
/// unit = "year" | "years"
/// | "month" | "months"
@@ -232,8 +238,12 @@ fn parse_items(input: &mut &str) -> ModalResult<DateTimeBuilder> {
let tz = timezone::parse(input).map(Item::TimeZone);
// Convert input to lowercase for case-insensitive parsing.
let lower = input.to_ascii_lowercase();
let input = &mut lower.as_str();
let lower: Cow<str> = if input.bytes().any(|b| b.is_ascii_uppercase()) {
input.to_ascii_lowercase().into()
} else {
(*input).into()
};
let input = &mut lower.as_ref();
let (mut items, _): (Vec<Item>, _) = trace(
"parse_items",
@@ -251,15 +261,31 @@ fn parse_items(input: &mut &str) -> ModalResult<DateTimeBuilder> {
/// Parse an item.
fn parse_item(input: &mut &str) -> ModalResult<Item> {
// An ISO date is either the start of a combined date and time item or a
// date item on its own. Parse it once instead of once per alternative.
let start = input.checkpoint();
if let Ok(date) = alt((date::iso1, date::iso2)).parse_next(input) {
let after_date = input.checkpoint();
match combined::parse_time_after_date(input, date.clone()) {
Ok(dt) => return Ok(Item::DateTime(dt)),
Err(ErrMode::Backtrack(_)) => {
input.reset(&after_date);
return Ok(Item::Date(date));
}
Err(e) => return Err(e),
}
}
input.reset(&start);
trace(
"parse_item",
alt((
combined::parse.map(Item::DateTime),
date::parse.map(Item::Date),
date::parse_non_iso.map(Item::Date),
time::parse.map(Item::Time),
relative::parse.map(Item::Relative),
weekday::parse.map(Item::Weekday),
offset::parse.map(Item::Offset),
offset::parse_local.map(|()| Item::LocalZone),
pure::parse.map(Item::Pure),
)),
)
@@ -391,6 +417,12 @@ mod tests {
assert!(result.is_ok());
}
#[test]
fn whitespace_only() {
let result = parse(&mut "\x0B \x0C");
assert!(result.is_ok());
}
#[test]
fn invalid() {
let result = parse(&mut "2025-05-19 2024-05-20 06:14:49");
@@ -732,6 +764,45 @@ mod tests {
assert_eq!(result.second(), 0);
}
#[test]
fn offset_followed_by_relative() {
let base = "2000-01-01 12:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
// Both read "+8 years" as a relative item, not as an offset of +8.
for input in ["UTC +8 years", "+8 years"] {
let result = parse_at_date(base.clone(), input).unwrap();
assert_eq!(result.to_string(), "2008-01-01 12:00:00+00:00", "{input}");
}
}
#[test]
fn comments_and_ignored_signs() {
let base = "2000-06-15 12:34:56"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
for (input, expected) in [
("1997-01-01 (foo) 10:00", "1997-01-01 10:00:00+00:00"),
("1997-01-01 (a (b)) 10:00", "1997-01-01 10:00:00+00:00"),
("(foo) 1997-01-01", "1997-01-01 00:00:00+00:00"),
("+ (c) 1997-01-01", "1997-01-01 00:00:00+00:00"),
("- (c) 1997-01-01", "1997-01-01 00:00:00+00:00"),
("1997-01-01 (x) + 1 day", "1997-01-02 00:00:00+00:00"),
] {
let result = parse_at_date(base.clone(), input).unwrap();
assert_eq!(result.to_string(), expected, "{input}");
}
// Like GNU, a hyphen followed by a digit is a sign, not ignored.
assert!(parse_at_date(base.clone(), "- 1997-01-01").is_err());
}
#[test]
fn pure() {
let now = Zoned::now().with_time_zone(TimeZone::UTC);
@@ -779,4 +850,30 @@ mod tests {
assert_eq!(parse_build(input), expected, "{input}");
}
}
/// `j` is a time zone item, so it participates in the same duplicate check
/// as a numeric offset. GNU rejects all three of these as a repeated zone.
#[test]
fn military_j_is_a_timezone_item() {
// Parses on its own, attached or spaced, in either case.
for input in ["j", "8j", "8 j", "8J", "j 8"] {
assert!(parse(&mut &*input).is_ok(), "`{input}` should parse");
}
for input in ["8j utc", "8 utc j", "8 j j"] {
let result = parse(&mut &*input);
assert!(result.is_err(), "`{input}` should be rejected");
assert!(
result
.unwrap_err()
.to_string()
.contains("time offset cannot appear more than once"),
"`{input}` should report a repeated time offset"
);
}
// A bare numeric offset is not an item on its own (the grammar requires
// a named zone), so this is rejected by the parser rather than by the
// duplicate check. GNU rejects it too.
assert!(parse(&mut "j +05:00").is_err());
}
}
+175 -96
View File
@@ -168,46 +168,60 @@ impl Display for Offset {
}
}
/// Timezone abbreviations are assumed to be no longer than 6 characters.
const MAX_TZ_SIZE: usize = 6;
pub(super) fn parse(input: &mut &str) -> ModalResult<Offset> {
timezone_name_offset.parse_next(input)
}
/// Parse the military timezone letter `j`.
///
/// Every other military letter denotes a fixed offset from UTC, but GNU `date`
/// defines `j` as local time, which cannot be expressed as an [`Offset`] at
/// all. It is therefore reported to the builder as its own item.
///
/// The whole alphabetic word is consumed before comparing, so `jan` is left to
/// the date parser rather than being read as `j` followed by `an`, and `jj` is
/// rejected rather than matching a `j` prefix.
pub(super) fn parse_local(input: &mut &str) -> ModalResult<()> {
s(take_while(1..=MAX_TZ_SIZE, AsChar::is_alpha))
.verify(|word: &str| word.eq_ignore_ascii_case("j"))
.void()
.parse_next(input)
}
/// Parse a timezone starting with `+` or `-`.
pub(super) fn timezone_offset(input: &mut &str) -> ModalResult<Offset> {
// Strings like "+8 years" are ambiguous, they can either be parsed as a
// timezone offset "+8" and a relative time "years", or just a relative time
// "+8 years". GNU date parses them the second way, so we do the same here.
//
// Return early if the input can be parsed as a relative time.
// Like GNU, read "+8 years" as a relative item, not as "+8" and "years".
// The costlier relative check only runs once an offset has parsed.
let start = input.checkpoint();
let result = alt((timezone_offset_colon, timezone_offset_colonless)).parse_next(input);
if matches!(result, Err(ErrMode::Backtrack(_))) {
return result;
}
let end = input.checkpoint();
input.reset(&start);
if peek(relative::parse).parse_next(input).is_ok() {
return Err(ErrMode::Backtrack(ContextError::new()));
}
alt((timezone_offset_colon, timezone_offset_colonless)).parse_next(input)
input.reset(&end);
result
}
/// Parse a timezone by name, with an optional numeric offset appended.
fn timezone_name_offset(input: &mut &str) -> ModalResult<Offset> {
/// I'm assuming there are no timezone abbreviations with more
/// than 6 charactres
const MAX_TZ_SIZE: usize = 6;
let nextword = s(take_while(1..=MAX_TZ_SIZE, AsChar::is_alpha)).parse_next(input)?;
let tz = timezone_name_to_offset(nextword)?;
// Strings like "UTC +8 years" are ambiguous, they can either be parsed as
// "UTC+8" and "years", or "UTC" and "+8 years". GNU date parses them the
// second way, so we do the same here.
//
// Only process if the input cannot be parsed as a relative time.
if peek(relative::parse).parse_next(input).is_err() {
let start = input.checkpoint();
if let Ok(other_tz) = timezone_offset.parse_next(input) {
let new_tz = tz.merge(other_tz);
return Ok(new_tz);
};
input.reset(&start);
}
// Like GNU, read "UTC +8 years" as "UTC" and "+8 years", not as "UTC+8"
// and "years". `timezone_offset` rejects input that parses as relative.
let start = input.checkpoint();
if let Ok(other_tz) = timezone_offset.parse_next(input) {
return Ok(tz.merge(other_tz));
};
input.reset(&start);
Ok(tz)
}
@@ -271,79 +285,87 @@ fn timezone_offset_colonless(input: &mut &str) -> ModalResult<Offset> {
/// https://www.timeanddate.com/time/zones/. GNU date only supports a subset of
/// these. We support the same subset as GNU date.
fn timezone_name_to_offset(input: &str) -> ModalResult<Offset> {
let mut offset_str = match input {
"z" => Ok("+0"),
"y" => Ok("-12"),
"x" => Ok("-11"),
"wet" => Ok("+0"),
"west" => Ok("+1"),
"wat" => Ok("+1"),
"w" => Ok("-10"),
"v" => Ok("-9"),
"utc" => Ok("+0"),
"ut" => Ok("+0"),
"u" => Ok("-8"),
"t" => Ok("-7"),
"sst" => Ok("-11"),
"sgt" => Ok("+8"),
"sast" => Ok("+2"),
"s" => Ok("-6"),
"r" => Ok("-5"),
"q" => Ok("-4"),
"pst" => Ok("-8"),
"pdt" => Ok("-7"),
"p" => Ok("-3"),
"o" => Ok("-2"),
"nzst" => Ok("+12"),
"nzdt" => Ok("+13"),
"nst" => Ok("-3:30"),
"ndt" => Ok("-2:30"),
"n" => Ok("-1"),
"mst" => Ok("-7"),
"msk" => Ok("+3"),
"msd" => Ok("+4"),
"mdt" => Ok("-6"),
"m" => Ok("+12"),
"l" => Ok("+11"),
"k" => Ok("+10"),
"jst" => Ok("+9"),
"ist" => Ok("+5:30"),
"i" => Ok("+9"),
"hst" => Ok("-10"),
"h" => Ok("+8"),
"gst" => Ok("+4"),
"gmt" => Ok("+0"),
"g" => Ok("+7"),
"f" => Ok("+6"),
"est" => Ok("-5"),
"eet" => Ok("+2"),
"eest" => Ok("+3"),
"edt" => Ok("-4"),
"eat" => Ok("+3"),
"e" => Ok("+5"),
"d" => Ok("+4"),
"cst" => Ok("-6"),
"clt" => Ok("-4"),
"clst" => Ok("-3"),
"cet" => Ok("+1"),
"cest" => Ok("+2"),
"cdt" => Ok("-5"),
"cat" => Ok("+2"),
"c" => Ok("+3"),
"bst" => Ok("+6"),
"brt" => Ok("-3"),
"brst" => Ok("-2"),
"b" => Ok("+2"),
"ast" => Ok("-3"),
"art" => Ok("-3"),
"akst" => Ok("-9"),
"akdt" => Ok("-8"),
"adt" => Ok("+4"),
"a" => Ok("+1"),
_ => Err(ErrMode::Backtrack(ContextError::new())),
}?;
let (negative, hours, minutes) = match input {
"z" => (false, 0, 0),
"y" => (true, 12, 0),
"x" => (true, 11, 0),
"wet" => (false, 0, 0),
"west" => (false, 1, 0),
"wat" => (false, 1, 0),
"w" => (true, 10, 0),
"v" => (true, 9, 0),
"utc" => (false, 0, 0),
"ut" => (false, 0, 0),
"u" => (true, 8, 0),
"t" => (true, 7, 0),
"sst" => (true, 12, 0),
"sgt" => (false, 8, 0),
"sast" => (false, 2, 0),
"s" => (true, 6, 0),
"r" => (true, 5, 0),
"q" => (true, 4, 0),
"pst" => (true, 8, 0),
"pdt" => (true, 7, 0),
"p" => (true, 3, 0),
"o" => (true, 2, 0),
"nzst" => (false, 12, 0),
"nzdt" => (false, 13, 0),
"nst" => (true, 3, 30),
"ndt" => (true, 2, 30),
"n" => (true, 1, 0),
"mst" => (true, 7, 0),
"msk" => (false, 3, 0),
"msd" => (false, 4, 0),
"mez" => (false, 1, 0),
"mesz" => (false, 2, 0),
"mest" => (false, 2, 0),
"mdt" => (true, 6, 0),
"m" => (false, 12, 0),
"l" => (false, 11, 0),
"kst" => (false, 9, 0),
"k" => (false, 10, 0),
"jst" => (false, 9, 0),
"ist" => (false, 5, 30),
"i" => (false, 9, 0),
"hst" => (true, 10, 0),
"h" => (false, 8, 0),
"gst" => (false, 10, 0),
"gmt" => (false, 0, 0),
"g" => (false, 7, 0),
"f" => (false, 6, 0),
"est" => (true, 5, 0),
"eet" => (false, 2, 0),
"eest" => (false, 3, 0),
"edt" => (true, 4, 0),
"eat" => (false, 3, 0),
"e" => (false, 5, 0),
"d" => (false, 4, 0),
"cst" => (true, 6, 0),
"clt" => (true, 4, 0),
"clst" => (true, 3, 0),
"cet" => (false, 1, 0),
"cest" => (false, 2, 0),
"cdt" => (true, 5, 0),
"cat" => (false, 2, 0),
"c" => (false, 3, 0),
"bst" => (false, 1, 0),
"brt" => (true, 3, 0),
"brst" => (true, 2, 0),
"b" => (false, 2, 0),
"ast" => (true, 4, 0),
"art" => (true, 3, 0),
"akst" => (true, 9, 0),
"akdt" => (true, 8, 0),
"adt" => (true, 3, 0),
"a" => (false, 1, 0),
_ => return Err(ErrMode::Backtrack(ContextError::new())),
};
timezone_offset(&mut offset_str)
Ok(Offset {
negative,
hours,
minutes,
})
}
#[cfg(test)]
@@ -431,6 +453,18 @@ mod tests {
("cst", off(true, 6, 0)), // negative offset
("ist", off(false, 5, 30)), // positive offset with non-zero minutes
("nst", off(true, 3, 30)), // negative offset with non-zero minutes
// Accepted by GNU date but previously missing here, which made
// `date -d "2024-01-15 12:00 MEZ"` fail (uutils/coreutils#13865).
("mez", off(false, 1, 0)),
("mesz", off(false, 2, 0)),
("mest", off(false, 2, 0)),
("kst", off(false, 9, 0)),
// Corrected to the offsets GNU date assigns these abbreviations.
("adt", off(true, 3, 0)), // Atlantic Daylight, was +4
("ast", off(true, 4, 0)), // Atlantic Standard, was -3
("bst", off(false, 1, 0)), // British Summer, was +6
("gst", off(false, 10, 0)), // Guam Standard, was +4
("sst", off(true, 12, 0)), // was -11
("z123", off(false, 0, 0)), // space separator can be ignored if immediately followed by digits (GNU date behavior)
] {
let mut s = input;
@@ -477,6 +511,24 @@ mod tests {
}
}
#[test]
fn offset_followed_by_relative() {
// "+8 years" is a relative item, so it is not an offset.
let mut s = "+8 years";
assert!(timezone_offset(&mut s).is_err());
assert_eq!(s, "+8 years");
// "utc +8 years" is "utc" followed by the relative item "+8 years".
let mut s = "utc +8 years";
assert_eq!(timezone_name_offset(&mut s).unwrap(), off(false, 0, 0));
assert_eq!(s, " +8 years");
// Without a relative item, the offset is still merged.
let mut s = "utc +8";
assert_eq!(timezone_name_offset(&mut s).unwrap(), off(false, 8, 0));
assert!(s.is_empty());
}
#[test]
fn total_seconds() {
assert_eq!(off(false, 0, 0).total_seconds(), 0);
@@ -484,4 +536,31 @@ mod tests {
assert_eq!(off(true, 5, 30).total_seconds(), -19_800);
assert_eq!(off(false, 24, 0).total_seconds(), 86_400);
}
/// `j` is the one military letter that is not an offset, so it is parsed
/// separately. The whole alphabetic word must be consumed before matching,
/// otherwise `jan` would be read as `j` followed by `an`.
#[test]
fn military_letter_j_local() {
for input in ["j", "J", " j"] {
let mut s = input;
assert!(
parse_local(&mut s).is_ok(),
"`{input}` should parse as local time"
);
assert!(s.is_empty(), "`{input}` should be fully consumed");
}
for input in ["jj", "jan", "jst", "z", "a", ""] {
let mut s = input;
assert!(
parse_local(&mut s).is_err(),
"`{input}` should not parse as local time"
);
}
// `j` is absent from the offset table, so the offset parser must not
// claim it.
let mut s = "j";
assert!(timezone_name_offset(&mut s).is_err());
}
}
+37 -1
View File
@@ -35,7 +35,7 @@ fn text_ordinal(input: &mut &str) -> ModalResult<i32> {
"fifth" => 5,
"sixth" => 6,
"seventh" => 7,
"eight" => 8,
"eighth" => 8,
"ninth" => 9,
"tenth" => 10,
"eleventh" => 11,
@@ -45,3 +45,39 @@ fn text_ordinal(input: &mut &str) -> ModalResult<i32> {
})
.parse_next(input)
}
#[cfg(test)]
mod tests {
use super::ordinal;
#[test]
fn text_ordinal() {
for (s, expected) in [
("last", -1),
("this", 0),
("next", 1),
("first", 1),
("third", 3),
("fourth", 4),
("fifth", 5),
("sixth", 6),
("seventh", 7),
("eighth", 8),
("ninth", 9),
("tenth", 10),
("eleventh", 11),
("twelfth", 12),
] {
let mut input = s;
assert_eq!(ordinal(&mut input).unwrap(), expected, "{s}");
assert!(input.is_empty(), "{s}");
}
}
#[test]
fn invalid_text_ordinal() {
for mut s in ["eight", "second", "foo"] {
assert!(ordinal(&mut s).is_err(), "{s}");
}
}
}
+52 -1
View File
@@ -6,7 +6,7 @@
use std::str::FromStr;
use winnow::{
ascii::{digit1, multispace0, Uint},
ascii::{digit1, Uint},
combinator::{alt, delimited, not, opt, peek, preceded, repeat, separated},
error::{ContextError, ParserError, StrContext, StrContextValue},
stream::AsChar,
@@ -26,6 +26,29 @@ where
preceded(space, p)
}
/// Same as winnow's `multispace0`, but also accepting `\v` and `\f`, like
/// the whitespace GNU `date` accepts between tokens
fn multispace0<'a, E>(input: &mut &'a str) -> winnow::Result<&'a str, E>
where
E: ParserError<&'a str>,
{
let len = input
.bytes()
.take_while(|b| matches!(b, b' ' | b'\t' | b'\n' | b'\x0B' | b'\x0C' | b'\r'))
.count();
let (ws, rest) = input.split_at(len);
*input = rest;
Ok(ws)
}
/// Same as [`multispace0`], but requiring at least one character
pub(super) fn multispace1<'a, E>(input: &mut &'a str) -> winnow::Result<&'a str, E>
where
E: ParserError<&'a str>,
{
take_while(1.., (' ', '\t', '\n', '\x0B', '\x0C', '\r')).parse_next(input)
}
/// Parse the space in-between tokens
///
/// You probably want to use the [`s`] combinator instead.
@@ -33,6 +56,11 @@ pub(super) fn space<'a, E>(input: &mut &'a str) -> winnow::Result<(), E>
where
E: ParserError<&'a str>,
{
// Fast path: most calls see no comment or ignorable sign
multispace0.parse_next(input)?;
if !input.starts_with(['(', '-', '+']) {
return Ok(());
}
separated(0.., multispace0, alt((comment, ignored_hyphen_or_plus))).parse_next(input)
}
@@ -202,4 +230,27 @@ mod tests {
);
}
}
#[test]
fn space_skips_comments_and_ignored_signs() {
for (input, rest) in [
("", ""),
("abc", "abc"),
(" \t abc", "abc"),
("(foo) abc", "abc"),
(" (a (b)) abc", "abc"),
("- abc", "abc"),
("+ (c) abc", "abc"),
("- (c) 12", "12"),
// A sign followed by a digit is not ignored.
("- 12", "- 12"),
(" +12", "+12"),
// An unbalanced comment is not skipped.
("(foo abc", "(foo abc"),
] {
let mut s = input;
space::<ContextError>(&mut s).unwrap();
assert_eq!(s, rest, "{input:?}");
}
}
}
+19 -7
View File
@@ -59,9 +59,19 @@ impl TryFrom<Relative> for jiff::Span {
Relative::Days(days) => jiff::Span::new().try_days(days),
Relative::Hours(hours) => jiff::Span::new().try_hours(hours),
Relative::Minutes(minutes) => jiff::Span::new().try_minutes(minutes),
Relative::Seconds(seconds, nanoseconds) => jiff::Span::new()
.try_seconds(seconds)
.and_then(|span| span.try_nanoseconds(nanoseconds)),
Relative::Seconds(seconds, nanoseconds) => {
// -0.25 is stored as (-1, 750_000_000), but a `jiff::Span` needs
// both fields with one sign, so move the fraction onto it.
let (seconds, nanoseconds) = if seconds < 0 && nanoseconds > 0 {
(seconds + 1, -i64::from(1_000_000_000 - nanoseconds))
} else {
(seconds, i64::from(nanoseconds))
};
jiff::Span::new()
.try_seconds(seconds)
.and_then(|span| span.try_nanoseconds(nanoseconds))
}
}
.map_err(|_| "relative value is invalid")
}
@@ -85,7 +95,7 @@ fn seconds(input: &mut &str) -> ModalResult<Relative> {
opt(alt((s('+').value(1), s('-').value(-1)))),
s(sec_and_nsec),
s(alpha1).verify(|s: &str| matches!(s, "seconds" | "second" | "sec" | "secs")),
ago,
direction,
)
.verify_map(|(sign, (sec, nsec), _, ago)| {
let sec = i64::try_from(sec).ok()?;
@@ -102,7 +112,7 @@ fn seconds(input: &mut &str) -> ModalResult<Relative> {
}
fn displacement(input: &mut &str) -> ModalResult<Relative> {
(opt(ordinal), s(alpha1), ago)
(opt(ordinal), s(alpha1), direction)
.verify_map(|(n, unit, ago): (Option<i32>, &str, bool)| {
let multiplier = n.unwrap_or(1) * if ago { -1 } else { 1 };
Some(match unit.strip_suffix('s').unwrap_or(unit) {
@@ -120,8 +130,10 @@ fn displacement(input: &mut &str) -> ModalResult<Relative> {
.parse_next(input)
}
fn ago(input: &mut &str) -> ModalResult<bool> {
opt(s("ago")).map(|o| o.is_some()).parse_next(input)
fn direction(input: &mut &str) -> ModalResult<bool> {
opt(alt((s("ago").value(true), s("hence").value(false))))
.map(|backward| backward.unwrap_or(false))
.parse_next(input)
}
#[cfg(test)]
+1 -1
View File
@@ -70,7 +70,7 @@ pub(super) fn parse(input: &mut &str) -> ModalResult<Weekday> {
}
fn day(input: &mut &str) -> ModalResult<Day> {
s(alpha1)
s((alpha1, opt('.')).take())
.verify_map(|s: &str| {
Some(match s {
"monday" | "mon" | "mon." => Day::Monday,
+67
View File
@@ -521,6 +521,49 @@ mod tests {
"2023-03-05 00:00:00 000000000"
);
}
#[test]
fn test_weekday_with_text_ordinal() {
let date = "2023-02-28 10:12:03"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::system())
.unwrap();
assert_eq!(
get_formatted_date(&date, "eighth monday"),
"2023-04-24 00:00:00 000000000"
);
}
#[test]
fn test_weekday_abbreviation_with_period() {
let date = "2023-02-28 10:12:03"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::system())
.unwrap();
for (weekday, expected) in [
("mon.", "2023-03-06 00:00:00 000000000"),
("tue.", "2023-02-28 00:00:00 000000000"),
("wed.", "2023-03-01 00:00:00 000000000"),
("thu.", "2023-03-02 00:00:00 000000000"),
("fri.", "2023-03-03 00:00:00 000000000"),
("sat.", "2023-03-04 00:00:00 000000000"),
("sun.", "2023-03-05 00:00:00 000000000"),
] {
assert_eq!(get_formatted_date(&date, weekday), expected, "{weekday}");
}
for (input, expected) in [
("wed., 2024-01-03", "2024-01-03 00:00:00"),
("mon., 2024-01-03", "2024-01-03 00:00:00"),
] {
let actual = crate::parse_datetime(input).unwrap().expect_in_range();
assert_eq!(actual.strftime("%F %T").to_string(), expected, "{input}");
}
}
}
#[cfg(test)]
@@ -784,6 +827,30 @@ mod tests {
mod test_relative {
use crate::parse_datetime;
#[test]
fn test_hence_suffix() {
let now: jiff::Zoned = "2026-01-15T12:00:00Z[UTC]".parse().unwrap();
for relative in [
"2 months",
"year",
"-2 days",
"next week",
"0 hours",
"1.5 seconds",
] {
let expected = crate::parse_datetime_at_date(now.clone(), relative).unwrap();
for suffix in ["hence", "HENCE"] {
assert_eq!(
crate::parse_datetime_at_date(now.clone(), format!("{relative} {suffix}"))
.unwrap(),
expected,
);
}
}
assert!(crate::parse_datetime_at_date(now.clone(), "hence").is_err());
assert!(crate::parse_datetime_at_date(now, "2 days henceforth").is_err());
}
#[test]
fn test_month() {
assert_eq!(
+179
View File
@@ -75,6 +75,18 @@ fn test_absolute_date_numeric(#[case] input: &str, #[case] expected: &str) {
check_absolute(input, expected);
}
// Any run of ASCII whitespace may separate items, like in GNU date. The
// expected values were verified with GNU date (`date -d 'input'`).
#[rstest]
#[case::vertical_tab_between_date_and_year("Jan 23\x0B2026 1:00AM", "2026-01-23 01:00:00+00:00")]
#[case::form_feed_between_date_and_year("Jan 23\x0C2026 1:00AM", "2026-01-23 01:00:00+00:00")]
#[case::vertical_tab_after_comma("Jan 23,\x0B2026", "2026-01-23 00:00:00+00:00")]
#[case::vertical_tab_before_time("14 nov 2026\x0B1:00AM", "2026-11-14 01:00:00+00:00")]
#[case::vertical_tab_as_iso_separator("2026-01-23\x0B01:00:00", "2026-01-23 01:00:00+00:00")]
fn test_whitespace_between_items(#[case] input: &str, #[case] expected: &str) {
check_absolute(input, expected);
}
#[rstest]
#[case::us_style("11/14", 2022, "2022-11-14 00:00:00+00:00")]
#[case::alphabetical_full_month_in_front("november 14", 2022, "2022-11-14 00:00:00+00:00")]
@@ -286,6 +298,19 @@ fn test_embedded_timezone(#[case] input: &str, #[case] expected: &str) {
check_absolute(input, expected);
}
// Zone abbreviations must resolve to the offsets GNU date assigns them,
// not to other real meanings of the same letters, e.g. BST as Bangladesh
// Standard Time rather than British Summer Time.
#[rstest]
#[case::bst("2026-06-15 12:00 BST", "2026-06-15 12:00:00+01:00")]
#[case::gst("2026-06-15 12:00 GST", "2026-06-15 12:00:00+10:00")]
#[case::ast("2026-06-15 12:00 AST", "2026-06-15 12:00:00-04:00")]
#[case::adt("2026-06-15 12:00 ADT", "2026-06-15 12:00:00-03:00")]
#[case::sst("2026-06-15 12:00 SST", "2026-06-15 12:00:00-12:00")]
fn test_zone_abbreviation_offsets(#[case] input: &str, #[case] expected: &str) {
check_absolute(input, expected);
}
// Regression test for uutils/coreutils#12555.
// A fixed offset (e.g. the "UTC" keyword) must anchor the instant *before*
// relative adjustments are applied. Otherwise, when the base zone observes DST,
@@ -313,3 +338,157 @@ fn test_utc_keyword_plus_relative_seconds_across_dst() {
.expect_in_range();
assert_eq!(parsed.timestamp().as_second(), seconds);
}
// The military time zone letter `j`.
//
// Every other military letter (`a`-`i`, `k`-`y`, and `z` for UTC) denotes a
// fixed offset from UTC. GNU `date` defines `j` as *local* time instead, so it
// follows the base zone's DST rules rather than pinning an offset:
//
// $ TZ=America/New_York date -d '8j' # 2026-08-25 08:00 -0400
// $ TZ=America/New_York date -d '2026-01-01 j' # 2026-01-01 00:00 -0500
// $ TZ=America/New_York date -d '8z' # 2026-08-25 04:00 -0400
//
// Verified against GNU coreutils 9.4.
#[rstest]
// Winter: New York is on EST (UTC-5).
#[case::est("2026-01-15 12:00:00", -5 * 3600)]
// Summer: the same zone is on EDT (UTC-4). A fixed offset could not do this.
#[case::edt("2026-07-15 12:00:00", -4 * 3600)]
fn test_military_j_is_local_time(#[case] base: &str, #[case] expected_offset: i32) {
let base = base
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::get("America/New_York").unwrap())
.unwrap();
let parsed = parse_datetime::parse_datetime_at_date(base.clone(), "8j")
.unwrap()
.expect_in_range();
assert_eq!(parsed.hour(), 8, "`8j` should be 08:00 local");
assert_eq!(
parsed.offset().seconds(),
expected_offset,
"`8j` should take the base zone's offset"
);
// `z` is UTC, so it must *not* follow the base zone. This is what
// distinguishes `j` from every other military letter.
let utc = parse_datetime::parse_datetime_at_date(base, "8z")
.unwrap()
.expect_in_range();
assert_eq!(utc.offset().seconds(), 0, "`8z` should be UTC");
}
#[rstest]
#[case::bare("j")]
#[case::attached("8j")]
#[case::uppercase("8J")]
#[case::spaced("8 j")]
#[case::leading("j 8")]
fn test_military_j_accepted(#[case] input: &str) {
assert!(
parse_datetime::parse_datetime(input).is_ok(),
"`{input}` should parse"
);
}
#[rstest]
// `j` is a time zone item, so a second one is a repeated zone, as in GNU.
#[case::j_then_utc("8j utc")]
#[case::utc_then_j("8 utc j")]
#[case::j_twice("8 j j")]
#[case::j_then_numeric("j +05:00")]
// The whole alphabetic word is matched, so `j` never steals a prefix.
#[case::doubled_letter("jj")]
#[case::doubled_letter_after_number("8 jj")]
fn test_military_j_rejected(#[case] input: &str) {
assert!(
parse_datetime::parse_datetime(input).is_err(),
"`{input}` should be rejected, as GNU date does"
);
}
// A relative item keeps the base time of day, even when combined with a time
// zone item. Only a date, a weekday, or a lone zone item resets it to
// midnight:
//
// $ TZ=America/New_York date -d 'j 1 day' # tomorrow, current time of day
// $ TZ=America/New_York date -d 'utc 1 day' # ditto, in UTC
// $ TZ=America/New_York date -d 'j' # today at 00:00
// $ TZ=America/New_York date -d 'j monday' # next monday at 00:00
//
// Verified against GNU coreutils 9.7.
#[rstest]
#[case::local_zone_and_relative("j 1 day", "2026-08-30 11:30:45")]
#[case::relative_then_local_zone("5 minutes j", "2026-08-29 11:35:45")]
#[case::offset_and_relative("utc 1 day", "2026-08-30 11:30:45")]
#[case::relative_then_offset("5 minutes utc", "2026-08-29 11:35:45")]
#[case::timezone_rule_and_relative("TZ=\"Europe/Paris\" 1 day", "2026-08-30 17:30:45")]
// Without a relative item, a zone item still truncates to midnight.
#[case::local_zone_alone("j", "2026-08-29 00:00:00")]
#[case::local_zone_and_weekday("j monday", "2026-08-31 00:00:00")]
#[case::offset_alone("utc", "2026-08-29 00:00:00")]
fn test_zone_item_keeps_time_of_day_with_relative(#[case] input: &str, #[case] expected: &str) {
let base = "2026-08-29 11:30:45"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::get("America/New_York").unwrap())
.unwrap();
let parsed = parse_datetime::parse_datetime_at_date(base, input)
.unwrap()
.expect_in_range();
assert_eq!(
parsed.datetime().to_string(),
expected.parse::<DateTime>().unwrap().to_string(),
"`{input}` should resolve to {expected} in its own zone"
);
}
// A weekday is ignored when an explicit date is given, as in GNU date
// (checked against coreutils 9.12, base Thursday 2026-10-01).
#[rstest]
#[case::mismatching_named_month("Wednesday August 17 2026", "2026-08-17 00:00:00")]
#[case::matching_named_month("Monday August 17 2026", "2026-08-17 00:00:00")]
#[case::abbreviated_with_comma("Wed, Aug 17 2026", "2026-08-17 00:00:00")]
#[case::day_month_with_comma("Wednesday, 17 August 2026", "2026-08-17 00:00:00")]
#[case::named_month_without_year("Wed Aug 17", "2026-08-17 00:00:00")]
#[case::iso_date("Wed 2026-08-17", "2026-08-17 00:00:00")]
#[case::iso_date_then_weekday("2026-08-17 Wed", "2026-08-17 00:00:00")]
#[case::us_date("Wed 08/17/2026", "2026-08-17 00:00:00")]
#[case::date_and_time("Wed 2026-08-17 10:30", "2026-08-17 10:30:00")]
#[case::time_before_date("Wed 10:30 2026-08-17", "2026-08-17 10:30:00")]
#[case::iso_datetime("2026-08-17T10:30:00 Wed", "2026-08-17 10:30:00")]
#[case::next_weekday("next Friday Sep 25 2026", "2026-09-25 00:00:00")]
#[case::next_other_weekday("next Sunday Sep 25 2026", "2026-09-25 00:00:00")]
#[case::last_weekday("last Wed 2026-08-17", "2026-08-17 00:00:00")]
#[case::ordinal_weekday("third Wed 2026-08-17", "2026-08-17 00:00:00")]
#[case::numeric_ordinal_weekday("2 Wed 2026-08-17", "2026-08-17 00:00:00")]
#[case::date_output_format("Sun Sep 25 12:00:00 AM UTC 2026", "2026-09-25 00:00:00")]
// Relative items still apply on top of the given date.
#[case::relative_day("Wed 2026-08-17 +1 day", "2026-08-18 00:00:00")]
#[case::relative_week("Wed 2026-08-17 next week", "2026-08-24 00:00:00")]
// Without a date, the weekday is still honoured.
#[case::weekday_alone("Wed", "2026-10-07 00:00:00")]
#[case::weekday_and_time("Wed 10:30", "2026-10-07 10:30:00")]
#[case::weekday_and_relative("Wed +1 day", "2026-10-08 00:00:00")]
fn test_weekday_ignored_with_explicit_date(#[case] input: &str, #[case] expected: &str) {
let base = "2026-10-01 12:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
let parsed = parse_datetime::parse_datetime_at_date(base, input)
.unwrap()
.expect_in_range();
assert_eq!(
parsed.datetime().to_string(),
expected.parse::<DateTime>().unwrap().to_string(),
"`{input}` should resolve to {expected}"
);
}
+37 -1
View File
@@ -5,7 +5,7 @@ use jiff::{civil::DateTime, tz::TimeZone, Zoned};
use parse_datetime::parse_datetime_at_date;
use rstest::rstest;
// The expected values are produced by GNU date version 8.32
// The expected values are produced by GNU date version 9.11
// export LC_TIME=en_US.UTF-8
// export TZ=UTC
// date date --date="12:34:56+09:00" +"%H:%M:%S.%N"
@@ -280,3 +280,39 @@ fn test_time_seconds_ago_invalid(#[case] input: &str) {
"Input string '{input}' did not produce an error when parsing"
);
}
// Fractional relative seconds, checked with
// TZ=UTC date --date="2026-08-27 12:00:00 +0000 <input>" +"%H:%M:%S.%N"
#[rstest]
#[case::plus_half("+0.5 sec", "12:00:00.500000000")]
#[case::minus_half("-0.5 sec", "11:59:59.500000000")]
#[case::plus_one_and_a_half("+1.5 sec", "12:00:01.500000000")]
#[case::minus_one_and_a_half("-1.5 sec", "11:59:58.500000000")]
#[case::plus_quarter("+0.25 sec", "12:00:00.250000000")]
#[case::minus_quarter("-0.25 sec", "11:59:59.750000000")]
#[case::plus_one_and_three_quarters("+1.75 sec", "12:00:01.750000000")]
#[case::minus_one_and_three_quarters("-1.75 sec", "11:59:58.250000000")]
#[case::plus_two_and_an_eighth("+2.125 sec", "12:00:02.125000000")]
#[case::minus_two_and_an_eighth("-2.125 sec", "11:59:57.875000000")]
#[case::minus_nine_nanoseconds("-0.000000009 sec", "11:59:59.999999991")]
#[case::minus_almost_one("-0.999999999 sec", "11:59:59.000000001")]
#[case::plus_half_ago("+0.5 sec ago", "11:59:59.500000000")]
#[case::minus_half_ago("-0.5 sec ago", "12:00:00.500000000")]
#[case::plus_one_and_a_half_ago("+1.5 sec ago", "11:59:58.500000000")]
#[case::unsigned_half_ago("0.5 sec ago", "11:59:59.500000000")]
#[case::unsigned_half("0.5 sec", "12:00:00.500000000")]
#[case::plus_zero_fraction("+0.0 sec", "12:00:00.000000000")]
#[case::minus_zero_fraction("-0.0 sec", "12:00:00.000000000")]
#[case::minus_whole_with_fraction_zero("-1.0 sec", "11:59:59.000000000")]
#[case::minus_whole("-1 second ago", "12:00:01.000000000")]
#[case::plural_minus_half("-0.5 seconds", "11:59:59.500000000")]
#[case::abbreviated_minus_half("-0.5 secs", "11:59:59.500000000")]
fn test_relative_fractional_seconds(#[case] input: &str, #[case] expected: &str) {
let base = "2026-08-27 12:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
check_time(input, expected, "%H:%M:%S.%N", Some(base));
}