Compare commits

...
50 Commits
Author SHA1 Message Date
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
Sylvestre Ledru 7440dd9ecb docs: update crate description to reflect Zoned return type
The crate now returns jiff Zoned objects, not DateTime.
2026-04-01 10:42:09 +02:00
renovate[bot] e5b92637b1 chore(deps): update moonrepo/setup-rust action to v1 2026-04-01 10:26:37 +02:00
Sylvestre Ledru 16ce783763 Add assertions for month and day in date parsing test 2026-04-01 10:26:21 +02:00
Sylvestre Ledru 62f26a49b3 tests: cover Display fractional-second formatting 2026-04-01 10:26:21 +02:00
Sylvestre Ledru 01687f304d Display: preserve fractional seconds when present
When nanoseconds are non-zero, include them in Display output as
`.NNNNNNNNN` between seconds and the offset. This avoids silently
dropping sub-second precision from the formatted representation.
2026-04-01 10:26:21 +02:00
Sylvestre Ledru 3767622cc3 ci: add fuzz_large_year target to CI workflow 2026-04-01 10:26:21 +02:00
Sylvestre Ledru 93f2eafcf7 docs: add ExtendedDateTime usage example to README 2026-04-01 10:26:21 +02:00
Sylvestre Ledru 42233b0c81 Add large-year coverage to fuzz target
Add a dedicated fuzz_large_year target with structured inputs biased
toward large years (near 9999 boundary, up to GNU_MAX_YEAR). Keep the
original fuzz_parse_datetime target unchanged for raw-bytes fuzzing.
2026-04-01 10:26:21 +02:00
Sylvestre Ledru e9b8f7a43e Deduplicate test helpers using Display and expect_in_range
Replace hand-rolled format_offset_colon / format_for_assert helpers
with the new Display impls. Replace local expect_in_range_datetime
test helpers with ParsedDateTime::expect_in_range().
2026-04-01 10:26:21 +02:00
Sylvestre Ledru 22da5707a6 Remove Item::Timestamp enum variant
The Timestamp variant was #[cfg(test)]-gated dead code outside tests.
The parse_timestamp function already calls set_timestamp on the builder
directly, bypassing the TryFrom<Vec<Item>> path. Remove the variant and
refactor builder tests to exercise set_timestamp directly instead.
2026-04-01 10:26:21 +02:00
Sylvestre Ledru 2efa5c1d7b Add doc comments on expect_in_range and PartialEq<Zoned>
Document that expect_in_range is intended for tests/trusted contexts
and that Extended values never compare equal to a Zoned.
2026-04-01 10:26:21 +02:00
Sylvestre Ledru f7aa4540c9 Add Display impls for ExtendedDateTime and ParsedDateTime
Provide a standard formatting path so callers (and tests) can use
`.to_string()` instead of hand-rolling offset formatting everywhere.
2026-04-01 10:26:21 +02:00
renovate[bot] 72cada4740 chore(deps): update rust crate codspeed-criterion-compat to v4 2026-04-01 10:22:53 +02:00
Daniel Hofstetter 108ebcb04a ci: use "cargo check" for MSRV check (#278) 2026-04-01 09:56:10 +02:00
Sylvestre Ledru fe0fd40ea5 Add benchmarks for extended year / ParsedDateTime API (#276)
Cover the extended year functionality introduced in #263:
- Basic extended year parsing (year 10000)
- Rollover from in-range to extended (9999-12-31 +1 day)
- Relative parsing with extended year base date
- Large year parsing (year 999999)
2026-04-01 08:01:02 +02:00
codspeed-hq[bot] 30ea3911d7 Add CodSpeed performance benchmarks (#273)
Co-authored-by: codspeed-hq[bot] <117304815+codspeed-hq[bot]@users.noreply.github.com>
2026-03-31 23:48:09 +02:00
abhishekpradhan ee4971cc73 Add extended runtime + ParsedDateTime API for large years (#263) 2026-03-31 09:51:07 +02:00
oech3 f6f1257961 ci: simplify, reproducible and fast setup 2026-03-31 09:32:44 +02:00
renovate[bot] 82b82df5b9 chore(deps): update rust crate winnow to v1.0.1 2026-03-30 15:57:54 +02:00
renovate[bot] d46a321e82 chore(deps): update codecov/codecov-action action to v6 2026-03-27 07:23:15 +01:00
renovate[bot] cfcf9c307d fix(deps): update rust crate winnow to v1 2026-03-18 07:07:44 +01:00
renovate[bot] d3367318eb chore(deps): update rust crate winnow to v0.7.15 2026-03-06 07:20:24 +01:00
renovate[bot] 8c59af1c99 chore(deps): update rust crate jiff to v0.2.23 2026-03-04 07:03:13 +01:00
renovate[bot] 7aeb87d88e chore(deps): update rust crate jiff to v0.2.22 2026-03-01 10:43:59 +01:00
renovate[bot] 65f9eac853 chore(deps): update rust crate jiff to v0.2.21 2026-02-23 07:15:17 +01:00
abhishekpradhanandSylvestre Ledru 4a1b3f3777 Groundwork: widen year parsing bounds for GNU max year (#261)
---------

Co-authored-by: Sylvestre Ledru <sylvestre@debian.org>
2026-02-22 17:17:44 +01:00
24 changed files with 3389 additions and 351 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 }}
+26 -25
View File
@@ -3,6 +3,7 @@ on: [push, pull_request]
name: Basic CI
env:
CARGO_INCREMENTAL: "0"
CARGO_TERM_COLOR: always
RUST_MIN_SRV: "1.71.1"
@@ -14,8 +15,7 @@ jobs:
matrix:
os: [ubuntu-latest, macOS-latest, windows-latest]
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: actions/checkout@v7
- run: cargo check
test:
@@ -25,17 +25,14 @@ jobs:
matrix:
os: [ubuntu-latest, macOS-latest, windows-latest]
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: actions/checkout@v7
- run: cargo test
fmt:
name: cargo fmt --all -- --check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- run: rustup component add rustfmt
- uses: actions/checkout@v7
- run: cargo fmt --all -- --check
clippy:
@@ -45,9 +42,7 @@ jobs:
matrix:
os: [ubuntu-latest, macOS-latest, windows-latest]
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- run: rustup component add clippy
- uses: actions/checkout@v7
- run: cargo clippy --all-targets -- -D warnings
min_version:
@@ -57,11 +52,11 @@ 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 }}
- run: cargo test
- run: cargo check
coverage:
name: Code Coverage
@@ -74,15 +69,15 @@ 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
id: vars
shell: bash
run: |
## VARs setup
outputs() { step_id="vars"; for var in "$@" ; do echo steps.${step_id}.outputs.${var}="${!var}"; echo "${var}=${!var}" >> $GITHUB_OUTPUT; done; }
# toolchain
TOOLCHAIN="nightly" ## default to "nightly" toolchain (required for certain required unstable compiler flags) ## !maint: refactor when stable channel has needed support
# * specify gnu-type TOOLCHAIN for windows; `grcov` requires gnu-style code coverage data files
case ${{ matrix.job.os }} in windows-*) TOOLCHAIN="$TOOLCHAIN-x86_64-pc-windows-gnu" ;; esac;
# * use requested TOOLCHAIN if specified
@@ -94,13 +89,12 @@ jobs:
outputs CODECOV_FLAGS
- name: rust toolchain ~ install
uses: dtolnay/rust-toolchain@nightly
uses: dtolnay/rust-toolchain@nightly # this job is not reproducible...
with:
components: llvm-tools-preview
- name: Test
run: cargo test --no-fail-fast
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: ""
RUSTFLAGS: "-Cinstrument-coverage -Zcoverage-options=branch -Ccodegen-units=1 -Copt-level=0 -Coverflow-checks=off -Zpanic_abort_tests -Cpanic=abort"
RUSTDOCFLAGS: "-Cpanic=abort"
@@ -109,12 +103,12 @@ jobs:
id: build_grcov
shell: bash
run: |
git clone https://github.com/mozilla/grcov.git ~/grcov/
git clone --depth=1 https://github.com/mozilla/grcov.git ~/grcov/
cd ~/grcov
# Hardcode the version of crossbeam-epoch. See
# https://github.com/uutils/coreutils/issues/3680
sed -i -e "s|tempfile =|crossbeam-epoch = \"=0.9.8\"\ntempfile =|" Cargo.toml
cargo install --path .
cargo install --path . --locked
cd -
# Uncomment when the upstream issue
# https://github.com/mozilla/grcov/issues/849 is fixed
@@ -137,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@v5
uses: codecov/codecov-action@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ${{ steps.coverage.outputs.report }}
@@ -154,14 +148,21 @@ jobs:
env:
RUN_FOR: 60
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@nightly
- uses: actions/checkout@v7
- name: emulate nightly toolchain
run: echo "RUSTC_BOOTSTRAP=1" >> "${GITHUB_ENV}"
- name: Install `cargo-fuzz`
run: cargo install cargo-fuzz
run: cargo install cargo-fuzz --locked
- uses: Swatinem/rust-cache@v2
- name: Run from_str for XX seconds
- name: Run fuzz_parse_datetime for XX seconds
shell: bash
run: |
## Run it
cd fuzz
cargo +nightly fuzz run fuzz_parse_datetime -- -max_total_time=${{ env.RUN_FOR }} -detect_leaks=0
cargo fuzz run 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
+37
View File
@@ -0,0 +1,37 @@
name: CodSpeed
on:
push:
branches:
- "main"
pull_request:
# `workflow_dispatch` allows CodSpeed to trigger backtest
# performance analysis in order to generate initial data.
workflow_dispatch:
permissions:
contents: read
id-token: write
jobs:
codspeed:
name: Run benchmarks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Setup rust toolchain, cache and cargo-codspeed binary
uses: moonrepo/setup-rust@v1
with:
channel: stable
cache-target: release
bins: cargo-codspeed
- name: Build the benchmark target(s)
run: cargo codspeed build
- name: Run the benchmarks
uses: CodSpeedHQ/action@v4
with:
mode: simulation
run: cargo codspeed run
Generated
+742 -79
View File
File diff suppressed because it is too large. Load diff
+10 -3
View File
@@ -1,7 +1,7 @@
[package]
name = "parse_datetime"
description = "parsing human-readable time strings and converting them to a DateTime"
version = "0.14.0"
description = "parsing human-readable time strings and converting them to a Zoned datetime"
version = "0.15.0"
edition = "2021"
license = "MIT"
repository = "https://github.com/uutils/parse_datetime"
@@ -9,9 +9,16 @@ readme = "README.md"
rust-version = "1.71.1"
[dependencies]
winnow = "0.7.10"
winnow = "1.0.0"
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"
[dev-dependencies.codspeed-criterion-compat]
version = "5.0"
[[bench]]
name = "parse_datetime"
harness = false
+32 -9
View File
@@ -3,8 +3,10 @@
[![Crates.io](https://img.shields.io/crates/v/parse_datetime.svg)](https://crates.io/crates/parse_datetime)
[![License](http://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/uutils/parse_datetime/blob/main/LICENSE)
[![CodeCov](https://codecov.io/gh/uutils/parse_datetime/branch/main/graph/badge.svg)](https://codecov.io/gh/uutils/parse_datetime)
[![CodSpeed](https://img.shields.io/endpoint?url=https://codspeed.io/badge.json)](https://codspeed.io/uutils/parse_datetime?utm_source=badge)
A Rust crate for parsing human-readable relative time strings and human-readable datetime strings and converting them to a jiff's `Zoned` object.
A Rust crate for parsing human-readable relative time strings and
human-readable datetime strings.
## Features
@@ -26,25 +28,44 @@ Then, import the crate and use the `parse_datetime_at_date` function:
```rs
use jiff::{ToSpan, Zoned};
use parse_datetime::parse_datetime_at_date;
use parse_datetime::{parse_datetime_at_date, ParsedDateTime};
let now = Zoned::now();
let after = parse_datetime_at_date(now.clone(), "+3 days");
assert_eq!(
now.checked_add(3.days()).unwrap(),
after.unwrap()
);
match after.unwrap() {
ParsedDateTime::InRange(z) => assert_eq!(now.checked_add(3.days()).unwrap(), z),
ParsedDateTime::Extended(_) => unreachable!("unexpected for this input"),
}
```
For DateTime parsing, import the `parse_datetime` function:
```rs
use jiff::{civil::{date, time} ,Zoned};
use parse_datetime::parse_datetime;
use parse_datetime::{parse_datetime, ParsedDateTime};
let dt = parse_datetime("2021-02-14 06:37:47");
assert_eq!(dt.unwrap(), Zoned::now().with().date(date(2021, 2, 14)).time(time(6, 37, 47, 0)).build().unwrap());
match dt.unwrap() {
ParsedDateTime::InRange(z) => assert_eq!(z, Zoned::now().with().date(date(2021, 2, 14)).time(time(6, 37, 47, 0)).build().unwrap()),
ParsedDateTime::Extended(_) => unreachable!("unexpected for this input"),
}
```
For years beyond jiff's representable range (e.g., year 10000+), the result is an `ExtendedDateTime`:
```rs
use parse_datetime::{parse_datetime, ParsedDateTime};
let dt = parse_datetime("12000-01-01").unwrap();
match dt {
ParsedDateTime::Extended(ext) => {
assert_eq!(ext.year, 12000);
assert_eq!(ext.month, 1);
assert_eq!(ext.day, 1);
}
ParsedDateTime::InRange(_) => unreachable!("year 12000 is out of jiff range"),
}
```
### Supported Formats
@@ -69,7 +90,9 @@ The `parse_datetime` and `parse_datetime_at_date` functions support absolute dat
The `parse_datetime` and `parse_datetime_at_date` function return:
- `Ok(Zoned)` - If the input string can be parsed as a `Zoned` object
- `Ok(ParsedDateTime)` - If the input string can be parsed
- `ParsedDateTime::InRange(Zoned)` for years supported by `jiff::Zoned`
- `ParsedDateTime::Extended(ExtendedDateTime)` for out-of-range years (for example `>9999`)
- `Err(ParseDateTimeError::InvalidInput)` - If the input string cannot be parsed
## Fuzzer
+146
View File
@@ -0,0 +1,146 @@
use codspeed_criterion_compat::{criterion_group, criterion_main, Criterion};
use jiff::Zoned;
use parse_datetime::{parse_datetime, parse_datetime_at_date};
fn bench_iso_datetime(c: &mut Criterion) {
c.bench_function("parse_iso_datetime", |b| {
b.iter(|| parse_datetime("2021-02-14 06:37:47 +0000"))
});
}
fn bench_iso_datetime_t_sep(c: &mut Criterion) {
c.bench_function("parse_iso_datetime_t_separator", |b| {
b.iter(|| parse_datetime("2021-02-14T22:37:47-0800"))
});
}
fn bench_date_only(c: &mut Criterion) {
c.bench_function("parse_date_only", |b| {
b.iter(|| parse_datetime("1997-01-01"))
});
}
fn bench_date_slash_format(c: &mut Criterion) {
c.bench_function("parse_date_slash_format", |b| {
b.iter(|| parse_datetime("05/07/1987"))
});
}
fn bench_epoch_timestamp(c: &mut Criterion) {
c.bench_function("parse_epoch_timestamp", |b| {
b.iter(|| parse_datetime("@1613371067"))
});
}
fn bench_relative_time(c: &mut Criterion) {
let now = Zoned::now();
c.bench_function("parse_relative_time", |b| {
b.iter(|| parse_datetime_at_date(now.clone(), "+3 days"))
});
}
fn bench_relative_time_complex(c: &mut Criterion) {
let now = Zoned::now();
c.bench_function("parse_relative_time_complex", |b| {
b.iter(|| parse_datetime_at_date(now.clone(), "1 year 3 months 2 days ago"))
});
}
fn bench_relative_keywords(c: &mut Criterion) {
c.bench_function("parse_yesterday", |b| {
b.iter(|| parse_datetime("yesterday"))
});
c.bench_function("parse_tomorrow", |b| b.iter(|| parse_datetime("tomorrow")));
c.bench_function("parse_now", |b| b.iter(|| parse_datetime("now")));
}
fn bench_weekday(c: &mut Criterion) {
c.bench_function("parse_weekday", |b| b.iter(|| parse_datetime("wednesday")));
}
fn bench_timezone_offset(c: &mut Criterion) {
c.bench_function("parse_timezone_offset", |b| {
b.iter(|| parse_datetime("UTC+07:00"))
});
}
fn bench_datetime_with_delta(c: &mut Criterion) {
c.bench_function("parse_datetime_with_delta", |b| {
b.iter(|| parse_datetime("1997-01-01 00:00:00 +0000 +1 year"))
});
}
fn bench_ctime_format(c: &mut Criterion) {
c.bench_function("parse_ctime_format", |b| {
b.iter(|| parse_datetime("Wed Jan 1 00:00:00 1997"))
});
}
fn bench_datetime_with_timezone_name(c: &mut Criterion) {
c.bench_function("parse_datetime_with_tz_name", |b| {
b.iter(|| parse_datetime("1997-01-19 08:17:48 BRT"))
});
}
fn bench_datetime_ending_in_z(c: &mut Criterion) {
c.bench_function("parse_datetime_ending_in_z", |b| {
b.iter(|| parse_datetime("2023-06-03 12:00:01Z"))
});
}
fn bench_invalid_input(c: &mut Criterion) {
c.bench_function("parse_invalid_input", |b| {
b.iter(|| parse_datetime("NotADate"))
});
}
fn bench_extended_year(c: &mut Criterion) {
c.bench_function("parse_extended_year", |b| {
b.iter(|| parse_datetime("10000-01-01"))
});
}
fn bench_extended_year_rollover(c: &mut Criterion) {
c.bench_function("parse_extended_year_rollover", |b| {
b.iter(|| parse_datetime("9999-12-31 +1 day"))
});
}
fn bench_extended_year_relative(c: &mut Criterion) {
let base = jiff::civil::DateTime::from(jiff::civil::date(2000, 1, 1))
.to_zoned(jiff::tz::TimeZone::UTC)
.unwrap();
c.bench_function("parse_extended_year_relative", |b| {
b.iter(|| parse_datetime_at_date(base.clone(), "10000-01-01 +1 day"))
});
}
fn bench_extended_large_year(c: &mut Criterion) {
c.bench_function("parse_extended_large_year", |b| {
b.iter(|| parse_datetime("999999-06-15"))
});
}
criterion_group!(
benches,
bench_iso_datetime,
bench_iso_datetime_t_sep,
bench_date_only,
bench_date_slash_format,
bench_epoch_timestamp,
bench_relative_time,
bench_relative_time_complex,
bench_relative_keywords,
bench_weekday,
bench_timezone_offset,
bench_datetime_with_delta,
bench_ctime_format,
bench_datetime_with_timezone_name,
bench_datetime_ending_in_z,
bench_invalid_input,
bench_extended_year,
bench_extended_year_rollover,
bench_extended_year_relative,
bench_extended_large_year,
);
criterion_main!(benches);
+140 -52
View File
@@ -7,18 +7,27 @@ name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
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.47"
version = "1.2.62"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd405d82c84ff7f35739f175f67d8b9fb7687a0e84ccdc78bd3568839827cf07"
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -33,15 +42,60 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "find-msvc-tools"
version = "0.1.5"
name = "defmt"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844"
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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fuzz_parse_datetime"
version = "0.2.0"
dependencies = [
"arbitrary",
"jiff",
"libfuzzer-sys",
"parse_datetime",
]
@@ -60,24 +114,25 @@ dependencies = [
[[package]]
name = "jiff"
version = "0.2.16"
version = "0.2.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35"
checksum = "ccfe6121cbe750cf81efa362d85c0bde7ea298ec43092d3a193baca59cdbd634"
dependencies = [
"defmt",
"jiff-static",
"jiff-tzdb-platform",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
"windows-sys",
"windows-link",
]
[[package]]
name = "jiff-static"
version = "0.2.16"
version = "0.2.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69"
checksum = "e165e897f662d428f3cd3828a919dbe067c2d42bb1031eede74ef9d27ecdedd2"
dependencies = [
"proc-macro2",
"quote",
@@ -86,9 +141,9 @@ dependencies = [
[[package]]
name = "jiff-tzdb"
version = "0.1.4"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1283705eb0a21404d2bfd6eef2a7593d240bc42a0bdb39db0ad6fa2ec026524"
checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076"
[[package]]
name = "jiff-tzdb-platform"
@@ -111,15 +166,15 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.177"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
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",
@@ -127,15 +182,15 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.28"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "memchr"
version = "2.7.6"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "num-traits"
@@ -148,7 +203,7 @@ dependencies = [
[[package]]
name = "parse_datetime"
version = "0.13.3"
version = "0.15.0"
dependencies = [
"jiff",
"num-traits",
@@ -157,33 +212,55 @@ dependencies = [
[[package]]
name = "portable-atomic"
version = "1.11.1"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-atomic-util"
version = "0.2.4"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
dependencies = [
"portable-atomic",
]
[[package]]
name = "proc-macro2"
version = "1.0.103"
name = "proc-macro-error-attr2"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.42"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
@@ -222,9 +299,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "syn"
version = "2.0.110"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
@@ -232,16 +309,36 @@ dependencies = [
]
[[package]]
name = "unicode-ident"
version = "1.0.22"
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "wasip2"
version = "1.0.1+wasi-0.2.4"
version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
"wit-bindgen",
]
@@ -252,26 +349,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.13"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
version = "0.46.0"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+8
View File
@@ -8,6 +8,8 @@ cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4.7"
arbitrary = { version = "1", features = ["derive"] }
jiff = "0.2"
[dependencies.parse_datetime]
path = "../"
@@ -17,3 +19,9 @@ name = "fuzz_parse_datetime"
path = "fuzz_targets/parse_datetime.rs"
test = false
doc = false
[[bin]]
name = "fuzz_large_year"
path = "fuzz_targets/large_year.rs"
test = false
doc = false
+64
View File
@@ -0,0 +1,64 @@
#![no_main]
use arbitrary::Arbitrary;
use jiff::{civil::DateTime, tz::TimeZone};
use libfuzzer_sys::fuzz_target;
#[derive(Arbitrary, Debug)]
struct Input {
/// Year for the base date (biased toward boundary years).
base_year_selector: u8,
/// Year to embed in a constructed large-year input string.
input_year: u32,
month: u8,
day: u8,
/// Suffix appended after the constructed date (e.g. relative items).
suffix: String,
/// Whether to also call parse_datetime (no base).
try_no_base: bool,
}
fn base_year(selector: u8) -> i16 {
match selector % 6 {
0 => 2024,
1 => 9998,
2 => 9999,
3 => 1,
4 => 100,
_ => (selector as i16) * 40,
}
}
fn clamp_year(y: u32) -> u32 {
// Focus on the interesting range: 9990..=100_000 and 0..=20_000
match y % 4 {
0 => 9990 + (y % 20), // near boundary
1 => 10000 + (y % 90_000), // large years
2 => y % 20_000, // general range
_ => 2_147_485_540 + (y % 10), // near GNU_MAX_YEAR
}
}
fuzz_target!(|input: Input| {
let year = clamp_year(input.input_year);
let month = (input.month % 12) + 1;
let day = (input.day % 28) + 1;
let date_str = format!("{year:04}-{month:02}-{day:02} {}", input.suffix);
// Test parse_datetime (uses current time as base).
if input.try_no_base {
let _ = parse_datetime::parse_datetime(&date_str);
}
// Test parse_datetime_at_date with a controlled base.
let by = base_year(input.base_year_selector);
if let Ok(base) = DateTime::new(by, 1, 1, 0, 0, 0, 0) {
if let Ok(base) = base.to_zoned(TimeZone::UTC) {
let _ = parse_datetime::parse_datetime_at_date(base, &date_str);
}
}
// Also try a bare large year as a pure number.
let bare = format!("{year}");
let _ = parse_datetime::parse_datetime(&bare);
});
+757
View File
File diff suppressed because it is too large. Load diff
+611 -63
View File
File diff suppressed because it is too large. Load diff
+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}");
}
}
}
+8 -8
View File
@@ -44,11 +44,11 @@ use super::{
pub(crate) struct Date {
pub(crate) day: u8,
pub(crate) month: u8,
pub(crate) year: Option<u16>,
pub(crate) year: Option<u32>,
}
impl Date {
pub(super) fn with_year(self, year: u16) -> Self {
pub(super) fn with_year(self, year: u32) -> Self {
Date {
day: self.day,
month: self.month,
@@ -118,12 +118,12 @@ impl TryFrom<Date> for jiff::civil::Date {
type Error = &'static str;
fn try_from(date: Date) -> Result<Self, Self::Error> {
jiff::civil::Date::new(
date.year.unwrap_or(0) as i16,
date.month as i8,
date.day as i8,
)
.map_err(|_| "date is not valid")
let year = date.year.unwrap_or(0);
let year: i16 = year
.try_into()
.map_err(|_| "date year is outside the supported range")?;
jiff::civil::Date::new(year, date.month as i8, date.day as i8)
.map_err(|_| "date is not valid")
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ use super::primitive::{dec_uint, plus_or_minus, s};
/// - `nanosecond` is always in the range of `0..1_000_000_000`.
/// - Negative timestamps are represented by a negative `second` value and a
/// positive `nanosecond` value.
#[derive(Debug, PartialEq)]
#[derive(Debug, PartialEq, Clone, Copy)]
pub(super) struct Timestamp {
second: i64,
nanosecond: u32,
+227 -21
View File
@@ -48,6 +48,7 @@ mod primitive;
pub(crate) mod error;
use crate::ParsedDateTime;
use jiff::Zoned;
use primitive::space;
use winnow::{
@@ -62,7 +63,6 @@ use error::Error;
#[derive(PartialEq, Debug)]
enum Item {
Timestamp(epoch::Timestamp),
DateTime(combined::DateTime),
Date(date::Date),
Time(time::Time),
@@ -73,18 +73,21 @@ enum Item {
Pure(String),
}
/// Parse a date and time string and build a `Zoned` object. The parsed result
/// is resolved against the given base date and time.
pub(crate) fn parse_at_date<S: AsRef<str> + Clone>(base: Zoned, input: S) -> Result<Zoned, Error> {
/// Parse a date and time string and resolve it against the given base date and
/// time, returning a [`ParsedDateTime`] result.
pub(crate) fn parse_at_date<S: AsRef<str> + Clone>(
base: Zoned,
input: S,
) -> Result<ParsedDateTime, Error> {
match parse(&mut input.as_ref()) {
Ok(builder) => builder.set_base(base).build(),
Err(e) => Err(e.into()),
}
}
/// Parse a date and time string and build a `Zoned` object. The parsed result
/// is resolved against the current local date and time.
pub(crate) fn parse_at_local<S: AsRef<str> + Clone>(input: S) -> Result<Zoned, Error> {
/// Parse a date and time string and resolve it against the current local date
/// and time, returning a [`ParsedDateTime`] result.
pub(crate) fn parse_at_local<S: AsRef<str> + Clone>(input: S) -> Result<ParsedDateTime, Error> {
match parse(&mut input.as_ref()) {
Ok(builder) => builder.build(), // the builder uses current local date and time if no base is given.
Err(e) => Err(e.into()),
@@ -209,12 +212,9 @@ fn parse_timestamp(input: &mut &str) -> ModalResult<DateTimeBuilder> {
trace(
"parse_timestamp",
// Expect exactly one timestamp and then EOF (allowing trailing spaces).
terminated(epoch::parse.map(Item::Timestamp), preceded(space, eof)),
terminated(epoch::parse, preceded(space, eof)),
)
.verify_map(|item: Item| match item {
Item::Timestamp(ts) => DateTimeBuilder::new().set_timestamp(ts).ok(),
_ => None,
})
.verify_map(|ts| DateTimeBuilder::new().set_timestamp(ts).ok())
.parse_next(input)
}
@@ -277,12 +277,13 @@ fn expect_error(input: &mut &str, reason: &'static str) -> ErrMode<ContextError>
#[cfg(test)]
mod tests {
use crate::ParsedDateTime;
use jiff::{civil::DateTime, tz::TimeZone, ToSpan, Zoned};
use super::*;
fn at_date(builder: DateTimeBuilder, base: Zoned) -> Zoned {
builder.set_base(base).build().unwrap()
builder.set_base(base).build().unwrap().expect_in_range()
}
fn at_utc(builder: DateTimeBuilder) -> Zoned {
@@ -299,6 +300,22 @@ mod tests {
.to_string()
}
fn assert_extended_datetime(input: &str, base: Zoned, expected: &str) {
let parsed = parse_at_date(base, input).unwrap();
assert!(
matches!(parsed, ParsedDateTime::Extended(_)),
"expected extended datetime, got in-range for: {input}"
);
assert_eq!(parsed.to_string(), expected, "{input}");
}
fn expect_extended_datetime(parsed: ParsedDateTime) -> crate::ExtendedDateTime {
match parsed {
ParsedDateTime::Extended(dt) => dt,
ParsedDateTime::InRange(z) => panic!("expected extended datetime, got in-range: {z}"),
}
}
#[test]
fn date_and_time() {
assert_eq!(
@@ -360,6 +377,14 @@ 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 "");
@@ -408,13 +433,12 @@ mod tests {
let result = parse(&mut "2025-05-19 @1690466034");
assert!(result.is_err());
// Pure number as year (too large).
// Pure number as year (large years are accepted).
let result = parse(&mut "jul 18 12:30 10000");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("year must be no greater than 9999"));
assert!(result.is_ok());
let built = result.unwrap().build().unwrap();
let dt = expect_extended_datetime(built);
assert_eq!(dt.year, 10000);
// Pure number as time (too long).
let result = parse(&mut "01:02 12345");
@@ -449,6 +473,184 @@ mod tests {
.contains("invalid minute in pure number"));
}
#[test]
fn negative_base_year_with_yearless_date_errors() {
let base = DateTime::new(-1, 1, 1, 0, 0, 0, 0)
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
let result = parse_at_date(base, "11/14");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("base year must be non-negative"));
}
#[test]
fn boundary_rollover_from_9999_falls_back_to_extended() {
let base = "2000-01-01 00:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
assert_extended_datetime("9999-12-31 +1 day", base, "10000-01-01 00:00:00+00:00");
}
#[test]
fn boundary_year_9999_absolute_date_parses() {
let base = "2000-01-01 00:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
assert_extended_datetime("9999-12-31", base, "9999-12-31 00:00:00+00:00");
}
#[test]
fn boundary_year_9999_with_time_parses() {
let base = "2000-01-01 00:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
assert_extended_datetime("9999-12-31 12:00", base, "9999-12-31 12:00:00+00:00");
}
#[test]
fn boundary_year_9999_with_utc_timezone_parses() {
let base = "2000-01-01 00:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
assert_extended_datetime("9999-12-31 12:00 UTC", base, "9999-12-31 12:00:00+00:00");
}
#[test]
fn boundary_year_9999_with_explicit_offset_parses() {
let base = "2000-01-01 00:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
assert_extended_datetime("9999-12-31 12:00 +01:00", base, "9999-12-31 12:00:00+01:00");
}
#[test]
fn large_year_relative_parity_months_and_years() {
let base = "2000-01-01 00:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
assert_extended_datetime(
"10000-01-31 +2 months",
base.clone(),
"10000-03-31 00:00:00+00:00",
);
assert_extended_datetime(
"10000-01-31 +3 months",
base.clone(),
"10000-05-01 00:00:00+00:00",
);
assert_extended_datetime(
"10000-08-31 +6 months",
base.clone(),
"10001-03-03 00:00:00+00:00",
);
assert_extended_datetime(
"10000-05-31 3 months ago",
base.clone(),
"10000-03-02 00:00:00+00:00",
);
assert_extended_datetime("10000-02-29 +1 year", base, "10001-03-01 00:00:00+00:00");
}
#[test]
fn rejects_year_above_gnu_max() {
assert!(parse_at_local("2147485548-01-01").is_err());
}
#[test]
fn large_year_can_return_in_range_after_relative() {
let base = "2000-01-01 00:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
let result = parse_at_date(base, "10000-01-01 -1000 years").unwrap();
let z = result.expect_in_range();
assert_eq!(
z.strftime("%Y-%m-%d %H:%M:%S%:z").to_string(),
"9000-01-01 00:00:00+00:00"
);
}
#[test]
fn large_year_can_return_in_range_after_relative_with_named_timezone_rule() {
let base = "2000-01-01 00:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
let result = parse_at_date(base, "TZ=\"Europe/Paris\" 10000-01-01 -1000 years").unwrap();
let z = result.expect_in_range();
assert_eq!(
z.strftime("%Y-%m-%d %H:%M:%S%:z").to_string(),
"9000-01-01 00:00:00+01:00"
);
}
#[test]
fn large_year_time_with_explicit_offset_is_extended() {
let base = "2000-01-01 00:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
let dt =
expect_extended_datetime(parse_at_date(base, "10000-01-01 12:34:56+02:00").unwrap());
assert_eq!((dt.year, dt.month, dt.day), (10000, 1, 1));
assert_eq!((dt.hour, dt.minute, dt.second), (12, 34, 56));
assert_eq!(dt.offset_seconds, 2 * 3600);
}
#[test]
fn parse_at_date_returns_error_for_invalid_input() {
let base = "2000-01-01 00:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
let result = parse_at_date(base, "not-a-date");
assert!(result.is_err());
}
#[test]
#[should_panic(expected = "expected extended datetime")]
fn assert_extended_datetime_panics_for_in_range_input() {
let base = "2000-01-01 00:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
assert_extended_datetime("2001-01-01", base, "2001-01-01 00:00:00+00:00");
}
#[test]
#[should_panic(expected = "ParsedDateTime is not representable as jiff::Zoned")]
fn expect_in_range_panics_for_extended_input() {
let base = "2000-01-01 00:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
let parsed = parse_at_date(base, "10000-01-01").unwrap();
let _ = parsed.expect_in_range();
}
#[test]
fn relative_weekday() {
// Jan 1 2025 is a Wed
@@ -563,11 +765,15 @@ mod tests {
for (input, expected) in [
(
r#"TZ="Europe/Paris" 2025-01-02"#,
"2025-01-02 00:00:00[Europe/Paris]".parse().unwrap(),
"2025-01-02 00:00:00[Europe/Paris]"
.parse::<Zoned>()
.unwrap(),
),
(
r#"TZ="Europe/Paris" 2025-01-02 03:04:05"#,
"2025-01-02 03:04:05[Europe/Paris]".parse().unwrap(),
"2025-01-02 03:04:05[Europe/Paris]"
.parse::<Zoned>()
.unwrap(),
),
] {
assert_eq!(parse_build(input), expected, "{input}");
+19
View File
@@ -108,6 +108,15 @@ impl Offset {
hour_adjustment,
)
}
pub(super) fn total_seconds(&self) -> i32 {
let secs = (self.hours as i32) * 3600 + (self.minutes as i32) * 60;
if self.negative {
-secs
} else {
secs
}
}
}
impl TryFrom<(bool, u8, u8)> for Offset {
@@ -272,6 +281,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"),
@@ -414,6 +424,7 @@ 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
@@ -465,4 +476,12 @@ mod tests {
);
}
}
#[test]
fn total_seconds() {
assert_eq!(off(false, 0, 0).total_seconds(), 0);
assert_eq!(off(false, 5, 30).total_seconds(), 19_800);
assert_eq!(off(true, 5, 30).total_seconds(), -19_800);
assert_eq!(off(false, 24, 0).total_seconds(), 86_400);
}
}
+1 -1
View File
@@ -40,7 +40,7 @@ pub(crate) enum Day {
Sunday,
}
#[derive(PartialEq, Eq, Debug)]
#[derive(PartialEq, Eq, Debug, Clone)]
pub(crate) struct Weekday {
pub(crate) offset: i32,
pub(crate) day: Day,
+29 -20
View File
@@ -14,11 +14,12 @@ use winnow::{stream::AsChar, token::take_while, ModalResult, Parser};
use super::primitive::s;
const GNU_MAX_YEAR: u32 = 2_147_485_547;
// TODO: Leverage `TryFrom` trait.
pub(super) fn year_from_str(year_str: &str) -> Result<u16, &'static str> {
pub(super) fn year_from_str(year_str: &str) -> Result<u32, &'static str> {
let mut year = year_str
.parse::<u16>()
.map_err(|_| "year must be a valid u16 number")?;
.parse::<u32>()
.map_err(|_| "year must be a non-negative integer")?;
// If year is 68 or smaller, then 2000 is added to it; otherwise, if year
// is less than 100, then 1900 is added to it.
@@ -34,13 +35,8 @@ pub(super) fn year_from_str(year_str: &str) -> Result<u16, &'static str> {
}
}
// 2147485547 is the maximum value accepted by GNU, but chrono only
// behaves like GNU for years in the range: [0, 9999], so we keep in the
// range [0, 9999].
//
// See discussion in https://github.com/uutils/parse_datetime/issues/160.
if year > 9999 {
return Err("year must be no greater than 9999");
if year > GNU_MAX_YEAR {
return Err("year exceeds GNU maximum");
}
Ok(year)
@@ -57,18 +53,31 @@ mod tests {
#[test]
fn test_year() {
// 2-characters are converted to 19XX/20XX
assert_eq!(year_from_str("10").unwrap(), 2010u16);
assert_eq!(year_from_str("68").unwrap(), 2068u16);
assert_eq!(year_from_str("69").unwrap(), 1969u16);
assert_eq!(year_from_str("99").unwrap(), 1999u16);
assert_eq!(year_from_str("10").unwrap(), 2010u32);
assert_eq!(year_from_str("68").unwrap(), 2068u32);
assert_eq!(year_from_str("69").unwrap(), 1969u32);
assert_eq!(year_from_str("99").unwrap(), 1999u32);
// 3,4-characters are converted verbatim
assert_eq!(year_from_str("468").unwrap(), 468u16);
assert_eq!(year_from_str("469").unwrap(), 469u16);
assert_eq!(year_from_str("1568").unwrap(), 1568u16);
assert_eq!(year_from_str("1569").unwrap(), 1569u16);
assert_eq!(year_from_str("468").unwrap(), 468u32);
assert_eq!(year_from_str("469").unwrap(), 469u32);
assert_eq!(year_from_str("1568").unwrap(), 1568u32);
assert_eq!(year_from_str("1569").unwrap(), 1569u32);
// years greater than 9999 are not accepted
assert!(year_from_str("10000").is_err());
// very large years are accepted up to GNU's upper bound
assert_eq!(year_from_str("10000").unwrap(), 10000u32);
assert_eq!(year_from_str("2147485547").unwrap(), 2_147_485_547u32);
assert_eq!(
year_from_str("2147485548").unwrap_err(),
"year exceeds GNU maximum"
);
}
#[test]
fn test_year_errors() {
assert_eq!(
year_from_str("not-a-year").unwrap_err(),
"year must be a non-negative integer"
);
}
}
+404 -50
View File
File diff suppressed because it is too large. Load diff
Loaded 20 of 24 files, more files were not shown because too many files have changed in this diff. Show more