Compare commits

...
24 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
18 changed files with 721 additions and 449 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
Generated
+146 -211
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.16.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) {
+76 -103
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,21 +101,20 @@ 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.35"
version = "0.2.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383"
dependencies = [
"defmt",
"jiff-core",
@@ -131,30 +129,31 @@ dependencies = [
[[package]]
name = "jiff-core"
version = "0.1.0"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
dependencies = [
"defmt",
"log",
]
[[package]]
name = "jiff-static"
version = "0.2.35"
version = "0.2.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
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"
@@ -167,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",
@@ -177,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"
@@ -193,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"
@@ -214,7 +213,7 @@ dependencies = [
[[package]]
name = "parse_datetime"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"jiff",
"num-traits",
@@ -223,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",
@@ -321,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"
@@ -362,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"
+13 -6
View File
@@ -212,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();
@@ -581,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 {
@@ -902,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()
@@ -918,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() {
+11 -3
View File
@@ -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:
//
+65 -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;
@@ -159,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"
@@ -236,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",
@@ -255,11 +261,26 @@ 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),
@@ -743,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);
+123 -97
View File
@@ -193,16 +193,21 @@ pub(super) fn parse_local(input: &mut &str) -> ModalResult<()> {
/// 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.
@@ -210,20 +215,13 @@ fn timezone_name_offset(input: &mut &str) -> ModalResult<Offset> {
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)
}
@@ -287,83 +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"),
"mez" => Ok("+1"),
"mesz" => Ok("+2"),
"mest" => Ok("+2"),
"mdt" => Ok("-6"),
"m" => Ok("+12"),
"l" => Ok("+11"),
"kst" => Ok("+9"),
"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)]
@@ -457,6 +459,12 @@ mod tests {
("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;
@@ -503,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);
+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}");
}
}
}
+35 -1
View File
@@ -32,7 +32,13 @@ fn multispace0<'a, E>(input: &mut &'a str) -> winnow::Result<&'a str, E>
where
E: ParserError<&'a str>,
{
take_while(0.., (' ', '\t', '\n', '\x0B', '\x0C', '\r')).parse_next(input)
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
@@ -50,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)
}
@@ -219,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!(
+58
View File
@@ -298,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,
@@ -434,3 +447,48 @@ fn test_zone_item_keeps_time_of_day_with_relative(#[case] input: &str, #[case] e
"`{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));
}