Compare commits

..
58 Commits
Author SHA1 Message Date
Sylvestre Ledru eb444cc124 prepare version 0.17.0 2026-10-11 16:23:43 +02:00
Ion f75711a932 fix(relative): add the fraction of a negative relative second count (#327)
* fix(relative): add the fraction of a negative relative second count

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

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

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

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

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

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

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

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

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

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

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

jiff::Timestamp::as_second() uses truncation toward zero, so a timestamp
of -1.5s returns -1. GNU date's %s format uses floor (toward negative
infinity), returning -2 for the same value.

Add ParsedDateTime::unix_epoch_second() which corrects for this by
detecting a negative subsec_nanosecond and subtracting 1 from the
truncated second. Also add subsec_nanosecond() that re-normalizes to
the non-negative [0, 1_000_000_000) range matching the GNU timespec
convention.

The uutils date command can use these methods to produce correct %s
output for negative fractional epoch inputs like @-1.5.

Fixes #283

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: cover extended epoch helper paths

* test: cover direct extended epoch accessors

* test: remove panic branch to land full patch coverage

The let-else with panic! left one line reachable only on test failure,
which showed up as uncovered on codecov/patch. Use matches! + assert!
to reach the same invariant with 100% patch coverage.
2026-07-05 16:23:40 +02:00
Tsolmondorj Natsagdorj 52554677dd fix: support HH:MM am/pm time formats in combined date-time parsing (#284)
* fix: support HH:MM am/pm time formats in combined date-time parsing

The combined date-time parser (e.g., "2024-06-15 12:00 PM") used
time::iso which only accepts 24-hour notation. When an am/pm suffix
followed, iso would greedily consume the time portion, leaving the
meridiem orphaned and causing a parse error.

Switch to time::parse which tries am_pm_time first (with fallback to
iso), so inputs like "2024-06-15 12:00 PM", "2024-06-15 11:30am", and
"2024-06-15 3:00 PM" are now accepted. Case insensitivity is already
handled by parse_items lowercasing the input before parsing.

Fixes #282



* perf: keep ISO fast path for combined datetime parsing
2026-07-05 16:22:03 +02:00
Tsolmondorj Natsagdorj 516a3a934d fix(offset): accept "ut" / "UT" as a bare UTC timezone abbreviation (#287)
* fix(offset): accept "ut" / "UT" as a bare UTC timezone abbreviation

GNU date -d accepts "ut" and "UT" (Universal Time) as synonyms for UTC.
parse_datetime rejected them because the timezone_name_to_offset table
contained "utc" and "gmt" but was missing "ut".

Add "ut" => "+0" to the lookup table. "gmt" was already present and
continues to work unchanged.

Add regression tests:
  - offset-level: timezone_name_without_offset now includes "ut"
  - lib-level: test_bare_utc_timezone_abbreviations covers all four
    forms (ut, UT, gmt, GMT) end-to-end

Fixes #280

* style: run rustfmt on bare UT timezone test
2026-07-05 14:08:51 +02:00
Daniel Hofstetter 1b014a15a3 Bump defmt from 1.1.0 to 1.1.1 (#310) 2026-07-05 14:08:15 +02:00
Daniel Hofstetter bf5cf43375 Bump anyhow from 1.0.102 to 1.0.103 (#309) 2026-06-30 11:08:19 +02:00
renovate[bot] edb83cceba chore(deps): update rust crate jiff to v0.2.31 2026-06-30 08:14:42 +02:00
renovate[bot] a3b8d128fa chore(deps): update rust crate codspeed-criterion-compat to v5.0.1 2026-06-27 10:58:09 +02:00
renovate[bot] e717803a5e chore(deps): update rust crate codspeed-criterion-compat to v5 2026-06-23 15:30:32 +02:00
renovate[bot] b6c2256daf chore(deps): update rust crate jiff to v0.2.29 2026-06-21 10:02:40 +02:00
renovate[bot] 3355829f3d chore(deps): update actions/checkout action to v7 2026-06-19 08:15:24 +02:00
xtqqczzeandSylvestre Ledru 80556965f3 chore(deps): update rust crates (#288)
Co-authored-by: Sylvestre Ledru <sylvestre@debian.org>
2026-06-15 21:37:34 +02:00
Sylvestre Ledru b83d7a953c builder: apply fixed offset before relative adjustments
A fixed offset (e.g. the "UTC" keyword) was applied after relative
items, so "1970/01/01 UTC N seconds" added N seconds to a local
wall-clock that could drift across a DST boundary before being
re-anchored at the offset, yielding an instant off by the DST gap.
Anchor the offset first, then apply relative items.

Fixes uutils/coreutils#12555.
2026-06-09 08:51:02 +02:00
renovate[bot] 4b412e03d2 chore(deps): update codecov/codecov-action action to v7 2026-06-07 09:06:00 +02:00
renovate[bot] c312311cf0 chore(deps): update rust crate libfuzzer-sys to v0.4.13 2026-06-05 07:21:52 +02:00
renovate[bot] 672a2acbc4 chore(deps): update rust crate jiff to v0.2.28 2026-05-29 08:20:02 +02:00
renovate[bot] 665eb95a13 chore(deps): update rust crate jiff to v0.2.27 2026-05-26 17:56:31 +02:00
renovate[bot] 0371374e7e chore(deps): update rust crate jiff to v0.2.25 2026-05-25 08:24:30 +02:00
xtqqczze 1129eeaeaf actions: add security audit workflow 2026-05-24 15:27:22 +02:00
renovate[bot] 400969043d chore(deps): update rust crate codspeed-criterion-compat to v4.7.0 2026-05-23 07:07:59 +02:00
renovate[bot] d464f3a2b1 chore(deps): update rust crate codspeed-criterion-compat to v4.6.0 2026-05-18 11:45:58 +02:00
renovate[bot] 025feb86f8 chore(deps): update rust crate winnow to v1.0.3 2026-05-15 07:18:53 +02:00
renovate[bot] 53ed79bcfc chore(deps): update rust crate jiff to v0.2.24 2026-04-24 07:12:33 +02:00
renovate[bot] d5bd1e8e4a chore(deps): update rust crate winnow to v1.0.2 2026-04-21 14:52:19 +02:00
renovate[bot] 7e9a8b502c chore(deps): update rust crate codspeed-criterion-compat to v4.5.0 2026-04-17 16:08:40 +02:00
20 changed files with 1394 additions and 376 deletions

No files matched your search

+15
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -1,7 +1,7 @@
[package]
name = "parse_datetime"
description = "parsing human-readable time strings and converting them to a Zoned datetime"
version = "0.15.0"
version = "0.17.0"
edition = "2021"
license = "MIT"
repository = "https://github.com/uutils/parse_datetime"
@@ -14,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"
+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) {
+133 -39
View File
@@ -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
View File
@@ -20,6 +20,10 @@ pub(crate) struct DateTimeBuilder {
time: Option<time::Time>,
weekday: Option<weekday::Weekday>,
offset: Option<offset::Offset>,
/// Whether the military timezone letter `j` was given. It is a timezone
/// item, so it excludes any other one, but it means local time and so
/// contributes no offset of its own.
local_zone: bool,
timezone: Option<jiff::tz::TimeZone>,
relative: Vec<relative::Relative>,
}
@@ -59,6 +63,7 @@ impl DateTimeBuilder {
|| self.time.is_some()
|| self.weekday.is_some()
|| self.offset.is_some()
|| self.local_zone
|| !self.relative.is_empty()
{
return Err("timestamp cannot be combined with other date/time items");
@@ -84,7 +89,7 @@ impl DateTimeBuilder {
return Err("timestamp cannot be combined with other date/time items");
} else if self.time.is_some() {
return Err("time cannot appear more than once");
} else if self.offset.is_some() && time.offset.is_some() {
} else if (self.offset.is_some() || self.local_zone) && time.offset.is_some() {
return Err("time offset and timezone are mutually exclusive");
}
@@ -107,6 +112,7 @@ impl DateTimeBuilder {
if self.timestamp.is_some() {
return Err("timestamp cannot be combined with other date/time items");
} else if self.offset.is_some()
|| self.local_zone
|| self.time.as_ref().and_then(|t| t.offset.as_ref()).is_some()
{
return Err("time offset cannot appear more than once");
@@ -116,6 +122,24 @@ impl DateTimeBuilder {
Ok(self)
}
/// Record the military timezone letter `j` (local time).
///
/// It occupies the same slot as a numeric offset, so GNU rejects `8j utc`,
/// `8 utc j` and `8 j j` as a repeated timezone, and so do we.
fn set_local_zone(mut self) -> Result<Self, &'static str> {
if self.timestamp.is_some() {
return Err("timestamp cannot be combined with other date/time items");
} else if self.offset.is_some()
|| self.local_zone
|| self.time.as_ref().and_then(|t| t.offset.as_ref()).is_some()
{
return Err("time offset cannot appear more than once");
}
self.local_zone = true;
Ok(self)
}
fn push_relative(mut self, relative: relative::Relative) -> Result<Self, &'static str> {
if self.timestamp.is_some() {
return Err("timestamp cannot be combined with other date/time items");
@@ -188,10 +212,15 @@ impl DateTimeBuilder {
/// from the base instant.
/// - b. Apply time. If time carries an explicit numeric offset, apply the
/// offset before setting time.
/// - c. Apply weekday (e.g., "next Friday" or "last Monday").
/// - 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
View File
@@ -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
View File
@@ -27,7 +27,7 @@
//! > ‘September’.
use winnow::{
ascii::{alpha1, multispace1},
ascii::alpha1,
combinator::{alt, eof, opt, preceded, terminated},
error::ErrMode,
stream::AsChar,
@@ -36,7 +36,7 @@ use winnow::{
};
use super::{
primitive::{ctx_err, dec_uint, s},
primitive::{ctx_err, dec_uint, multispace1, s},
year::{year_from_str, year_str},
};
@@ -127,8 +127,10 @@ impl TryFrom<Date> for jiff::civil::Date {
}
}
pub(super) fn parse(input: &mut &str) -> ModalResult<Date> {
alt((iso1, iso2, us, literal1, literal2, literal3)).parse_next(input)
/// Parse a date that is not in ISO format. ISO dates are parsed by [`iso1`]
/// and [`iso2`], which the caller tries first.
pub(super) fn parse_non_iso(input: &mut &str) -> ModalResult<Date> {
alt((us, literal1, literal2, literal3)).parse_next(input)
}
/// Parse `[year]-[month]-[day]`
@@ -299,7 +301,13 @@ fn day_from_str(s: &str) -> ModalResult<u8> {
#[cfg(test)]
mod tests {
use super::{parse, Date};
use winnow::{combinator::alt, ModalResult, Parser};
use super::Date;
fn parse(input: &mut &str) -> ModalResult<Date> {
alt((super::iso1, super::iso2, super::parse_non_iso)).parse_next(input)
}
// Test cases from the GNU docs:
//
+110 -5
View File
@@ -48,6 +48,8 @@ mod primitive;
pub(crate) mod error;
use std::borrow::Cow;
use crate::ParsedDateTime;
use jiff::Zoned;
use primitive::space;
@@ -69,6 +71,10 @@ enum Item {
Weekday(weekday::Weekday),
Relative(relative::Relative),
Offset(offset::Offset),
/// The military timezone letter `j`, which GNU `date` defines as local
/// time. It is a timezone item like [`Item::Offset`], but carries no
/// offset, so it cannot be represented as one.
LocalZone,
TimeZone(jiff::tz::TimeZone),
Pure(String),
}
@@ -155,7 +161,7 @@ pub(crate) fn parse_at_local<S: AsRef<str> + Clone>(input: S) -> Result<ParsedDa
///
/// time_offset = ( "+" | "-" ) , dec_uint , [ ":" , dec_uint ] ;
///
/// relative = [ numeric_ordinal ] , unit , [ "ago" ] | day_shift ;
/// relative = [ numeric_ordinal ] , unit , [ "ago" | "hence" ] | day_shift ;
///
/// unit = "year" | "years"
/// | "month" | "months"
@@ -232,8 +238,12 @@ fn parse_items(input: &mut &str) -> ModalResult<DateTimeBuilder> {
let tz = timezone::parse(input).map(Item::TimeZone);
// Convert input to lowercase for case-insensitive parsing.
let lower = input.to_ascii_lowercase();
let input = &mut lower.as_str();
let lower: Cow<str> = if input.bytes().any(|b| b.is_ascii_uppercase()) {
input.to_ascii_lowercase().into()
} else {
(*input).into()
};
let input = &mut lower.as_ref();
let (mut items, _): (Vec<Item>, _) = trace(
"parse_items",
@@ -251,15 +261,31 @@ fn parse_items(input: &mut &str) -> ModalResult<DateTimeBuilder> {
/// Parse an item.
fn parse_item(input: &mut &str) -> ModalResult<Item> {
// An ISO date is either the start of a combined date and time item or a
// date item on its own. Parse it once instead of once per alternative.
let start = input.checkpoint();
if let Ok(date) = alt((date::iso1, date::iso2)).parse_next(input) {
let after_date = input.checkpoint();
match combined::parse_time_after_date(input, date.clone()) {
Ok(dt) => return Ok(Item::DateTime(dt)),
Err(ErrMode::Backtrack(_)) => {
input.reset(&after_date);
return Ok(Item::Date(date));
}
Err(e) => return Err(e),
}
}
input.reset(&start);
trace(
"parse_item",
alt((
combined::parse.map(Item::DateTime),
date::parse.map(Item::Date),
date::parse_non_iso.map(Item::Date),
time::parse.map(Item::Time),
relative::parse.map(Item::Relative),
weekday::parse.map(Item::Weekday),
offset::parse.map(Item::Offset),
offset::parse_local.map(|()| Item::LocalZone),
pure::parse.map(Item::Pure),
)),
)
@@ -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
View File
@@ -168,46 +168,60 @@ impl Display for Offset {
}
}
/// Timezone abbreviations are assumed to be no longer than 6 characters.
const MAX_TZ_SIZE: usize = 6;
pub(super) fn parse(input: &mut &str) -> ModalResult<Offset> {
timezone_name_offset.parse_next(input)
}
/// Parse the military timezone letter `j`.
///
/// Every other military letter denotes a fixed offset from UTC, but GNU `date`
/// defines `j` as local time, which cannot be expressed as an [`Offset`] at
/// all. It is therefore reported to the builder as its own item.
///
/// The whole alphabetic word is consumed before comparing, so `jan` is left to
/// the date parser rather than being read as `j` followed by `an`, and `jj` is
/// rejected rather than matching a `j` prefix.
pub(super) fn parse_local(input: &mut &str) -> ModalResult<()> {
s(take_while(1..=MAX_TZ_SIZE, AsChar::is_alpha))
.verify(|word: &str| word.eq_ignore_ascii_case("j"))
.void()
.parse_next(input)
}
/// Parse a timezone starting with `+` or `-`.
pub(super) fn timezone_offset(input: &mut &str) -> ModalResult<Offset> {
// Strings like "+8 years" are ambiguous, they can either be parsed as a
// timezone offset "+8" and a relative time "years", or just a relative time
// "+8 years". GNU date parses them the second way, so we do the same here.
//
// Return early if the input can be parsed as a relative time.
// Like GNU, read "+8 years" as a relative item, not as "+8" and "years".
// The costlier relative check only runs once an offset has parsed.
let start = input.checkpoint();
let result = alt((timezone_offset_colon, timezone_offset_colonless)).parse_next(input);
if matches!(result, Err(ErrMode::Backtrack(_))) {
return result;
}
let end = input.checkpoint();
input.reset(&start);
if peek(relative::parse).parse_next(input).is_ok() {
return Err(ErrMode::Backtrack(ContextError::new()));
}
alt((timezone_offset_colon, timezone_offset_colonless)).parse_next(input)
input.reset(&end);
result
}
/// Parse a timezone by name, with an optional numeric offset appended.
fn timezone_name_offset(input: &mut &str) -> ModalResult<Offset> {
/// I'm assuming there are no timezone abbreviations with more
/// than 6 charactres
const MAX_TZ_SIZE: usize = 6;
let nextword = s(take_while(1..=MAX_TZ_SIZE, AsChar::is_alpha)).parse_next(input)?;
let tz = timezone_name_to_offset(nextword)?;
// Strings like "UTC +8 years" are ambiguous, they can either be parsed as
// "UTC+8" and "years", or "UTC" and "+8 years". GNU date parses them the
// second way, so we do the same here.
//
// Only process if the input cannot be parsed as a relative time.
if peek(relative::parse).parse_next(input).is_err() {
let start = input.checkpoint();
if let Ok(other_tz) = timezone_offset.parse_next(input) {
let new_tz = tz.merge(other_tz);
return Ok(new_tz);
};
input.reset(&start);
}
// Like GNU, read "UTC +8 years" as "UTC" and "+8 years", not as "UTC+8"
// and "years". `timezone_offset` rejects input that parses as relative.
let start = input.checkpoint();
if let Ok(other_tz) = timezone_offset.parse_next(input) {
return Ok(tz.merge(other_tz));
};
input.reset(&start);
Ok(tz)
}
@@ -271,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
View File
@@ -35,7 +35,7 @@ fn text_ordinal(input: &mut &str) -> ModalResult<i32> {
"fifth" => 5,
"sixth" => 6,
"seventh" => 7,
"eight" => 8,
"eighth" => 8,
"ninth" => 9,
"tenth" => 10,
"eleventh" => 11,
@@ -45,3 +45,39 @@ fn text_ordinal(input: &mut &str) -> ModalResult<i32> {
})
.parse_next(input)
}
#[cfg(test)]
mod tests {
use super::ordinal;
#[test]
fn text_ordinal() {
for (s, expected) in [
("last", -1),
("this", 0),
("next", 1),
("first", 1),
("third", 3),
("fourth", 4),
("fifth", 5),
("sixth", 6),
("seventh", 7),
("eighth", 8),
("ninth", 9),
("tenth", 10),
("eleventh", 11),
("twelfth", 12),
] {
let mut input = s;
assert_eq!(ordinal(&mut input).unwrap(), expected, "{s}");
assert!(input.is_empty(), "{s}");
}
}
#[test]
fn invalid_text_ordinal() {
for mut s in ["eight", "second", "foo"] {
assert!(ordinal(&mut s).is_err(), "{s}");
}
}
}
+52 -1
View File
@@ -6,7 +6,7 @@
use std::str::FromStr;
use winnow::{
ascii::{digit1, multispace0, Uint},
ascii::{digit1, Uint},
combinator::{alt, delimited, not, opt, peek, preceded, repeat, separated},
error::{ContextError, ParserError, StrContext, StrContextValue},
stream::AsChar,
@@ -26,6 +26,29 @@ where
preceded(space, p)
}
/// Same as winnow's `multispace0`, but also accepting `\v` and `\f`, like
/// the whitespace GNU `date` accepts between tokens
fn multispace0<'a, E>(input: &mut &'a str) -> winnow::Result<&'a str, E>
where
E: ParserError<&'a str>,
{
let len = input
.bytes()
.take_while(|b| matches!(b, b' ' | b'\t' | b'\n' | b'\x0B' | b'\x0C' | b'\r'))
.count();
let (ws, rest) = input.split_at(len);
*input = rest;
Ok(ws)
}
/// Same as [`multispace0`], but requiring at least one character
pub(super) fn multispace1<'a, E>(input: &mut &'a str) -> winnow::Result<&'a str, E>
where
E: ParserError<&'a str>,
{
take_while(1.., (' ', '\t', '\n', '\x0B', '\x0C', '\r')).parse_next(input)
}
/// Parse the space in-between tokens
///
/// You probably want to use the [`s`] combinator instead.
@@ -33,6 +56,11 @@ pub(super) fn space<'a, E>(input: &mut &'a str) -> winnow::Result<(), E>
where
E: ParserError<&'a str>,
{
// Fast path: most calls see no comment or ignorable sign
multispace0.parse_next(input)?;
if !input.starts_with(['(', '-', '+']) {
return Ok(());
}
separated(0.., multispace0, alt((comment, ignored_hyphen_or_plus))).parse_next(input)
}
@@ -202,4 +230,27 @@ mod tests {
);
}
}
#[test]
fn space_skips_comments_and_ignored_signs() {
for (input, rest) in [
("", ""),
("abc", "abc"),
(" \t abc", "abc"),
("(foo) abc", "abc"),
(" (a (b)) abc", "abc"),
("- abc", "abc"),
("+ (c) abc", "abc"),
("- (c) 12", "12"),
// A sign followed by a digit is not ignored.
("- 12", "- 12"),
(" +12", "+12"),
// An unbalanced comment is not skipped.
("(foo abc", "(foo abc"),
] {
let mut s = input;
space::<ContextError>(&mut s).unwrap();
assert_eq!(s, rest, "{input:?}");
}
}
}
+19 -7
View File
@@ -59,9 +59,19 @@ impl TryFrom<Relative> for jiff::Span {
Relative::Days(days) => jiff::Span::new().try_days(days),
Relative::Hours(hours) => jiff::Span::new().try_hours(hours),
Relative::Minutes(minutes) => jiff::Span::new().try_minutes(minutes),
Relative::Seconds(seconds, nanoseconds) => jiff::Span::new()
.try_seconds(seconds)
.and_then(|span| span.try_nanoseconds(nanoseconds)),
Relative::Seconds(seconds, nanoseconds) => {
// -0.25 is stored as (-1, 750_000_000), but a `jiff::Span` needs
// both fields with one sign, so move the fraction onto it.
let (seconds, nanoseconds) = if seconds < 0 && nanoseconds > 0 {
(seconds + 1, -i64::from(1_000_000_000 - nanoseconds))
} else {
(seconds, i64::from(nanoseconds))
};
jiff::Span::new()
.try_seconds(seconds)
.and_then(|span| span.try_nanoseconds(nanoseconds))
}
}
.map_err(|_| "relative value is invalid")
}
@@ -85,7 +95,7 @@ fn seconds(input: &mut &str) -> ModalResult<Relative> {
opt(alt((s('+').value(1), s('-').value(-1)))),
s(sec_and_nsec),
s(alpha1).verify(|s: &str| matches!(s, "seconds" | "second" | "sec" | "secs")),
ago,
direction,
)
.verify_map(|(sign, (sec, nsec), _, ago)| {
let sec = i64::try_from(sec).ok()?;
@@ -102,7 +112,7 @@ fn seconds(input: &mut &str) -> ModalResult<Relative> {
}
fn displacement(input: &mut &str) -> ModalResult<Relative> {
(opt(ordinal), s(alpha1), ago)
(opt(ordinal), s(alpha1), direction)
.verify_map(|(n, unit, ago): (Option<i32>, &str, bool)| {
let multiplier = n.unwrap_or(1) * if ago { -1 } else { 1 };
Some(match unit.strip_suffix('s').unwrap_or(unit) {
@@ -120,8 +130,10 @@ fn displacement(input: &mut &str) -> ModalResult<Relative> {
.parse_next(input)
}
fn ago(input: &mut &str) -> ModalResult<bool> {
opt(s("ago")).map(|o| o.is_some()).parse_next(input)
fn direction(input: &mut &str) -> ModalResult<bool> {
opt(alt((s("ago").value(true), s("hence").value(false))))
.map(|backward| backward.unwrap_or(false))
.parse_next(input)
}
#[cfg(test)]
+1 -1
View File
@@ -70,7 +70,7 @@ pub(super) fn parse(input: &mut &str) -> ModalResult<Weekday> {
}
fn day(input: &mut &str) -> ModalResult<Day> {
s(alpha1)
s((alpha1, opt('.')).take())
.verify_map(|s: &str| {
Some(match s {
"monday" | "mon" | "mon." => Day::Monday,
+235
View File
@@ -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
View File
@@ -75,6 +75,18 @@ fn test_absolute_date_numeric(#[case] input: &str, #[case] expected: &str) {
check_absolute(input, expected);
}
// Any run of ASCII whitespace may separate items, like in GNU date. The
// expected values were verified with GNU date (`date -d 'input'`).
#[rstest]
#[case::vertical_tab_between_date_and_year("Jan 23\x0B2026 1:00AM", "2026-01-23 01:00:00+00:00")]
#[case::form_feed_between_date_and_year("Jan 23\x0C2026 1:00AM", "2026-01-23 01:00:00+00:00")]
#[case::vertical_tab_after_comma("Jan 23,\x0B2026", "2026-01-23 00:00:00+00:00")]
#[case::vertical_tab_before_time("14 nov 2026\x0B1:00AM", "2026-11-14 01:00:00+00:00")]
#[case::vertical_tab_as_iso_separator("2026-01-23\x0B01:00:00", "2026-01-23 01:00:00+00:00")]
fn test_whitespace_between_items(#[case] input: &str, #[case] expected: &str) {
check_absolute(input, expected);
}
#[rstest]
#[case::us_style("11/14", 2022, "2022-11-14 00:00:00+00:00")]
#[case::alphabetical_full_month_in_front("november 14", 2022, "2022-11-14 00:00:00+00:00")]
@@ -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
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));
}