Compare commits

...
36 Commits
Author SHA1 Message Date
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
14 changed files with 881 additions and 226 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 }}
+8 -8
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 }}
@@ -148,7 +148,7 @@ jobs:
env:
RUN_FOR: 60
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: emulate nightly toolchain
run: echo "RUSTC_BOOTSTRAP=1" >> "${GITHUB_ENV}"
- name: Install `cargo-fuzz`
+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
+193 -154
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -1,7 +1,7 @@
[package]
name = "parse_datetime"
description = "parsing human-readable time strings and converting them to a Zoned datetime"
version = "0.15.0"
version = "0.16.0"
edition = "2021"
license = "MIT"
repository = "https://github.com/uutils/parse_datetime"
@@ -17,7 +17,7 @@ jiff = { version = "0.2.15", default-features = false, features = ["tz-system",
rstest = "0.26"
[dev-dependencies.codspeed-criterion-compat]
version = "4.0"
version = "5.0"
[[bench]]
name = "parse_datetime"
+113 -30
View File
@@ -13,15 +13,21 @@ dependencies = [
[[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",
@@ -35,6 +41,38 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "defmt"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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",
]
[[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.4.2"
@@ -76,25 +114,37 @@ dependencies = [
[[package]]
name = "jiff"
version = "0.2.23"
version = "0.2.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
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.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
dependencies = [
"jiff-core",
"proc-macro2",
"quote",
"syn",
@@ -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",
@@ -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",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -247,6 +319,26 @@ dependencies = [
"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",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -255,9 +347,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 +360,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"
+55 -16
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");
@@ -189,8 +213,8 @@ impl DateTimeBuilder {
/// - 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.
/// - d. Apply fixed offset if present (anchors the instant).
/// - e. Apply relative adjustments (e.g., "+3 days", "-2 months").
pub(super) fn build(self) -> Result<ParsedDateTime, error::Error> {
if let Some(date) = self.date.as_ref() {
if date.year.unwrap_or(0) > 9999 {
@@ -240,12 +264,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 +344,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 +375,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 +389,7 @@ impl DateTimeBuilder {
time,
weekday,
offset,
local_zone,
timezone,
relative,
} = self;
@@ -372,11 +403,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 +558,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)?,
@@ -678,6 +712,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 +747,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"
+53 -8
View File
@@ -16,7 +16,7 @@
//! > discarded.
use winnow::{
combinator::{alt, trace},
seq, ModalResult, Parser,
ModalResult, Parser,
};
use crate::items::space;
@@ -29,14 +29,32 @@ pub(crate) struct DateTime {
pub(crate) time: time::Time,
}
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.")
}
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)
let date = trace("iso_date", alt((date::iso1, date::iso2))).parse_next(input)?;
// 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)]
@@ -73,4 +91,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}");
}
}
}
+2 -2
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},
};
+45
View File
@@ -69,6 +69,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),
}
@@ -260,6 +264,7 @@ fn parse_item(input: &mut &str) -> ModalResult<Item> {
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 +382,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");
@@ -771,4 +790,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());
}
}
+58 -3
View File
@@ -168,10 +168,29 @@ 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
@@ -188,9 +207,6 @@ pub(super) fn timezone_offset(input: &mut &str) -> ModalResult<Offset> {
/// 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)?;
@@ -281,6 +297,7 @@ fn timezone_name_to_offset(input: &str) -> ModalResult<Offset> {
"w" => Ok("-10"),
"v" => Ok("-9"),
"utc" => Ok("+0"),
"ut" => Ok("+0"),
"u" => Ok("-8"),
"t" => Ok("-7"),
"sst" => Ok("-11"),
@@ -301,9 +318,13 @@ fn timezone_name_to_offset(input: &str) -> ModalResult<Offset> {
"mst" => Ok("-7"),
"msk" => Ok("+3"),
"msd" => Ok("+4"),
"mez" => Ok("+1"),
"mesz" => Ok("+2"),
"mest" => Ok("+2"),
"mdt" => Ok("-6"),
"m" => Ok("+12"),
"l" => Ok("+11"),
"kst" => Ok("+9"),
"k" => Ok("+10"),
"jst" => Ok("+9"),
"ist" => Ok("+5:30"),
@@ -423,12 +444,19 @@ 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)),
("z123", off(false, 0, 0)), // space separator can be ignored if immediately followed by digits (GNU date behavior)
] {
let mut s = input;
@@ -482,4 +510,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());
}
}
+18 -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,23 @@ 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>,
{
take_while(0.., (' ', '\t', '\n', '\x0B', '\x0C', '\r')).parse_next(input)
}
/// 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.
+168
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
@@ -694,6 +745,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();
@@ -887,4 +951,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);
}
}
}
+149
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,140 @@ 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);
}
// 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"
);
}