mirror of
https://github.com/uutils/parse_datetime.git
synced 2026-10-16 03:25:04 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb444cc124 | ||
|
|
f75711a932 | ||
|
|
de62667844 | ||
|
|
701f5a0200 | ||
|
|
2df367552d | ||
|
|
3fc6d60400 | ||
|
|
1c92c879ed | ||
|
|
5bc4b0a902 | ||
|
|
a066a83ca4 | ||
|
|
ed95575362 | ||
|
|
c6bfe2d15a | ||
|
|
59336d8db1 | ||
|
|
dabd1eab31 | ||
|
|
f1523281a0 | ||
|
|
5b0b578a4c | ||
|
|
2788bf4e3d | ||
|
|
04fa426eff | ||
|
|
fef8bf0cfc | ||
|
|
e25bf1530c | ||
|
|
fdd87ce6ad | ||
|
|
f451f94912 | ||
|
|
d4bace0d65 | ||
|
|
618dda7263 | ||
|
|
9f2a054284 | ||
|
|
390c747003 | ||
|
|
1c7fdea358 | ||
|
|
e42c4f0e2e | ||
|
|
4cf4db33f0 | ||
|
|
46fb737541 | ||
|
|
0562eec95b | ||
|
|
d435b3727a | ||
|
|
01efbda22a | ||
|
|
0ded151ae6 | ||
|
|
75aaf908c0 | ||
|
|
cb1cb02a33 | ||
|
|
52554677dd | ||
|
|
516a3a934d | ||
|
|
1b014a15a3 | ||
|
|
bf5cf43375 | ||
|
|
edb83cceba | ||
|
|
a3b8d128fa | ||
|
|
e717803a5e | ||
|
|
b6c2256daf | ||
|
|
3355829f3d | ||
|
|
80556965f3 | ||
|
|
b83d7a953c | ||
|
|
4b412e03d2 | ||
|
|
c312311cf0 | ||
|
|
672a2acbc4 | ||
|
|
665eb95a13 | ||
|
|
0371374e7e | ||
|
|
1129eeaeaf | ||
|
|
400969043d | ||
|
|
d464f3a2b1 | ||
|
|
025feb86f8 | ||
|
|
53ed79bcfc | ||
|
|
d5bd1e8e4a | ||
|
|
7e9a8b502c |
No files matched your search
@@ -0,0 +1,15 @@
|
||||
name: Security audit
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: rustsec/audit-check@v2
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
+16
-13
@@ -15,7 +15,7 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macOS-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- run: cargo check
|
||||
|
||||
test:
|
||||
@@ -25,14 +25,14 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macOS-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- run: cargo test
|
||||
|
||||
fmt:
|
||||
name: cargo fmt --all -- --check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- run: cargo fmt --all -- --check
|
||||
|
||||
clippy:
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macOS-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- run: cargo clippy --all-targets -- -D warnings
|
||||
|
||||
min_version:
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macOS-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: ${{ env.RUST_MIN_SRV }}
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
- { os: macos-latest , features: macos }
|
||||
- { os: windows-latest , features: windows }
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: emulate nightly toolchain
|
||||
run: echo "RUSTC_BOOTSTRAP=1" >> "${GITHUB_ENV}"
|
||||
- name: Initialize workflow variables
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
grcov . --binary-path="${COVERAGE_REPORT_DIR}" --output-type lcov --output-path "${COVERAGE_REPORT_FILE}" --branch --ignore build.rs --ignore "vendor/*" --ignore "/*" --ignore "[a-zA-Z]:/*" --excl-br-line "^\s*((debug_)?assert(_eq|_ne)?!|#\[derive\()" --source-dir .
|
||||
echo "report=${COVERAGE_REPORT_FILE}" >> $GITHUB_OUTPUT
|
||||
- name: Upload coverage results (to Codecov.io)
|
||||
uses: codecov/codecov-action@v6
|
||||
uses: codecov/codecov-action@v7
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ${{ steps.coverage.outputs.report }}
|
||||
@@ -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@v6
|
||||
- name: emulate nightly toolchain
|
||||
run: echo "RUSTC_BOOTSTRAP=1" >> "${GITHUB_ENV}"
|
||||
- uses: actions/checkout@v7
|
||||
- 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
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
name: Run benchmarks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup rust toolchain, cache and cargo-codspeed binary
|
||||
uses: moonrepo/setup-rust@v1
|
||||
@@ -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
+195
-168
File diff suppressed because it is too large.
Load diff
+3
-3
@@ -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,10 +14,10 @@ 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 = "4.0"
|
||||
version = "5.0"
|
||||
|
||||
[[bench]]
|
||||
name = "parse_datetime"
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Generated
+133
-39
@@ -4,24 +4,30 @@ 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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.58"
|
||||
version = "1.2.62"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1"
|
||||
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
@@ -36,14 +42,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
name = "defmt"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"defmt-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-macros"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b"
|
||||
dependencies = [
|
||||
"defmt-parser",
|
||||
"proc-macro-error2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-parser"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
|
||||
dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -76,28 +114,40 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.23"
|
||||
version = "0.2.38"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
|
||||
checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
"jiff-core",
|
||||
"jiff-static",
|
||||
"jiff-tzdb-platform",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"serde_core",
|
||||
"windows-sys",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-core"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.23"
|
||||
version = "0.2.38"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
|
||||
checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c"
|
||||
dependencies = [
|
||||
"jiff-core",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -127,15 +177,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.183"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libfuzzer-sys"
|
||||
version = "0.4.12"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d"
|
||||
checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"cc",
|
||||
@@ -164,7 +214,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "parse_datetime"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"jiff",
|
||||
"num-traits",
|
||||
@@ -179,13 +229,35 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.6"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
|
||||
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
|
||||
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 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
@@ -227,7 +299,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -247,6 +319,37 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
@@ -255,9 +358,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.2+wasi-0.2.9"
|
||||
version = "1.0.3+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
|
||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
@@ -268,26 +371,17 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.15"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
+68
-22
@@ -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").
|
||||
/// - d. Apply relative adjustments (e.g., "+3 days", "-2 months").
|
||||
/// - e. Apply final fixed offset if present.
|
||||
pub(super) fn build(self) -> Result<ParsedDateTime, error::Error> {
|
||||
/// - 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(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()?
|
||||
@@ -317,7 +349,17 @@ impl DateTimeBuilder {
|
||||
dt = dt.checked_add(Span::new().try_days(delta)?)?;
|
||||
}
|
||||
|
||||
// 4d. Apply relative adjustments.
|
||||
// 4d. Apply fixed offset. This anchors the instant before relative
|
||||
// adjustments so that "1970/01/01 UTC N seconds" adds N seconds to the
|
||||
// fixed-offset instant rather than to a local wall-clock that may drift
|
||||
// across a DST boundary (see issue uutils/coreutils#12555).
|
||||
if let Some(offset) = self.offset {
|
||||
let (offset, hour_adjustment) = offset.normalize();
|
||||
dt = dt.checked_add(Span::new().hours(hour_adjustment))?;
|
||||
dt = dt.datetime().to_zoned((&offset).try_into()?)?;
|
||||
}
|
||||
|
||||
// 4e. Apply relative adjustments.
|
||||
for rel in self.relative {
|
||||
dt = match rel {
|
||||
relative::Relative::Years(_) | relative::Relative::Months(_) => {
|
||||
@@ -338,13 +380,6 @@ impl DateTimeBuilder {
|
||||
};
|
||||
}
|
||||
|
||||
// 4e. Apply final fixed offset.
|
||||
if let Some(offset) = self.offset {
|
||||
let (offset, hour_adjustment) = offset.normalize();
|
||||
dt = dt.checked_add(Span::new().hours(hour_adjustment))?;
|
||||
dt = dt.datetime().to_zoned((&offset).try_into()?)?;
|
||||
}
|
||||
|
||||
Ok(dt)
|
||||
}
|
||||
|
||||
@@ -359,6 +394,7 @@ impl DateTimeBuilder {
|
||||
time,
|
||||
weekday,
|
||||
offset,
|
||||
local_zone,
|
||||
timezone,
|
||||
relative,
|
||||
} = self;
|
||||
@@ -372,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")?,
|
||||
@@ -525,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)?,
|
||||
@@ -547,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 {
|
||||
@@ -678,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(),
|
||||
@@ -712,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"
|
||||
@@ -863,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()
|
||||
@@ -879,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));
|
||||
}
|
||||
|
||||
|
||||
+64
-11
@@ -16,7 +16,7 @@
|
||||
//! > discarded.
|
||||
use winnow::{
|
||||
combinator::{alt, trace},
|
||||
seq, ModalResult, Parser,
|
||||
ModalResult, Parser,
|
||||
};
|
||||
|
||||
use crate::items::space;
|
||||
@@ -29,20 +29,46 @@ pub(crate) struct DateTime {
|
||||
pub(crate) time: time::Time,
|
||||
}
|
||||
|
||||
pub(crate) fn parse(input: &mut &str) -> ModalResult<DateTime> {
|
||||
seq!(DateTime {
|
||||
date: trace("iso_date", alt((date::iso1, date::iso2))),
|
||||
// Note: the `T` is lowercased by the main parse function
|
||||
_: alt((s('t').void(), (' ', space).void())),
|
||||
time: trace("iso_time", time::iso),
|
||||
})
|
||||
.parse_next(input)
|
||||
fn remaining_starts_with_meridiem(input: &str) -> bool {
|
||||
let trimmed = input.trim_start();
|
||||
trimmed.starts_with("am")
|
||||
|| trimmed.starts_with("pm")
|
||||
|| trimmed.starts_with("a.m.")
|
||||
|| trimmed.starts_with("p.m.")
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
|
||||
let mut iso_input = *input;
|
||||
if let Ok(parsed_time) = trace("iso_time", time::iso).parse_next(&mut iso_input) {
|
||||
if !remaining_starts_with_meridiem(iso_input) {
|
||||
*input = iso_input;
|
||||
return Ok(DateTime {
|
||||
date,
|
||||
time: parsed_time,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let time = trace("iso_time", time::parse).parse_next(input)?;
|
||||
Ok(DateTime { date, time })
|
||||
}
|
||||
|
||||
#[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() {
|
||||
@@ -73,4 +99,31 @@ mod tests {
|
||||
assert_eq!(parse(&mut s).ok(), reference, "Failed string: {old_s}")
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_and_time_ampm() {
|
||||
let reference = Some(DateTime {
|
||||
date: Date {
|
||||
day: 15,
|
||||
month: 6,
|
||||
year: Some(2024),
|
||||
},
|
||||
time: Time {
|
||||
hour: 15,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
nanosecond: 0,
|
||||
offset: None,
|
||||
},
|
||||
});
|
||||
|
||||
for mut s in [
|
||||
"2024-06-15 3:00 pm",
|
||||
"2024-06-15 3:00pm",
|
||||
"2024-06-15 3:00 p.m.",
|
||||
] {
|
||||
let old_s = s.to_owned();
|
||||
assert_eq!(parse(&mut s).ok(), reference, "Failed string: {old_s}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-5
@@ -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:
|
||||
//
|
||||
|
||||
+110
-5
@@ -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),
|
||||
)),
|
||||
)
|
||||
@@ -377,12 +403,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_and_time_ampm() {
|
||||
// https://github.com/uutils/parse_datetime/issues/282
|
||||
assert_eq!("12:00", test_eq_fmt("%H:%M", "2024-06-15 12:00 PM"));
|
||||
assert_eq!("11:30", test_eq_fmt("%H:%M", "2024-06-15 11:30am"));
|
||||
assert_eq!("15:00", test_eq_fmt("%H:%M", "2024-06-15 3:00 PM"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty() {
|
||||
let result = parse(&mut "");
|
||||
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");
|
||||
@@ -724,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);
|
||||
@@ -771,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());
|
||||
}
|
||||
}
|
||||
+176
-95
@@ -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,78 +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"),
|
||||
"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)]
|
||||
@@ -423,12 +446,25 @@ mod tests {
|
||||
fn timezone_name_without_offset() {
|
||||
for (input, expected) in [
|
||||
("utc", off(false, 0, 0)), // UTC
|
||||
("ut", off(false, 0, 0)), // Universal Time = UTC (issue #280)
|
||||
("gmt", off(false, 0, 0)), // UTC
|
||||
("z", off(false, 0, 0)), // UTC
|
||||
("west", off(false, 1, 0)), // positive offset
|
||||
("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;
|
||||
@@ -475,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);
|
||||
@@ -482,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
@@ -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
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
+235
@@ -49,6 +49,57 @@ impl ParsedDateTime {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the number of seconds since the Unix epoch, using floor
|
||||
/// division (rounding toward negative infinity).
|
||||
///
|
||||
/// This matches the behavior of GNU `date +%s` for negative fractional
|
||||
/// timestamps. For example, a timestamp of −1.5 seconds returns −2
|
||||
/// (not −1 as truncation toward zero would give).
|
||||
///
|
||||
/// Note: `jiff::Timestamp::as_second()` uses truncation toward zero,
|
||||
/// which differs from the POSIX/GNU convention for `%s`. This method
|
||||
/// corrects for that.
|
||||
pub fn unix_epoch_second(&self) -> i64 {
|
||||
match self {
|
||||
ParsedDateTime::InRange(z) => {
|
||||
let ts = z.timestamp();
|
||||
let s = ts.as_second();
|
||||
let ns = ts.subsec_nanosecond();
|
||||
// jiff stores negative fractional timestamps with a negative
|
||||
// subsec_nanosecond (e.g. -1.5s → as_second=-1, subsec=-500ms).
|
||||
// Floor semantics require subtracting 1 from the second when
|
||||
// the nanosecond component is negative.
|
||||
if ns < 0 {
|
||||
s - 1
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
ParsedDateTime::Extended(ext) => ext.unix_seconds(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the sub-second nanosecond component, always in `0..1_000_000_000`.
|
||||
///
|
||||
/// This is the complement of [`unix_epoch_second`](Self::unix_epoch_second):
|
||||
/// the full timestamp equals `unix_epoch_second() + subsec_nanosecond() / 1e9`.
|
||||
///
|
||||
/// For negative fractional timestamps the nanosecond is re-normalized to be
|
||||
/// non-negative (matching the GNU `timespec` convention).
|
||||
pub fn subsec_nanosecond(&self) -> i32 {
|
||||
match self {
|
||||
ParsedDateTime::InRange(z) => {
|
||||
let ns = z.timestamp().subsec_nanosecond();
|
||||
if ns < 0 {
|
||||
1_000_000_000 + ns
|
||||
} else {
|
||||
ns
|
||||
}
|
||||
}
|
||||
ParsedDateTime::Extended(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Unwraps the `InRange` variant, panicking if this is an `Extended` value.
|
||||
///
|
||||
/// This is a convenience for contexts where the caller is certain the result
|
||||
@@ -470,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)]
|
||||
@@ -694,6 +788,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// GNU `date` accepts bare `ut` / `UT` as a synonym for UTC (issue #280).
|
||||
/// `gmt` and `GMT` already worked; this test covers all four forms.
|
||||
#[test]
|
||||
fn test_bare_utc_timezone_abbreviations() {
|
||||
// All four should parse without error and produce a result in UTC (+00:00).
|
||||
for abbr in ["ut", "UT", "gmt", "GMT"] {
|
||||
let result = parse_datetime(abbr)
|
||||
.unwrap_or_else(|_| panic!("bare timezone '{abbr}' should be accepted"));
|
||||
let offset_secs = result.expect_in_range().offset().seconds();
|
||||
assert_eq!(offset_secs, 0, "'{abbr}' should resolve to UTC (+0)");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_weekday_only() {
|
||||
let now = Zoned::now();
|
||||
@@ -720,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!(
|
||||
@@ -887,4 +1018,108 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod unix_epoch_second {
|
||||
use jiff::{civil::DateTime, tz::TimeZone};
|
||||
|
||||
use crate::{parse_datetime, parse_datetime_at_date, DateParts, ParsedDateTime, TimeParts};
|
||||
|
||||
fn epoch_second(input: &str) -> (i64, i32) {
|
||||
let base = "2024-01-01 00:00:00"
|
||||
.parse::<DateTime>()
|
||||
.unwrap()
|
||||
.to_zoned(TimeZone::UTC)
|
||||
.unwrap();
|
||||
let parsed = parse_datetime_at_date(base, input).unwrap();
|
||||
(parsed.unix_epoch_second(), parsed.subsec_nanosecond())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positive_integer_epoch() {
|
||||
let (sec, nsec) = epoch_second("@1690466034");
|
||||
assert_eq!(sec, 1690466034);
|
||||
assert_eq!(nsec, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positive_fractional_epoch() {
|
||||
let (sec, nsec) = epoch_second("@1690466034.5");
|
||||
assert_eq!(sec, 1690466034);
|
||||
assert_eq!(nsec, 500_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_integer_epoch() {
|
||||
let (sec, nsec) = epoch_second("@-1");
|
||||
assert_eq!(sec, -1);
|
||||
assert_eq!(nsec, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_fractional_epoch_floors() {
|
||||
// Issue #283: @-1.5 must floor to -2, not truncate to -1.
|
||||
let (sec, nsec) = epoch_second("@-1.5");
|
||||
assert_eq!(sec, -2, "floor(-1.5) should be -2, not -1");
|
||||
assert_eq!(nsec, 500_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_fractional_epoch_large() {
|
||||
// From issue #283: @-893375784.554767216
|
||||
let (sec, nsec) = epoch_second("@-893375784.554767216");
|
||||
assert_eq!(sec, -893375785);
|
||||
assert_eq!(nsec, 445_232_784);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_epoch() {
|
||||
let (sec, nsec) = epoch_second("@0");
|
||||
assert_eq!(sec, 0);
|
||||
assert_eq!(nsec, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_zero_fractional() {
|
||||
// @-0.5 should floor to -1
|
||||
let (sec, nsec) = epoch_second("@-0.5");
|
||||
assert_eq!(sec, -1);
|
||||
assert_eq!(nsec, 500_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extended_values_use_extended_epoch_path() {
|
||||
let parsed = parse_datetime("10000-01-01").unwrap();
|
||||
// Year 10000 is beyond jiff::Zoned's representable range, so
|
||||
// parse_datetime must route through the ExtendedDateTime branch
|
||||
// of both accessors.
|
||||
assert!(matches!(parsed, ParsedDateTime::Extended(_)));
|
||||
assert_eq!(parsed.subsec_nanosecond(), 0);
|
||||
|
||||
// Year 10000 UTC in seconds since 1970 is well past 2.5e11.
|
||||
assert!(parsed.unix_epoch_second() > 250_000_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manually_constructed_extended_values_use_extended_epoch_path() {
|
||||
let ext = crate::ExtendedDateTime::new(
|
||||
DateParts {
|
||||
year: 10_000,
|
||||
month: 1,
|
||||
day: 1,
|
||||
},
|
||||
TimeParts {
|
||||
hour: 12,
|
||||
minute: 34,
|
||||
second: 56,
|
||||
nanosecond: 789_000_000,
|
||||
},
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
let parsed = ParsedDateTime::Extended(ext.clone());
|
||||
|
||||
assert_eq!(parsed.unix_epoch_second(), ext.unix_seconds());
|
||||
assert_eq!(parsed.subsec_nanosecond(), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
+207
@@ -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")]
|
||||
@@ -285,3 +297,198 @@ fn test_multiple_month_skip(#[case] base: &str, #[case] input: &str, #[case] exp
|
||||
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,
|
||||
// adding a large relative offset drifts the wall-clock across a DST boundary
|
||||
// and re-anchoring at the fixed offset bakes in the extra hour.
|
||||
// "1970-01-01 UTC + N seconds" must equal exactly N seconds after the epoch,
|
||||
// regardless of the base zone's DST rules.
|
||||
#[test]
|
||||
fn test_utc_keyword_plus_relative_seconds_across_dst() {
|
||||
// Base in a DST-observing zone (Europe/Berlin); the value only matters as
|
||||
// the default zone for interpreting the date, not the result.
|
||||
let base = "2020-06-15 12:00:00"
|
||||
.parse::<DateTime>()
|
||||
.unwrap()
|
||||
.to_zoned(TimeZone::get("Europe/Berlin").unwrap())
|
||||
.unwrap();
|
||||
|
||||
// A timestamp landing in summer (CEST, UTC+2) while the epoch base is in
|
||||
// winter (CET, UTC+1), so the DST offset differs between base and result.
|
||||
let seconds = 1_780_318_971_i64;
|
||||
let input = format!("1970-01-01 UTC {seconds} seconds");
|
||||
|
||||
let parsed = parse_datetime::parse_datetime_at_date(base, input)
|
||||
.unwrap()
|
||||
.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
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user