diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd37b4d..9ad863d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - run: rustup component add clippy - - run: cargo clippy -- -D warnings + - run: cargo clippy --all-targets -- -D warnings coverage: name: Code Coverage @@ -75,21 +75,22 @@ jobs: if [ -n "${{ matrix.job.toolchain }}" ]; then TOOLCHAIN="${{ matrix.job.toolchain }}" ; fi outputs TOOLCHAIN # target-specific options - # * CARGO_FEATURES_OPTION - CARGO_FEATURES_OPTION='--all -- --check' ; ## default to '--all-features' for code coverage # * CODECOV_FLAGS CODECOV_FLAGS=$( echo "${{ matrix.job.os }}" | sed 's/[^[:alnum:]]/_/g' ) outputs CODECOV_FLAGS - name: rust toolchain ~ install uses: dtolnay/rust-toolchain@nightly + with: + components: llvm-tools-preview - name: Test - run: cargo test ${{ steps.vars.outputs.CARGO_FEATURES_OPTION }} --no-fail-fast + run: cargo test --no-fail-fast env: CARGO_INCREMENTAL: "0" RUSTC_WRAPPER: "" - RUSTFLAGS: "-Zprofile -Ccodegen-units=1 -Copt-level=0 -Clink-dead-code -Coverflow-checks=off -Zpanic_abort_tests -Cpanic=abort" + RUSTFLAGS: "-Cinstrument-coverage -Zcoverage-options=branch -Ccodegen-units=1 -Copt-level=0 -Coverflow-checks=off -Zpanic_abort_tests -Cpanic=abort" RUSTDOCFLAGS: "-Cpanic=abort" + LLVM_PROFILE_FILE: "parse_datetime-%p-%m.profraw" - name: "`grcov` ~ install" id: build_grcov shell: bash @@ -117,16 +118,15 @@ jobs: COVERAGE_REPORT_FILE="${COVERAGE_REPORT_DIR}/lcov.info" mkdir -p "${COVERAGE_REPORT_DIR}" # display coverage files - grcov . --output-type files --ignore build.rs --ignore "vendor/*" --ignore "/*" --ignore "[a-zA-Z]:/*" --excl-br-line "^\s*((debug_)?assert(_eq|_ne)?!|#\[derive\()" | sort --unique + grcov . --binary-path="${COVERAGE_REPORT_DIR}" --output-type files --ignore build.rs --ignore "vendor/*" --ignore "/*" --ignore "[a-zA-Z]:/*" --excl-br-line "^\s*((debug_)?assert(_eq|_ne)?!|#\[derive\()" | sort --unique # generate coverage report - grcov . --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\()" + 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\()" echo "report=${COVERAGE_REPORT_FILE}" >> $GITHUB_OUTPUT - name: Upload coverage results (to Codecov.io) - uses: codecov/codecov-action@v4 - # if: steps.vars.outputs.HAS_CODECOV_TOKEN + uses: codecov/codecov-action@v5 with: - # token: ${{ secrets.CODECOV_TOKEN }} - file: ${{ steps.coverage.outputs.report }} + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ steps.coverage.outputs.report }} ## flags: IntegrationTests, UnitTests, ${{ steps.vars.outputs.CODECOV_FLAGS }} flags: ${{ steps.vars.outputs.CODECOV_FLAGS }} name: codecov-umbrella diff --git a/Cargo.lock b/Cargo.lock index eb10320..c4cc03d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "aho-corasick" @@ -52,14 +52,14 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.26" +version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec837a71355b28f6556dbd569b37b3f363091c0bd4b2e735674521b4c5fd9bc5" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ "android-tzdata", "iana-time-zone", "num-traits", - "winapi", + "windows-link", ] [[package]] @@ -70,16 +70,16 @@ checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" [[package]] name = "iana-time-zone" -version = "0.1.56" +version = "0.1.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0722cd7114b7de04316e7ea5456a0bbb20e4adb46fd27a3697adb812cff0f37c" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", "wasm-bindgen", - "windows", + "windows-core", ] [[package]] @@ -93,9 +93,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.63" +version = "0.3.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f37a4a5928311ac501dee68b3c7613a1037d0edb30c8e5427bd832d55d1b790" +checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a" dependencies = [ "wasm-bindgen", ] @@ -118,20 +118,13 @@ version = "2.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "nom" -version = "7.1.3" +version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" dependencies = [ "memchr", - "minimal-lexical", ] [[package]] @@ -151,7 +144,7 @@ checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" [[package]] name = "parse_datetime" -version = "0.5.0" +version = "0.9.0" dependencies = [ "chrono", "nom", @@ -178,9 +171,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.10.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d119d7c7ca818f8a53c300863d4f87566aac09943aef5b355bb83969dae75d87" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", @@ -190,9 +183,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.0" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d58da636bd923eae52b7e9120271cbefb16f399069ee566ca5ebf9c30e32238" +checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" dependencies = [ "aho-corasick", "memchr", @@ -201,9 +194,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.0" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3cbb081b9784b07cceb8824c8583f86db4814d172ab043f3c23f7dc600bf83d" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "syn" @@ -224,19 +217,20 @@ checksum = "b15811caf2415fb889178633e7724bad2509101cde276048e013b9def5e51fa0" [[package]] name = "wasm-bindgen" -version = "0.2.86" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bba0e8cb82ba49ff4e229459ff22a191bbe9a1cb3a341610c9c33efc27ddf73" +checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" dependencies = [ "cfg-if", + "once_cell", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.86" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b04bc93f9d6bdee709f6bd2118f57dd6679cf1176a1af464fca3ab0d66d8fb" +checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" dependencies = [ "bumpalo", "log", @@ -249,9 +243,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.86" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14d6b024f1a526bb0234f52840389927257beb670610081360e5a03c5df9c258" +checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -259,9 +253,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.86" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e128beba882dd1eb6200e1dc92ae6c5dbaa4311aa7bb211ca035779e5efc39f8" +checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" dependencies = [ "proc-macro2", "quote", @@ -272,50 +266,35 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.86" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9d5b4305409d1fc9482fee2d7f9bcbf24b3972bf59817ef757e23982242a93" +checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" [[package]] -name = "winapi" -version = "0.3.9" +name = "windows-core" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", + "windows-targets", ] [[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" +name = "windows-link" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" -dependencies = [ - "windows-targets", -] +checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3" [[package]] name = "windows-targets" -version = "0.48.0" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" +checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" dependencies = [ "windows_aarch64_gnullvm", "windows_aarch64_msvc", "windows_i686_gnu", + "windows_i686_gnullvm", "windows_i686_msvc", "windows_x86_64_gnu", "windows_x86_64_gnullvm", @@ -324,42 +303,48 @@ dependencies = [ [[package]] name = "windows_aarch64_gnullvm" -version = "0.48.0" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" +checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" [[package]] name = "windows_aarch64_msvc" -version = "0.48.0" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" +checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" [[package]] name = "windows_i686_gnu" -version = "0.48.0" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" +checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" [[package]] name = "windows_i686_msvc" -version = "0.48.0" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" +checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" [[package]] name = "windows_x86_64_gnu" -version = "0.48.0" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" +checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" [[package]] name = "windows_x86_64_gnullvm" -version = "0.48.0" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" +checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" [[package]] name = "windows_x86_64_msvc" -version = "0.48.0" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" +checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" diff --git a/Cargo.toml b/Cargo.toml index a9649b5..751b38f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,13 @@ [package] name = "parse_datetime" description = "parsing human-readable time strings and converting them to a DateTime" -version = "0.5.0" +version = "0.9.0" edition = "2021" license = "MIT" repository = "https://github.com/uutils/parse_datetime" readme = "README.md" [dependencies] -regex = "1.10" -chrono = { version="0.4", default-features=false, features=["std", "alloc", "clock"] } -nom = "7.1.3" +regex = "1.10.4" +chrono = { version="0.4.38", default-features=false, features=["std", "alloc", "clock"] } +nom = "8.0.0" diff --git a/README.md b/README.md index 6f3b031..5a0e0df 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,10 @@ A Rust crate for parsing human-readable relative time strings and human-readable ## Usage -Add this to your `Cargo.toml`: +Add `parse_datetime` to your `Cargo.toml` with: -```toml -[dependencies] -parse_datetime = "0.5.0" +``` +cargo add parse_datetime ``` Then, import the crate and use the `parse_datetime_at_date` function: diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index bb78b87..6d63155 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "aho-corasick" @@ -46,11 +46,13 @@ checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1" [[package]] name = "cc" -version = "1.0.79" +version = "1.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" +checksum = "13208fcbb66eaeffe09b99fffbe1af420f00a7b35aa99ad683dfc1aa76145229" dependencies = [ "jobserver", + "libc", + "shlex", ] [[package]] @@ -61,14 +63,14 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.26" +version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec837a71355b28f6556dbd569b37b3f363091c0bd4b2e735674521b4c5fd9bc5" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ "android-tzdata", "iana-time-zone", "num-traits", - "winapi", + "windows-link", ] [[package]] @@ -84,33 +86,21 @@ dependencies = [ "chrono", "libfuzzer-sys", "parse_datetime", - "rand", "regex", ] -[[package]] -name = "getrandom" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c85e1d9ab2eadba7e5040d4e09cbd6d072b76a557ad64e797c2cb9d4da21d7e4" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - [[package]] name = "iana-time-zone" -version = "0.1.56" +version = "0.1.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0722cd7114b7de04316e7ea5456a0bbb20e4adb46fd27a3697adb812cff0f37c" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", "wasm-bindgen", - "windows", + "windows-core", ] [[package]] @@ -124,18 +114,18 @@ dependencies = [ [[package]] name = "jobserver" -version = "0.1.26" +version = "0.1.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "936cfd212a0155903bcbc060e316fb6cc7cbf2e1907329391ebadc1fe0ce77c2" +checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" dependencies = [ "libc", ] [[package]] name = "js-sys" -version = "0.3.63" +version = "0.3.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f37a4a5928311ac501dee68b3c7613a1037d0edb30c8e5427bd832d55d1b790" +checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a" dependencies = [ "wasm-bindgen", ] @@ -148,13 +138,12 @@ checksum = "6a987beff54b60ffa6d51982e1aa1146bc42f19bd26be28b0586f252fccf5317" [[package]] name = "libfuzzer-sys" -version = "0.4.6" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "beb09950ae85a0a94b27676cccf37da5ff13f27076aa1adbc6545dd0d0e1bd4e" +checksum = "cf78f52d400cf2d84a3a973a78a592b4adc535739e0a5597a0da6f0c357adc75" dependencies = [ "arbitrary", "cc", - "once_cell", ] [[package]] @@ -169,20 +158,13 @@ version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5486aed0026218e61b8a01d5fbd5a0a134649abb71a0e53b7bc088529dced86e" -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "nom" -version = "7.1.3" +version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" dependencies = [ "memchr", - "minimal-lexical", ] [[package]] @@ -202,19 +184,13 @@ checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" [[package]] name = "parse_datetime" -version = "0.5.0" +version = "0.9.0" dependencies = [ "chrono", "nom", "regex", ] -[[package]] -name = "ppv-lite86" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" - [[package]] name = "proc-macro2" version = "1.0.59" @@ -233,41 +209,11 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom", -] - [[package]] name = "regex" -version = "1.10.3" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b62dbe01f0b06f9d8dc7d49e05a0785f153b00b2c227856282f671e0318c9b15" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", @@ -277,9 +223,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.4" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b7fa1134405e2ec9353fd416b17f8dacd46c473d7d3fd1cf202706a14eb792a" +checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" dependencies = [ "aho-corasick", "memchr", @@ -288,9 +234,15 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.2" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "shlex" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "syn" @@ -309,27 +261,22 @@ version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15811caf2415fb889178633e7724bad2509101cde276048e013b9def5e51fa0" -[[package]] -name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" - [[package]] name = "wasm-bindgen" -version = "0.2.86" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bba0e8cb82ba49ff4e229459ff22a191bbe9a1cb3a341610c9c33efc27ddf73" +checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" dependencies = [ "cfg-if", + "once_cell", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.86" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b04bc93f9d6bdee709f6bd2118f57dd6679cf1176a1af464fca3ab0d66d8fb" +checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" dependencies = [ "bumpalo", "log", @@ -342,9 +289,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.86" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14d6b024f1a526bb0234f52840389927257beb670610081360e5a03c5df9c258" +checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -352,9 +299,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.86" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e128beba882dd1eb6200e1dc92ae6c5dbaa4311aa7bb211ca035779e5efc39f8" +checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" dependencies = [ "proc-macro2", "quote", @@ -365,50 +312,35 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.86" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9d5b4305409d1fc9482fee2d7f9bcbf24b3972bf59817ef757e23982242a93" +checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" [[package]] -name = "winapi" -version = "0.3.9" +name = "windows-core" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", + "windows-targets", ] [[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" +name = "windows-link" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" -dependencies = [ - "windows-targets", -] +checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3" [[package]] name = "windows-targets" -version = "0.48.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ "windows_aarch64_gnullvm", "windows_aarch64_msvc", "windows_i686_gnu", + "windows_i686_gnullvm", "windows_i686_msvc", "windows_x86_64_gnu", "windows_x86_64_gnullvm", @@ -417,42 +349,48 @@ dependencies = [ [[package]] name = "windows_aarch64_gnullvm" -version = "0.48.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_msvc" -version = "0.48.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" -version = "0.48.0" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_msvc" -version = "0.48.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" -version = "0.48.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" -version = "0.48.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" -version = "0.48.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 7e5c2f2..dfc4c79 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -7,9 +7,8 @@ edition = "2021" cargo-fuzz = true [dependencies] -rand = "0.8.5" -libfuzzer-sys = "0.4" -regex = "1.10.3" +libfuzzer-sys = "0.4.7" +regex = "1.10.4" chrono = { version="0.4", default-features=false, features=["std", "alloc", "clock"] } [dependencies.parse_datetime] diff --git a/renovate.json b/renovate.json index 39a2b6e..5db72dd 100644 --- a/renovate.json +++ b/renovate.json @@ -1,6 +1,6 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ - "config:base" + "config:recommended" ] } diff --git a/src/lib.rs b/src/lib.rs index aff8465..c94d108 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ //! * relative time to now, e.g. "+1 hour" //! use regex::Error as RegexError; +use regex::Regex; use std::error::Error; use std::fmt::{self, Display}; @@ -20,11 +21,11 @@ mod parse_time_only_str; mod parse_weekday; use chrono::{ - DateTime, Datelike, Duration, FixedOffset, Local, LocalResult, NaiveDateTime, TimeZone, - Timelike, + DateTime, Datelike, Duration, FixedOffset, Local, LocalResult, MappedLocalTime, NaiveDate, + NaiveDateTime, TimeZone, Timelike, }; -use parse_relative_time::parse_relative_time; +use parse_relative_time::parse_relative_time_at_date; use parse_timestamp::parse_timestamp; #[derive(Debug, PartialEq)] @@ -62,6 +63,10 @@ impl From for ParseDateTimeError { mod format { pub const ISO_8601: &str = "%Y-%m-%d"; pub const ISO_8601_NO_SEP: &str = "%Y%m%d"; + // US format for calendar date items: + // https://www.gnu.org/software/coreutils/manual/html_node/Calendar-date-items.html + pub const MMDDYYYY_SLASH: &str = "%m/%d/%Y"; + pub const MMDDYY_SLASH: &str = "%m/%d/%y"; pub const POSIX_LOCALE: &str = "%a %b %e %H:%M:%S %Y"; pub const YYYYMMDDHHMM_DOT_SS: &str = "%Y%m%d%H%M.%S"; pub const YYYYMMDDHHMMSS: &str = "%Y-%m-%d %H:%M:%S.%f"; @@ -72,9 +77,91 @@ mod format { pub const YYYYMMDDHHMM_UTC_OFFSET: &str = "%Y%m%d%H%MUTC%z"; pub const YYYYMMDDHHMM_ZULU_OFFSET: &str = "%Y%m%d%H%MZ%z"; pub const YYYYMMDDHHMM_HYPHENATED_OFFSET: &str = "%Y-%m-%d %H:%M %z"; + pub const YYYYMMDDHHMMSS_HYPHENATED_OFFSET: &str = "%Y-%m-%d %H:%M:%S %#z"; + pub const YYYYMMDDHHMMSS_HYPHENATED_ZULU: &str = "%Y-%m-%d %H:%M:%SZ"; + pub const YYYYMMDDHHMMSS_T_SEP_HYPHENATED_OFFSET: &str = "%Y-%m-%dT%H:%M:%S%#z"; + pub const YYYYMMDDHHMMSS_T_SEP_HYPHENATED_ZULU: &str = "%Y-%m-%dT%H:%M:%SZ"; + pub const YYYYMMDDHHMMSS_T_SEP_HYPHENATED_SPACE_OFFSET: &str = "%Y-%m-%dT%H:%M:%S %#z"; pub const YYYYMMDDHHMMS_T_SEP: &str = "%Y-%m-%dT%H:%M:%S"; pub const UTC_OFFSET: &str = "UTC%#z"; pub const ZULU_OFFSET: &str = "Z%#z"; + pub const NAKED_OFFSET: &str = "%#z"; + + /// Whether the pattern ends in the character `Z`. + pub(crate) fn is_zulu(pattern: &str) -> bool { + pattern.ends_with('Z') + } + + /// Patterns for datetimes with timezones. + /// + /// These are in decreasing order of length. The same pattern may + /// appear multiple times with different lengths if the pattern + /// accepts input strings of different lengths. For example, the + /// specifier `%#z` accepts two-digit time zone offsets (`+00`) + /// and four-digit time zone offsets (`+0000`). + pub(crate) const PATTERNS_TZ: [(&str, usize); 9] = [ + (YYYYMMDDHHMMSS_HYPHENATED_OFFSET, 25), + (YYYYMMDDHHMMSS_T_SEP_HYPHENATED_SPACE_OFFSET, 25), + (YYYYMMDDHHMMSS_T_SEP_HYPHENATED_OFFSET, 24), + (YYYYMMDDHHMMSS_HYPHENATED_OFFSET, 23), + (YYYYMMDDHHMMSS_T_SEP_HYPHENATED_OFFSET, 22), + (YYYYMMDDHHMM_HYPHENATED_OFFSET, 22), + (YYYYMMDDHHMM_UTC_OFFSET, 20), + (YYYYMMDDHHMM_OFFSET, 18), + (YYYYMMDDHHMM_ZULU_OFFSET, 18), + ]; + + /// Patterns for datetimes without timezones. + /// + /// These are in decreasing order of length. + pub(crate) const PATTERNS_NO_TZ: [(&str, usize); 9] = [ + (YYYYMMDDHHMMSS, 29), + (POSIX_LOCALE, 24), + (YYYYMMDDHHMMSS_HYPHENATED_ZULU, 20), + (YYYYMMDDHHMMSS_T_SEP_HYPHENATED_ZULU, 20), + (YYYYMMDDHHMMS_T_SEP, 19), + (YYYYMMDDHHMMS, 19), + (YYYY_MM_DD_HH_MM, 16), + (YYYYMMDDHHMM_DOT_SS, 15), + (YYYYMMDDHHMM, 12), + ]; + + /// Patterns for dates with neither times nor timezones. + /// + /// These are in decreasing order of length. The same pattern may + /// appear multiple times with different lengths if the pattern + /// accepts input strings of different lengths. For example, the + /// specifier `%m` accepts one-digit month numbers (like `2`) and + /// two-digit month numbers (like `02` or `12`). + pub(crate) const PATTERNS_DATE_NO_TZ: [(&str, usize); 8] = [ + (ISO_8601, 10), + (MMDDYYYY_SLASH, 10), + (ISO_8601, 9), + (MMDDYYYY_SLASH, 9), + (ISO_8601, 8), + (MMDDYY_SLASH, 8), + (MMDDYYYY_SLASH, 8), + (ISO_8601_NO_SEP, 8), + ]; + + /// Patterns for lone timezone offsets. + /// + /// These are in decreasing order of length. The same pattern may + /// appear multiple times with different lengths if the pattern + /// accepts input strings of different lengths. For example, the + /// specifier `%#z` accepts two-digit time zone offsets (`+00`) + /// and four-digit time zone offsets (`+0000`). + pub(crate) const PATTERNS_OFFSET: [(&str, usize); 9] = [ + (UTC_OFFSET, 9), + (UTC_OFFSET, 8), + (ZULU_OFFSET, 7), + (UTC_OFFSET, 6), + (ZULU_OFFSET, 6), + (NAKED_OFFSET, 6), + (NAKED_OFFSET, 5), + (ZULU_OFFSET, 4), + (NAKED_OFFSET, 3), + ]; } /// Parses a time string and returns a `DateTime` representing the @@ -147,31 +234,67 @@ pub fn parse_datetime_at_date + Clone>( // TODO: Replace with a proper customiseable parsing solution using `nom`, `grmtools`, or // similar - // Formats with offsets don't require NaiveDateTime workaround - for fmt in [ - format::YYYYMMDDHHMM_OFFSET, - format::YYYYMMDDHHMM_HYPHENATED_OFFSET, - format::YYYYMMDDHHMM_UTC_OFFSET, - format::YYYYMMDDHHMM_ZULU_OFFSET, - ] { - if let Ok(parsed) = DateTime::parse_from_str(s.as_ref(), fmt) { - return Ok(parsed); + // Try to parse a reference date first. Try parsing from longest + // pattern to shortest pattern. If a reference date can be parsed, + // then try to parse a time delta from the remaining slice. If no + // reference date could be parsed, then try to parse the entire + // string as a time delta. If no time delta could be parsed, + // return an error. + let (ref_date, n) = if let Some((ref_date, n)) = parse_reference_date(date, s.as_ref()) { + (ref_date, n) + } else { + let tz = TimeZone::from_offset(date.offset()); + match date.naive_local().and_local_timezone(tz) { + MappedLocalTime::Single(ref_date) => (ref_date, 0), + _ => return Err(ParseDateTimeError::InvalidInput), + } + }; + parse_relative_time_at_date(ref_date, &s.as_ref()[n..]) +} + +/// Parse an absolute datetime from a prefix of s, if possible. +/// +/// Try to parse the longest possible absolute datetime at the beginning +/// of string `s`. Return the parsed datetime and the index in `s` at +/// which the datetime ended. +fn parse_reference_date(date: DateTime, s: S) -> Option<(DateTime, usize)> +where + S: AsRef, +{ + // HACK: if the string ends with a single digit preceded by a + or - + // sign, then insert a 0 between the sign and the digit to make it + // possible for `chrono` to parse it. + let pattern = Regex::new(r"([\+-])(\d)$").unwrap(); + let tmp_s = pattern.replace(s.as_ref(), "${1}0${2}"); + for (fmt, n) in format::PATTERNS_TZ { + if tmp_s.len() >= n { + if let Ok(parsed) = DateTime::parse_from_str(&tmp_s[0..n], fmt) { + if tmp_s == s.as_ref() { + return Some((parsed, n)); + } + return Some((parsed, n - 1)); + } } } // Parse formats with no offset, assume local time - for fmt in [ - format::YYYYMMDDHHMMS_T_SEP, - format::YYYYMMDDHHMM, - format::YYYYMMDDHHMMS, - format::YYYYMMDDHHMMSS, - format::YYYY_MM_DD_HH_MM, - format::YYYYMMDDHHMM_DOT_SS, - format::POSIX_LOCALE, - ] { - if let Ok(parsed) = NaiveDateTime::parse_from_str(s.as_ref(), fmt) { - if let Ok(dt) = naive_dt_to_fixed_offset(date, parsed) { - return Ok(dt); + for (fmt, n) in format::PATTERNS_NO_TZ { + if s.as_ref().len() >= n { + if let Ok(parsed) = NaiveDateTime::parse_from_str(&s.as_ref()[0..n], fmt) { + // Special case: `chrono` can only parse a datetime like + // `2000-01-01 01:23:45Z` as a naive datetime, so we + // manually force it to be in UTC. + if format::is_zulu(fmt) { + match FixedOffset::east_opt(0) + .unwrap() + .from_local_datetime(&parsed) + { + MappedLocalTime::Single(datetime) => return Some((datetime, n)), + _ => return None, + } + } else if let Ok(dt) = naive_dt_to_fixed_offset(date, parsed) { + return Some((dt, n)); + } } } } @@ -194,23 +317,24 @@ pub fn parse_datetime_at_date + Clone>( let dt = DateTime::::from(beginning_of_day); - return Ok(dt); + return Some((dt, s.as_ref().len())); } // Parse epoch seconds if let Ok(timestamp) = parse_timestamp(s.as_ref()) { - if let Some(timestamp_date) = NaiveDateTime::from_timestamp_opt(timestamp, 0) { - return Ok(date.offset().from_utc_datetime(×tamp_date)); + if let Some(timestamp_date) = DateTime::from_timestamp(timestamp, 0) { + return Some((timestamp_date.into(), s.as_ref().len())); } } - let ts = s.as_ref().to_owned() + "0000"; // Parse date only formats - assume midnight local timezone - for fmt in [format::ISO_8601, format::ISO_8601_NO_SEP] { - let f = fmt.to_owned() + "%H%M"; - if let Ok(parsed) = NaiveDateTime::parse_from_str(&ts, &f) { - if let Ok(dt) = naive_dt_to_fixed_offset(date, parsed) { - return Ok(dt); + for (fmt, n) in format::PATTERNS_DATE_NO_TZ { + if s.as_ref().len() >= n { + if let Ok(parsed) = NaiveDate::parse_from_str(&s.as_ref()[0..n], fmt) { + let datetime = parsed.and_hms_opt(0, 0, 0).unwrap(); + if let Ok(dt) = naive_dt_to_fixed_offset(date, datetime) { + return Some((dt, n)); + } } } } @@ -219,32 +343,25 @@ pub fn parse_datetime_at_date + Clone>( // offsets, so instead we replicate parse_date behaviour by getting // the current date with local, and create a date time string at midnight, // before trying offset suffixes - let ts = format!("{}", date.format("%Y%m%d")) + "0000" + s.as_ref(); - for fmt in [format::UTC_OFFSET, format::ZULU_OFFSET] { - let f = format::YYYYMMDDHHMM.to_owned() + fmt; - if let Ok(parsed) = DateTime::parse_from_str(&ts, &f) { - return Ok(parsed); - } - } - - // Parse relative time. - if let Ok(relative_time) = parse_relative_time(s.as_ref()) { - let current_time = DateTime::::from(date); - - if let Some(date_time) = current_time.checked_add_signed(relative_time) { - return Ok(date_time); + let ts = format!("{}0000{}", date.format("%Y%m%d"), tmp_s.as_ref()); + for (fmt, n) in format::PATTERNS_OFFSET { + if ts.len() == n + 12 { + let f = format::YYYYMMDDHHMM.to_owned() + fmt; + if let Ok(parsed) = DateTime::parse_from_str(&ts, &f) { + if tmp_s == s.as_ref() { + return Some((parsed, n)); + } + return Some((parsed, n - 1)); + } } } // parse time only dates if let Some(date_time) = parse_time_only_str::parse_time_only(date, s.as_ref()) { - return Ok(date_time); + return Some((date_time, s.as_ref().len())); } - // Default parse and failure - s.as_ref() - .parse() - .map_err(|_| (ParseDateTimeError::InvalidInput)) + None } // Convert NaiveDateTime to DateTime by assuming the offset @@ -302,6 +419,14 @@ mod tests { assert_eq!(actual.unwrap().timestamp(), TEST_TIME); } + #[test] + fn test_t_sep_single_digit_offset_no_space() { + env::set_var("TZ", "UTC"); + let dt = "2021-02-14T22:37:47-8"; + let actual = parse_datetime(dt); + assert_eq!(actual.unwrap().timestamp(), TEST_TIME); + } + #[test] fn invalid_formats() { let invalid_dts = vec!["NotADate", "202104", "202104-12T22:37:47"]; @@ -327,9 +452,31 @@ mod tests { } } + #[cfg(test)] + mod calendar_date_items { + use crate::parse_datetime; + use chrono::{DateTime, Local, TimeZone}; + + #[test] + fn single_digit_month_day() { + std::env::set_var("TZ", "UTC"); + let x = Local.with_ymd_and_hms(1987, 5, 7, 0, 0, 0).unwrap(); + let expected = DateTime::fixed_offset(&x); + + assert_eq!(Ok(expected), parse_datetime("1987-05-07")); + assert_eq!(Ok(expected), parse_datetime("1987-5-07")); + assert_eq!(Ok(expected), parse_datetime("1987-05-7")); + assert_eq!(Ok(expected), parse_datetime("1987-5-7")); + assert_eq!(Ok(expected), parse_datetime("5/7/1987")); + assert_eq!(Ok(expected), parse_datetime("5/07/1987")); + assert_eq!(Ok(expected), parse_datetime("05/7/1987")); + assert_eq!(Ok(expected), parse_datetime("05/07/1987")); + } + } + #[cfg(test)] mod offsets { - use chrono::Local; + use chrono::{Local, NaiveDate}; use crate::parse_datetime; use crate::ParseDateTimeError; @@ -343,6 +490,8 @@ mod tests { "Z+07:00", "Z+0700", "Z+07", + "+07", + "+7", ]; let expected = format!("{}{}", Local::now().format("%Y%m%d"), "0000+0700"); @@ -364,13 +513,22 @@ mod tests { #[test] fn invalid_offset_format() { - let invalid_offsets = vec!["+0700", "UTC+2", "Z-1", "UTC+01005"]; - for offset in invalid_offsets { - assert_eq!( - parse_datetime(offset), - Err(ParseDateTimeError::InvalidInput) - ); - } + let offset = "UTC+01005"; + assert_eq!( + parse_datetime(offset), + Err(ParseDateTimeError::InvalidInput) + ); + } + + #[test] + fn test_datetime_with_offset() { + let actual = parse_datetime("1997-01-19 08:17:48 +0").unwrap(); + let expected = NaiveDate::from_ymd_opt(1997, 1, 19) + .unwrap() + .and_hms_opt(8, 17, 48) + .unwrap() + .and_utc(); + assert_eq!(actual, expected); } } @@ -388,7 +546,7 @@ mod tests { ]; for relative_time in relative_times { - assert_eq!(parse_datetime(relative_time).is_ok(), true); + assert!(parse_datetime(relative_time).is_ok()); } } } @@ -402,12 +560,13 @@ mod tests { fn get_formatted_date(date: DateTime, weekday: &str) -> String { let result = parse_datetime_at_date(date, weekday).unwrap(); - return result.format("%F %T %f").to_string(); + result.format("%F %T %f").to_string() } + #[test] fn test_weekday() { // add some constant hours and minutes and seconds to check its reset - let date = Local.with_ymd_and_hms(2023, 02, 28, 10, 12, 3).unwrap(); + let date = Local.with_ymd_and_hms(2023, 2, 28, 10, 12, 3).unwrap(); // 2023-2-28 is tuesday assert_eq!( @@ -457,12 +616,12 @@ mod tests { for offset in offsets { // positive offset let time = Utc.timestamp_opt(offset, 0).unwrap(); - let dt = parse_datetime(format!("@{}", offset)); + let dt = parse_datetime(format!("@{offset}")); assert_eq!(dt.unwrap(), time); // negative offset let time = Utc.timestamp_opt(-offset, 0).unwrap(); - let dt = parse_datetime(format!("@-{}", offset)); + let dt = parse_datetime(format!("@-{offset}")); assert_eq!(dt.unwrap(), time); } } @@ -476,11 +635,11 @@ mod tests { #[test] fn test_time_only() { env::set_var("TZ", "UTC"); - let test_date = Local.with_ymd_and_hms(2024, 03, 03, 0, 0, 0).unwrap(); + let test_date = Local.with_ymd_and_hms(2024, 3, 3, 0, 0, 0).unwrap(); let parsed_time = parse_datetime_at_date(test_date, "9:04:30 PM +0530") .unwrap() .timestamp(); - assert_eq!(parsed_time, 1709480070) + assert_eq!(parsed_time, 1709480070); } } /// Used to test example code presented in the README. @@ -512,4 +671,213 @@ mod tests { assert_eq!(result, Err(ParseDateTimeError::InvalidInput)); } } + + #[test] + fn test_datetime_ending_in_z() { + use crate::parse_datetime; + use chrono::{TimeZone, Utc}; + + let actual = parse_datetime("2023-06-03 12:00:01Z").unwrap(); + let expected = Utc.with_ymd_and_hms(2023, 6, 3, 12, 0, 1).unwrap(); + assert_eq!(actual, expected); + } + + #[test] + fn test_parse_invalid_datetime() { + assert!(crate::parse_datetime("bogus +1 day").is_err()); + } + + #[test] + fn test_parse_invalid_delta() { + assert!(crate::parse_datetime("1997-01-01 bogus").is_err()); + } + + #[test] + fn test_parse_datetime_tz_nodelta() { + std::env::set_var("TZ", "UTC0"); + + // 1997-01-01 00:00:00 +0000 + let expected = chrono::NaiveDate::from_ymd_opt(1997, 1, 1) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc() + .fixed_offset(); + + for s in [ + "1997-01-01 00:00:00 +0000", + "1997-01-01 00:00:00 +00", + "199701010000 +0000", + "199701010000UTC+0000", + "199701010000Z+0000", + "1997-01-01 00:00 +0000", + "1997-01-01 00:00:00 +0000", + "1997-01-01T00:00:00+0000", + "1997-01-01T00:00:00+00", + "1997-01-01T00:00:00Z", + "@852076800", + ] { + let actual = crate::parse_datetime(s).unwrap(); + assert_eq!(actual, expected); + } + } + + #[test] + fn test_parse_datetime_notz_nodelta() { + std::env::set_var("TZ", "UTC0"); + let expected = chrono::NaiveDate::from_ymd_opt(1997, 1, 1) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc() + .fixed_offset(); + + for s in [ + "1997-01-01 00:00:00.000000000", + "Wed Jan 1 00:00:00 1997", + "1997-01-01T00:00:00", + "1997-01-01 00:00:00", + "1997-01-01 00:00", + "199701010000.00", + "199701010000", + ] { + let actual = crate::parse_datetime(s).unwrap(); + assert_eq!(actual, expected); + } + } + + #[test] + fn test_parse_date_notz_nodelta() { + std::env::set_var("TZ", "UTC0"); + let expected = chrono::NaiveDate::from_ymd_opt(1997, 1, 1) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc() + .fixed_offset(); + + for s in ["1997-01-01", "19970101", "01/01/1997", "01/01/97"] { + let actual = crate::parse_datetime(s).unwrap(); + assert_eq!(actual, expected); + } + } + + #[test] + fn test_parse_datetime_tz_delta() { + std::env::set_var("TZ", "UTC0"); + + // 1998-01-01 + let expected = chrono::NaiveDate::from_ymd_opt(1998, 1, 1) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc() + .fixed_offset(); + + for s in [ + "1997-01-01 00:00:00 +0000 +1 year", + "1997-01-01 00:00:00 +00 +1 year", + "199701010000 +0000 +1 year", + "199701010000UTC+0000 +1 year", + "199701010000Z+0000 +1 year", + "1997-01-01T00:00:00Z +1 year", + "1997-01-01 00:00 +0000 +1 year", + "1997-01-01 00:00:00 +0000 +1 year", + "1997-01-01T00:00:00+0000 +1 year", + "1997-01-01T00:00:00+00 +1 year", + ] { + let actual = crate::parse_datetime(s).unwrap(); + assert_eq!(actual, expected); + } + } + + #[test] + fn test_parse_datetime_notz_delta() { + std::env::set_var("TZ", "UTC0"); + let expected = chrono::NaiveDate::from_ymd_opt(1998, 1, 1) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc() + .fixed_offset(); + + for s in [ + "1997-01-01 00:00:00.000000000 +1 year", + "Wed Jan 1 00:00:00 1997 +1 year", + "1997-01-01T00:00:00 +1 year", + "1997-01-01 00:00:00 +1 year", + "1997-01-01 00:00 +1 year", + "199701010000.00 +1 year", + "199701010000 +1 year", + ] { + let actual = crate::parse_datetime(s).unwrap(); + assert_eq!(actual, expected); + } + } + + #[test] + fn test_parse_date_notz_delta() { + std::env::set_var("TZ", "UTC0"); + let expected = chrono::NaiveDate::from_ymd_opt(1998, 1, 1) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc() + .fixed_offset(); + + for s in [ + "1997-01-01 +1 year", + "19970101 +1 year", + "01/01/1997 +1 year", + "01/01/97 +1 year", + ] { + let actual = crate::parse_datetime(s).unwrap(); + assert_eq!(actual, expected); + } + } + + #[test] + fn test_time_only() { + use chrono::{FixedOffset, Local}; + std::env::set_var("TZ", "UTC"); + + let offset = FixedOffset::east_opt(5 * 60 * 60 + 1800).unwrap(); + let expected = Local::now() + .date_naive() + .and_hms_opt(21, 4, 30) + .unwrap() + .and_local_timezone(offset) + .unwrap(); + let actual = crate::parse_datetime("9:04:30 PM +0530").unwrap(); + assert_eq!(actual, expected); + } + + #[test] + fn test_weekday_only() { + use chrono::{Datelike, Days, Local, MappedLocalTime, NaiveTime, Weekday}; + std::env::set_var("TZ", "UTC0"); + let now = Local::now(); + let midnight = NaiveTime::from_hms_opt(0, 0, 0).unwrap(); + let today = now.weekday(); + let midnight_today = if let MappedLocalTime::Single(t) = now.with_time(midnight) { + t + } else { + panic!() + }; + + for (s, day) in [ + ("sunday", Weekday::Sun), + ("monday", Weekday::Mon), + ("tuesday", Weekday::Tue), + ("wednesday", Weekday::Wed), + ("thursday", Weekday::Thu), + ("friday", Weekday::Fri), + ("saturday", Weekday::Sat), + ] { + let actual = crate::parse_datetime(s).unwrap(); + let delta = Days::new(u64::from(day.days_since(today))); + let expected = midnight_today.checked_add_days(delta).unwrap(); + assert_eq!(actual, expected); + } + } } diff --git a/src/parse_relative_time.rs b/src/parse_relative_time.rs index 7bc0840..ea4a190 100644 --- a/src/parse_relative_time.rs +++ b/src/parse_relative_time.rs @@ -1,15 +1,27 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -use crate::ParseDateTimeError; -use chrono::{Duration, Local, NaiveDate, Utc}; +use crate::{parse_weekday::parse_weekday, ParseDateTimeError}; +use chrono::{ + DateTime, Datelike, Days, Duration, LocalResult, Months, NaiveDate, NaiveDateTime, NaiveTime, + TimeZone, Weekday, +}; use regex::Regex; -/// Parses a relative time string and returns a `Duration` representing the -/// relative time. -///Regex + +/// Number of days in each month. +/// +/// Months are 0-indexed, so January is at index 0. The number of days +/// in February is 28. +const DAYS_PER_MONTH: [u32; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + +/// Parses a relative time string and adds the duration that it represents to the +/// given date. +/// /// # Arguments /// +/// * `date` - A `Date` instance representing the base date for the calculation /// * `s` - A string slice representing the relative time. /// +/// If `s` is empty, the `date` is returned as-is. /// /// # Supported formats /// @@ -38,61 +50,55 @@ use regex::Regex; /// This function will return `Err(ParseDateTimeError::InvalidInput)` if the input string /// cannot be parsed as a relative time. /// -/// ``` -pub fn parse_relative_time(s: &str) -> Result { - parse_relative_time_at_date(Utc::now().date_naive(), s) -} - -/// Parses a duration string and returns a `Duration` instance, with the duration -/// calculated from the specified date. -/// -/// # Arguments -/// -/// * `date` - A `Date` instance representing the base date for the calculation -/// * `s` - A string slice representing the relative time. -/// -/// # Errors -/// -/// This function will return `Err(ParseDateTimeError::InvalidInput)` if the input string -/// cannot be parsed as a relative time. -/// ``` -pub fn parse_relative_time_at_date( - date: NaiveDate, +pub fn parse_relative_time_at_date( + mut datetime: DateTime, s: &str, -) -> Result { +) -> Result, ParseDateTimeError> { + let s = s.trim(); + if s.is_empty() { + return Ok(datetime); + } let time_pattern: Regex = Regex::new( - r"(?x) - (?:(?P[-+]?\d*)\s*)? - (\s*(?Pnext|last)?\s*)? - (?Pyears?|months?|fortnights?|weeks?|days?|hours?|h|minutes?|mins?|m|seconds?|secs?|s|yesterday|tomorrow|now|today) + r"(?ix) + (?:(?P[-+]?\s*\d*)\s*)? + (\s*(?Pnext|this|last)?\s*)? + (?Pyears?|months?|fortnights?|weeks?|days?|hours?|h|minutes?|mins?|m|seconds?|secs?|s|yesterday|tomorrow|now|today|(?P[a-z]{3,9}))\b (\s*(?Pand|,)?\s*)? (\s*(?Pago)?)?", )?; - let mut total_duration = Duration::seconds(0); - let mut is_ago = s.contains(" ago"); + let mut is_ago = s.to_ascii_lowercase().contains(" ago"); let mut captures_processed = 0; let mut total_length = 0; for capture in time_pattern.captures_iter(s) { captures_processed += 1; - let value_str = capture + let value_str: String = capture .name("value") .ok_or(ParseDateTimeError::InvalidInput)? - .as_str(); + .as_str() + .chars() + .filter(|c| !c.is_whitespace()) // Remove potential space between +/- and number + .collect(); + let direction = capture + .name("direction") + .map_or("", |d| d.as_str()) + .to_ascii_lowercase(); let value = if value_str.is_empty() { - 1 + if direction == "this" { + 0 + } else { + 1 + } } else { value_str .parse::() .map_err(|_| ParseDateTimeError::InvalidInput)? }; - if let Some(direction) = capture.name("direction") { - if direction.as_str() == "last" { - is_ago = true; - } + if direction == "last" { + is_ago = true; } let unit = capture @@ -104,26 +110,31 @@ pub fn parse_relative_time_at_date( is_ago = true; } - let duration = match unit { - "years" | "year" => Duration::days(value * 365), - "months" | "month" => Duration::days(value * 30), - "fortnights" | "fortnight" => Duration::weeks(value * 2), - "weeks" | "week" => Duration::weeks(value), - "days" | "day" => Duration::days(value), - "hours" | "hour" | "h" => Duration::hours(value), - "minutes" | "minute" | "mins" | "min" | "m" => Duration::minutes(value), - "seconds" | "second" | "secs" | "sec" | "s" => Duration::seconds(value), - "yesterday" => Duration::days(-1), - "tomorrow" => Duration::days(1), - "now" | "today" => Duration::zero(), - _ => return Err(ParseDateTimeError::InvalidInput), + let new_datetime = match unit.to_ascii_lowercase().as_str() { + "years" | "year" => add_months(datetime, value * 12, is_ago), + "months" | "month" => add_months(datetime, value, is_ago), + "fortnights" | "fortnight" => add_days(datetime, value * 14, is_ago), + "weeks" | "week" => add_days(datetime, value * 7, is_ago), + "days" | "day" => add_days(datetime, value, is_ago), + "hours" | "hour" | "h" => add_duration(datetime, Duration::hours(value), is_ago), + "minutes" | "minute" | "mins" | "min" | "m" => { + add_duration(datetime, Duration::minutes(value), is_ago) + } + "seconds" | "second" | "secs" | "sec" | "s" => { + add_duration(datetime, Duration::seconds(value), is_ago) + } + "yesterday" => add_days(datetime, 1, true), + "tomorrow" => add_days(datetime, 1, false), + "now" | "today" => Some(datetime), + _ => capture + .name("weekday") + .and_then(|weekday| parse_weekday(weekday.as_str())) + .and_then(|weekday| adjust_for_weekday(datetime, weekday, value, is_ago)), + }; + datetime = match new_datetime { + Some(dt) => dt, + None => return Err(ParseDateTimeError::InvalidInput), }; - let neg_duration = -duration; - total_duration = - match total_duration.checked_add(if is_ago { &neg_duration } else { &duration }) { - Some(duration) => duration, - None => return Err(ParseDateTimeError::InvalidInput), - }; // Calculate the total length of the matched substring if let Some(m) = capture.get(0) { @@ -139,76 +150,270 @@ pub fn parse_relative_time_at_date( if captures_processed == 0 { Err(ParseDateTimeError::InvalidInput) } else { - let time_now = Local::now().date_naive(); - let date_duration = date - time_now; + Ok(datetime) + } +} + +fn adjust_for_weekday( + mut datetime: DateTime, + weekday: Weekday, + mut amount: i64, + is_ago: bool, +) -> Option> { + let mut same_day = true; + // last/this/next truncates the time to midnight + datetime = datetime.with_time(NaiveTime::MIN).unwrap(); + while datetime.weekday() != weekday { + datetime = add_days(datetime, 1, is_ago)?; + same_day = false; + } + if !same_day && 0 < amount { + amount -= 1; + } + add_days(datetime, amount * 7, is_ago) +} + +fn add_months( + datetime: DateTime, + months: i64, + mut is_ago: bool, +) -> Option> { + let months = if months < 0 { + is_ago = !is_ago; + u32::try_from(-months).ok()? + } else { + u32::try_from(months).ok()? + }; + if is_ago { + datetime.checked_sub_months(Months::new(months)) + } else { + checked_add_months(datetime, months) + } +} + +/// Whether the given year is a leap year. +fn is_leap_year(year: i32) -> bool { + NaiveDate::from_ymd_opt(year, 1, 1).is_some_and(|d| d.leap_year()) +} + +/// Get the number of days in the month in a particular year. +/// +/// The year is required because February has 29 days in leap years. +fn days_in_month(year: i32, month: u32) -> u32 { + if is_leap_year(year) && month == 2 { + 29 + } else { + DAYS_PER_MONTH[month as usize - 1] + } +} + +/// Get the datetime at the given number of months ahead. +/// +/// If the date is out of range or would be ambiguous (in the case of a +/// fold in the local time), return `None`. +/// +/// If the day would be out of range in the new month (for example, if +/// `datetime` has day 31 but the resulting month only has 30 days), +/// then the surplus days are rolled over into the following month. +/// +/// # Examples +/// +/// Surplus days are rolled over +/// +/// ```rust,ignore +/// use chrono::{NaiveDate, TimeZone, Utc}; +/// let datetime = Utc.from_utc_datetime( +/// &NaiveDate::from_ymd_opt(1996, 3, 31).unwrap().into() +/// ); +/// let new_datetime = checked_add_months(datetime, 1).unwrap(); +/// assert_eq!( +/// new_datetime, +/// Utc.from_utc_datetime(&NaiveDate::from_ymd_opt(1996, 5, 1).unwrap().into()), +/// ); +/// ``` +fn checked_add_months(datetime: DateTime, months: u32) -> Option> +where + T: TimeZone, +{ + // The starting date. + let ref_year = datetime.year(); + let ref_month = datetime.month(); + let ref_date_in_months = 12 * ref_year + (ref_month as i32) - 1; + + // The year, month, and day of the target date. + let target_date_in_months = ref_date_in_months.checked_add(months as i32)?; + let year = target_date_in_months.div_euclid(12); + let month = target_date_in_months.rem_euclid(12) + 1; + let day = datetime.day(); + + // Account for overflow when getting the correct day in the next + // month. For example, + // + // $ date -I --date '1996-01-31 +1 month' # a leap year + // 1996-03-02 + // $ date -I --date '1997-01-31 +1 month' # a non-leap year + // 1997-03-03 + // + let (month, day) = if day > days_in_month(year, month as u32) { + (month + 1, day - days_in_month(year, month as u32)) + } else { + (month, datetime.day()) + }; - Ok(total_duration + date_duration) + // Create the new timezone-naive datetime. + let new_date = NaiveDate::from_ymd_opt(year, month as u32, day)?; + let time = datetime.time(); + let new_naive_datetime = NaiveDateTime::new(new_date, time); + + // Make it timezone-aware. + let offset = T::from_offset(datetime.offset()); + match offset.from_local_datetime(&new_naive_datetime) { + LocalResult::Single(d) => Some(d), + LocalResult::Ambiguous(_, _) | LocalResult::None => None, + } +} + +fn add_days( + datetime: DateTime, + days: i64, + mut is_ago: bool, +) -> Option> { + let days = if days < 0 { + is_ago = !is_ago; + u64::try_from(-days).ok()? + } else { + u64::try_from(days).ok()? + }; + if is_ago { + datetime.checked_sub_days(Days::new(days)) + } else { + datetime.checked_add_days(Days::new(days)) } } +fn add_duration( + datetime: DateTime, + duration: Duration, + is_ago: bool, +) -> Option> { + let duration = if is_ago { -duration } else { duration }; + datetime.checked_add_signed(duration) +} + #[cfg(test)] mod tests { - + use super::parse_relative_time_at_date; use super::ParseDateTimeError; - use super::{parse_relative_time, parse_relative_time_at_date}; - use chrono::{Duration, Local, NaiveDate, Utc}; + use chrono::{Days, Duration, Months, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc}; + + fn parse_duration(s: &str) -> Result { + let now = Utc::now(); + let parsed = parse_relative_time_at_date(now, s)?; + Ok(parsed - now) + } + + #[test] + fn test_empty_string() { + let now = Utc::now(); + assert_eq!(parse_relative_time_at_date(now, "").unwrap(), now); + } #[test] fn test_years() { + let now = Utc::now(); assert_eq!( - parse_relative_time("1 year").unwrap(), - Duration::seconds(31_536_000) + parse_relative_time_at_date(now, "1 year").unwrap(), + now.checked_add_months(Months::new(12)).unwrap() ); + assert_eq!(parse_relative_time_at_date(now, "this year").unwrap(), now); assert_eq!( - parse_relative_time("-2 years").unwrap(), - Duration::seconds(-63_072_000) + parse_relative_time_at_date(now, "-2 years").unwrap(), + now.checked_sub_months(Months::new(24)).unwrap() ); assert_eq!( - parse_relative_time("2 years ago").unwrap(), - Duration::seconds(-63_072_000) + parse_relative_time_at_date(now, "2 years ago").unwrap(), + now.checked_sub_months(Months::new(24)).unwrap() ); assert_eq!( - parse_relative_time("year").unwrap(), - Duration::seconds(31_536_000) + parse_relative_time_at_date(now, "year").unwrap(), + now.checked_add_months(Months::new(12)).unwrap() ); } + #[test] + fn test_leap_day() { + // $ date -I --date '1996-02-29 +1 year' + // 1997-03-01 + // $ date -I --date '1996-02-29 +12 months' + // 1997-03-01 + let datetime = Utc.from_utc_datetime(&NaiveDate::from_ymd_opt(1996, 2, 29).unwrap().into()); + let expected = Utc.from_utc_datetime(&NaiveDate::from_ymd_opt(1997, 3, 1).unwrap().into()); + let parse = |s| parse_relative_time_at_date(datetime, s).unwrap(); + assert_eq!(parse("+1 year"), expected); + assert_eq!(parse("+12 months"), expected); + assert_eq!(parse("+366 days"), expected); + } + #[test] fn test_months() { + use crate::parse_relative_time::add_months; + + let now = Utc::now(); assert_eq!( - parse_relative_time("1 month").unwrap(), - Duration::seconds(2_592_000) + parse_relative_time_at_date(now, "1 month").unwrap(), + add_months(now, 1, false).unwrap(), ); + assert_eq!(parse_relative_time_at_date(now, "this month").unwrap(), now); assert_eq!( - parse_relative_time("1 month and 2 weeks").unwrap(), - Duration::seconds(3_801_600) + parse_relative_time_at_date(now, "1 month and 2 weeks").unwrap(), + add_months(now, 1, false) + .unwrap() + .checked_add_days(Days::new(14)) + .unwrap() ); assert_eq!( - parse_relative_time("1 month and 2 weeks ago").unwrap(), - Duration::seconds(-3_801_600) + parse_relative_time_at_date(now, "1 month and 2 weeks ago").unwrap(), + add_months(now, 1, true) + .unwrap() + .checked_sub_days(Days::new(14)) + .unwrap() ); assert_eq!( - parse_relative_time("2 months").unwrap(), - Duration::seconds(5_184_000) + parse_relative_time_at_date(now, "2 months").unwrap(), + now.checked_add_months(Months::new(2)).unwrap() ); assert_eq!( - parse_relative_time("month").unwrap(), - Duration::seconds(2_592_000) + parse_relative_time_at_date(now, "month").unwrap(), + add_months(now, 1, false).unwrap(), ); } + #[test] + fn test_overflow_days_in_month() { + // $ date -I --date '1996-03-31 1 month' + // 1996-05-01 + let datetime = Utc.from_utc_datetime(&NaiveDate::from_ymd_opt(1996, 3, 31).unwrap().into()); + let expected = Utc.from_utc_datetime(&NaiveDate::from_ymd_opt(1996, 5, 1).unwrap().into()); + let parse = |s| parse_relative_time_at_date(datetime, s).unwrap(); + assert_eq!(parse("1 month"), expected); + } + #[test] fn test_fortnights() { assert_eq!( - parse_relative_time("1 fortnight").unwrap(), + parse_duration("1 fortnight").unwrap(), Duration::seconds(1_209_600) ); assert_eq!( - parse_relative_time("3 fortnights").unwrap(), + parse_duration("this fortnight").unwrap(), + Duration::seconds(0) + ); + assert_eq!( + parse_duration("3 fortnights").unwrap(), Duration::seconds(3_628_800) ); assert_eq!( - parse_relative_time("fortnight").unwrap(), + parse_duration("fortnight").unwrap(), Duration::seconds(1_209_600) ); } @@ -216,153 +421,143 @@ mod tests { #[test] fn test_weeks() { assert_eq!( - parse_relative_time("1 week").unwrap(), + parse_duration("1 week").unwrap(), Duration::seconds(604_800) ); + assert_eq!(parse_duration("this week").unwrap(), Duration::seconds(0)); assert_eq!( - parse_relative_time("1 week 3 days").unwrap(), + parse_duration("1 week 3 days").unwrap(), Duration::seconds(864_000) ); assert_eq!( - parse_relative_time("1 week 3 days ago").unwrap(), + parse_duration("1 week 3 days ago").unwrap(), Duration::seconds(-864_000) ); assert_eq!( - parse_relative_time("-2 weeks").unwrap(), + parse_duration("-2 weeks").unwrap(), Duration::seconds(-1_209_600) ); assert_eq!( - parse_relative_time("2 weeks ago").unwrap(), + parse_duration("2 weeks ago").unwrap(), Duration::seconds(-1_209_600) ); - assert_eq!( - parse_relative_time("week").unwrap(), - Duration::seconds(604_800) - ); + assert_eq!(parse_duration("week").unwrap(), Duration::seconds(604_800)); } #[test] fn test_days() { + assert_eq!(parse_duration("1 day").unwrap(), Duration::seconds(86400)); assert_eq!( - parse_relative_time("1 day").unwrap(), - Duration::seconds(86400) - ); - assert_eq!( - parse_relative_time("2 days ago").unwrap(), + parse_duration("2 days ago").unwrap(), Duration::seconds(-172_800) ); + assert_eq!(parse_duration("this day").unwrap(), Duration::seconds(0)); assert_eq!( - parse_relative_time("-2 days").unwrap(), + parse_duration("-2 days").unwrap(), Duration::seconds(-172_800) ); - assert_eq!( - parse_relative_time("day").unwrap(), - Duration::seconds(86400) - ); + assert_eq!(parse_duration("day").unwrap(), Duration::seconds(86400)); } #[test] fn test_hours() { + assert_eq!(parse_duration("1 hour").unwrap(), Duration::seconds(3600)); assert_eq!( - parse_relative_time("1 hour").unwrap(), - Duration::seconds(3600) - ); - assert_eq!( - parse_relative_time("1 hour ago").unwrap(), + parse_duration("1 hour ago").unwrap(), Duration::seconds(-3600) ); + assert_eq!(parse_duration("this hour").unwrap(), Duration::seconds(0)); assert_eq!( - parse_relative_time("-2 hours").unwrap(), + parse_duration("-2 hours").unwrap(), Duration::seconds(-7200) ); - assert_eq!( - parse_relative_time("hour").unwrap(), - Duration::seconds(3600) - ); + assert_eq!(parse_duration("hour").unwrap(), Duration::seconds(3600)); } #[test] fn test_minutes() { - assert_eq!( - parse_relative_time("1 minute").unwrap(), - Duration::seconds(60) - ); - assert_eq!( - parse_relative_time("2 minutes").unwrap(), - Duration::seconds(120) - ); - assert_eq!(parse_relative_time("min").unwrap(), Duration::seconds(60)); + assert_eq!(parse_duration("this minute").unwrap(), Duration::seconds(0)); + assert_eq!(parse_duration("1 minute").unwrap(), Duration::seconds(60)); + assert_eq!(parse_duration("2 minutes").unwrap(), Duration::seconds(120)); + assert_eq!(parse_duration("min").unwrap(), Duration::seconds(60)); } #[test] fn test_seconds() { - assert_eq!( - parse_relative_time("1 second").unwrap(), - Duration::seconds(1) - ); - assert_eq!( - parse_relative_time("2 seconds").unwrap(), - Duration::seconds(2) - ); - assert_eq!(parse_relative_time("sec").unwrap(), Duration::seconds(1)); + assert_eq!(parse_duration("this second").unwrap(), Duration::seconds(0)); + assert_eq!(parse_duration("1 second").unwrap(), Duration::seconds(1)); + assert_eq!(parse_duration("2 seconds").unwrap(), Duration::seconds(2)); + assert_eq!(parse_duration("sec").unwrap(), Duration::seconds(1)); } #[test] fn test_relative_days() { - assert_eq!(parse_relative_time("now").unwrap(), Duration::seconds(0)); - assert_eq!(parse_relative_time("today").unwrap(), Duration::seconds(0)); + assert_eq!(parse_duration("now").unwrap(), Duration::seconds(0)); + assert_eq!(parse_duration("today").unwrap(), Duration::seconds(0)); assert_eq!( - parse_relative_time("yesterday").unwrap(), + parse_duration("yesterday").unwrap(), Duration::seconds(-86400) ); assert_eq!( - parse_relative_time("tomorrow").unwrap(), + parse_duration("tomorrow").unwrap(), Duration::seconds(86400) ); } #[test] fn test_no_spaces() { - assert_eq!(parse_relative_time("-1hour").unwrap(), Duration::hours(-1)); - assert_eq!(parse_relative_time("+3days").unwrap(), Duration::days(3)); - assert_eq!(parse_relative_time("2weeks").unwrap(), Duration::weeks(2)); + let now = Utc::now(); + assert_eq!(parse_duration("-1hour").unwrap(), Duration::hours(-1)); + assert_eq!(parse_duration("+3days").unwrap(), Duration::days(3)); + assert_eq!(parse_duration("2weeks").unwrap(), Duration::weeks(2)); assert_eq!( - parse_relative_time("2weeks 1hour").unwrap(), + parse_duration("2weeks 1hour").unwrap(), Duration::seconds(1_213_200) ); assert_eq!( - parse_relative_time("2weeks 1hour ago").unwrap(), + parse_duration("2weeks 1hour ago").unwrap(), Duration::seconds(-1_213_200) ); + assert_eq!(parse_duration("thismonth").unwrap(), Duration::days(0)); assert_eq!( - parse_relative_time("+4months").unwrap(), - Duration::days(4 * 30) + parse_relative_time_at_date(now, "+4months").unwrap(), + now.checked_add_months(Months::new(4)).unwrap() ); assert_eq!( - parse_relative_time("-2years").unwrap(), - Duration::days(-2 * 365) + parse_relative_time_at_date(now, "-2years").unwrap(), + now.checked_sub_months(Months::new(24)).unwrap() ); + assert_eq!(parse_duration("15minutes").unwrap(), Duration::minutes(15)); assert_eq!( - parse_relative_time("15minutes").unwrap(), - Duration::minutes(15) + parse_duration("-30seconds").unwrap(), + Duration::seconds(-30) ); assert_eq!( - parse_relative_time("-30seconds").unwrap(), + parse_duration("30seconds ago").unwrap(), Duration::seconds(-30) ); + } + + #[test] + fn test_spaces() { + let now = Utc::now(); assert_eq!( - parse_relative_time("30seconds ago").unwrap(), - Duration::seconds(-30) + parse_relative_time_at_date(now, "+ 1 hour").unwrap(), + now.checked_add_signed(Duration::hours(1)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "- 1 hour").unwrap(), + now.checked_sub_signed(Duration::hours(1)).unwrap() ); } #[test] fn test_invalid_input() { - let result = parse_relative_time("foobar"); + let result = parse_duration("foobar"); println!("{result:?}"); assert_eq!(result, Err(ParseDateTimeError::InvalidInput)); - let result = parse_relative_time("invalid 1"); + let result = parse_duration("invalid 1"); assert_eq!(result, Err(ParseDateTimeError::InvalidInput)); // Fails for now with a panic /* let result = parse_relative_time("777777777777777771m"); @@ -374,249 +569,471 @@ mod tests { #[test] fn test_parse_relative_time_at_date() { - let date = NaiveDate::from_ymd_opt(2014, 9, 5).unwrap(); - let now = Local::now().date_naive(); - let days_diff = (date - now).num_days(); + let datetime = Utc.from_utc_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(2014, 9, 5).unwrap(), + NaiveTime::from_hms_opt(0, 2, 3).unwrap(), + )); + let now = Utc::now(); + let diff = datetime - now; assert_eq!( - parse_relative_time_at_date(date, "1 day").unwrap(), - Duration::days(days_diff + 1) + parse_relative_time_at_date(datetime, "1 day").unwrap(), + now + diff + Duration::days(1) ); assert_eq!( - parse_relative_time_at_date(date, "2 hours").unwrap(), - Duration::days(days_diff) + Duration::hours(2) + parse_relative_time_at_date(datetime, "2 hours").unwrap(), + now + diff + Duration::hours(2) ); } - #[test] - fn test_invalid_input_at_date() { - let date = NaiveDate::from_ymd_opt(2014, 9, 5).unwrap(); - assert!(matches!( - parse_relative_time_at_date(date, "invalid"), - Err(ParseDateTimeError::InvalidInput) - )); - } - #[test] fn test_direction() { + let now = Utc::now(); assert_eq!( - parse_relative_time("last hour").unwrap(), + parse_duration("last hour").unwrap(), Duration::seconds(-3600) ); assert_eq!( - parse_relative_time("next year").unwrap(), - Duration::days(365) + parse_relative_time_at_date(now, "next year").unwrap(), + now.checked_add_months(Months::new(12)).unwrap() ); - assert_eq!(parse_relative_time("next week").unwrap(), Duration::days(7)); + assert_eq!(parse_duration("next week").unwrap(), Duration::days(7)); assert_eq!( - parse_relative_time("last month").unwrap(), - Duration::days(-30) + parse_relative_time_at_date(now, "last month").unwrap(), + now.checked_sub_months(Months::new(1)).unwrap() ); + + assert_eq!(parse_duration("this month").unwrap(), Duration::days(0)); + + assert_eq!(parse_duration("this year").unwrap(), Duration::days(0)); } #[test] fn test_duration_parsing() { + use crate::parse_relative_time::add_months; + + let now = Utc::now(); assert_eq!( - parse_relative_time("1 year").unwrap(), - Duration::seconds(31_536_000) + parse_relative_time_at_date(now, "1 year").unwrap(), + now.checked_add_months(Months::new(12)).unwrap() ); assert_eq!( - parse_relative_time("-2 years").unwrap(), - Duration::seconds(-63_072_000) + parse_relative_time_at_date(now, "-2 years").unwrap(), + now.checked_sub_months(Months::new(24)).unwrap() ); assert_eq!( - parse_relative_time("2 years ago").unwrap(), - Duration::seconds(-63_072_000) + parse_relative_time_at_date(now, "2 years ago").unwrap(), + now.checked_sub_months(Months::new(24)).unwrap() ); assert_eq!( - parse_relative_time("year").unwrap(), - Duration::seconds(31_536_000) + parse_relative_time_at_date(now, "year").unwrap(), + now.checked_add_months(Months::new(12)).unwrap() ); assert_eq!( - parse_relative_time("1 month").unwrap(), - Duration::seconds(2_592_000) + parse_relative_time_at_date(now, "1 month").unwrap(), + add_months(now, 1, false).unwrap(), ); assert_eq!( - parse_relative_time("1 month and 2 weeks").unwrap(), - Duration::seconds(3_801_600) + parse_relative_time_at_date(now, "1 month and 2 weeks").unwrap(), + add_months(now, 1, false) + .unwrap() + .checked_add_days(Days::new(14)) + .unwrap() ); assert_eq!( - parse_relative_time("1 month, 2 weeks").unwrap(), - Duration::seconds(3_801_600) + parse_relative_time_at_date(now, "1 month, 2 weeks").unwrap(), + add_months(now, 1, false) + .unwrap() + .checked_add_days(Days::new(14)) + .unwrap() ); assert_eq!( - parse_relative_time("1 months 2 weeks").unwrap(), - Duration::seconds(3_801_600) + parse_relative_time_at_date(now, "1 months 2 weeks").unwrap(), + add_months(now, 1, false) + .unwrap() + .checked_add_days(Days::new(14)) + .unwrap() ); assert_eq!( - parse_relative_time("1 month and 2 weeks ago").unwrap(), - Duration::seconds(-3_801_600) + parse_relative_time_at_date(now, "1 month and 2 weeks ago").unwrap(), + now.checked_sub_months(Months::new(1)) + .unwrap() + .checked_sub_days(Days::new(14)) + .unwrap() ); assert_eq!( - parse_relative_time("2 months").unwrap(), - Duration::seconds(5_184_000) + parse_relative_time_at_date(now, "2 months").unwrap(), + now.checked_add_months(Months::new(2)).unwrap() ); assert_eq!( - parse_relative_time("month").unwrap(), - Duration::seconds(2_592_000) + parse_relative_time_at_date(now, "month").unwrap(), + add_months(now, 1, false).unwrap(), ); assert_eq!( - parse_relative_time("1 fortnight").unwrap(), - Duration::seconds(1_209_600) + parse_relative_time_at_date(now, "1 fortnight").unwrap(), + now.checked_add_days(Days::new(14)).unwrap() ); assert_eq!( - parse_relative_time("3 fortnights").unwrap(), - Duration::seconds(3_628_800) + parse_relative_time_at_date(now, "3 fortnights").unwrap(), + now.checked_add_days(Days::new(3 * 14)).unwrap() ); assert_eq!( - parse_relative_time("fortnight").unwrap(), - Duration::seconds(1_209_600) + parse_relative_time_at_date(now, "fortnight").unwrap(), + now.checked_add_days(Days::new(14)).unwrap() ); assert_eq!( - parse_relative_time("1 week").unwrap(), - Duration::seconds(604_800) + parse_relative_time_at_date(now, "1 week").unwrap(), + now.checked_add_days(Days::new(7)).unwrap() ); assert_eq!( - parse_relative_time("1 week 3 days").unwrap(), - Duration::seconds(864_000) + parse_relative_time_at_date(now, "1 week 3 days").unwrap(), + now.checked_add_days(Days::new(7 + 3)).unwrap() ); assert_eq!( - parse_relative_time("1 week 3 days ago").unwrap(), - Duration::seconds(-864_000) + parse_relative_time_at_date(now, "1 week 3 days ago").unwrap(), + now.checked_sub_days(Days::new(7 + 3)).unwrap() ); assert_eq!( - parse_relative_time("-2 weeks").unwrap(), - Duration::seconds(-1_209_600) + parse_relative_time_at_date(now, "-2 weeks").unwrap(), + now.checked_sub_days(Days::new(14)).unwrap() ); assert_eq!( - parse_relative_time("2 weeks ago").unwrap(), - Duration::seconds(-1_209_600) + parse_relative_time_at_date(now, "2 weeks ago").unwrap(), + now.checked_sub_days(Days::new(14)).unwrap() ); assert_eq!( - parse_relative_time("week").unwrap(), - Duration::seconds(604_800) + parse_relative_time_at_date(now, "week").unwrap(), + now.checked_add_days(Days::new(7)).unwrap() ); + assert_eq!(parse_duration("1 day").unwrap(), Duration::seconds(86_400)); assert_eq!( - parse_relative_time("1 day").unwrap(), - Duration::seconds(86_400) + parse_duration("2 days ago").unwrap(), + Duration::seconds(-172_800) ); assert_eq!( - parse_relative_time("2 days ago").unwrap(), + parse_duration("-2 days").unwrap(), Duration::seconds(-172_800) ); + assert_eq!(parse_duration("day").unwrap(), Duration::seconds(86_400)); + + assert_eq!(parse_duration("1 hour").unwrap(), Duration::seconds(3_600)); + assert_eq!(parse_duration("1 h").unwrap(), Duration::seconds(3_600)); assert_eq!( - parse_relative_time("-2 days").unwrap(), - Duration::seconds(-172_800) + parse_duration("1 hour ago").unwrap(), + Duration::seconds(-3_600) ); assert_eq!( - parse_relative_time("day").unwrap(), - Duration::seconds(86_400) + parse_duration("-2 hours").unwrap(), + Duration::seconds(-7_200) ); + assert_eq!(parse_duration("hour").unwrap(), Duration::seconds(3_600)); + + assert_eq!(parse_duration("1 minute").unwrap(), Duration::seconds(60)); + assert_eq!(parse_duration("1 min").unwrap(), Duration::seconds(60)); + assert_eq!(parse_duration("2 minutes").unwrap(), Duration::seconds(120)); + assert_eq!(parse_duration("2 mins").unwrap(), Duration::seconds(120)); + assert_eq!(parse_duration("2m").unwrap(), Duration::seconds(120)); + assert_eq!(parse_duration("min").unwrap(), Duration::seconds(60)); + + assert_eq!(parse_duration("1 second").unwrap(), Duration::seconds(1)); + assert_eq!(parse_duration("1 s").unwrap(), Duration::seconds(1)); + assert_eq!(parse_duration("2 seconds").unwrap(), Duration::seconds(2)); + assert_eq!(parse_duration("2 secs").unwrap(), Duration::seconds(2)); + assert_eq!(parse_duration("2 sec").unwrap(), Duration::seconds(2)); + assert_eq!(parse_duration("sec").unwrap(), Duration::seconds(1)); + + assert_eq!(parse_duration("now").unwrap(), Duration::seconds(0)); + assert_eq!(parse_duration("today").unwrap(), Duration::seconds(0)); assert_eq!( - parse_relative_time("1 hour").unwrap(), - Duration::seconds(3_600) + parse_relative_time_at_date(now, "1 year 2 months 4 weeks 3 days and 2 seconds") + .unwrap(), + now.checked_add_months(Months::new(12 + 2)) + .unwrap() + .checked_add_days(Days::new(4 * 7 + 3)) + .unwrap() + .checked_add_signed(Duration::seconds(2)) + .unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "1 year 2 months 4 weeks 3 days and 2 seconds ago") + .unwrap(), + now.checked_sub_months(Months::new(12 + 2)) + .unwrap() + .checked_sub_days(Days::new(4 * 7 + 3)) + .unwrap() + .checked_sub_signed(Duration::seconds(2)) + .unwrap() ); + } + + #[test] + #[should_panic] + fn test_display_parse_duration_error_through_parse_relative_time() { + let invalid_input = "9223372036854775807 seconds and 1 second"; + let _ = parse_duration(invalid_input).unwrap(); + } + + #[test] + fn test_display_should_fail() { + let invalid_input = "Thu Jan 01 12:34:00 2015"; + let error = parse_duration(invalid_input).unwrap_err(); + assert_eq!( - parse_relative_time("1 h").unwrap(), - Duration::seconds(3_600) + format!("{error}"), + "Invalid input string: cannot be parsed as a relative time" ); + } + + #[test] + fn test_parse_relative_time_at_date_day() { + let now = Utc::now(); + let now_yesterday = now - Duration::days(1); assert_eq!( - parse_relative_time("1 hour ago").unwrap(), - Duration::seconds(-3_600) + parse_relative_time_at_date(now_yesterday, "2 days").unwrap(), + now + Duration::days(1) ); + } + + #[test] + fn test_parse_relative_time_at_date_month() { + // Use January because it has 31 days rather than 30 + let now = Utc.from_utc_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + )); assert_eq!( - parse_relative_time("-2 hours").unwrap(), - Duration::seconds(-7_200) + parse_relative_time_at_date(now, "1 month").unwrap(), + now.checked_add_months(Months::new(1)).unwrap() ); + } + + #[test] + fn test_parse_relative_time_at_date_year() { + // Use 2024 because it's a leap year and has 366 days rather than 365 + let now = Utc.from_utc_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + )); assert_eq!( - parse_relative_time("hour").unwrap(), - Duration::seconds(3_600) + parse_relative_time_at_date(now, "1 year").unwrap(), + now.checked_add_months(Months::new(12)).unwrap() ); + } + + #[test] + fn test_invalid_input_at_date_relative() { + let now = Utc::now(); + let result = parse_relative_time_at_date(now, "foobar"); + assert_eq!(result, Err(ParseDateTimeError::InvalidInput)); + let result = parse_relative_time_at_date(now, "invalid 1r"); + assert_eq!(result, Err(ParseDateTimeError::InvalidInput)); + } + + #[test] + fn test_parse_relative_time_at_date_this_weekday() { + // Jan 1 2025 is a Wed + let now = Utc.from_utc_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + )); + // Check "this " assert_eq!( - parse_relative_time("1 minute").unwrap(), - Duration::seconds(60) + parse_relative_time_at_date(now, "this wednesday").unwrap(), + now ); - assert_eq!(parse_relative_time("1 min").unwrap(), Duration::seconds(60)); + assert_eq!(parse_relative_time_at_date(now, "this wed").unwrap(), now); + // Other days assert_eq!( - parse_relative_time("2 minutes").unwrap(), - Duration::seconds(120) + parse_relative_time_at_date(now, "this thursday").unwrap(), + now.checked_add_days(Days::new(1)).unwrap() ); assert_eq!( - parse_relative_time("2 mins").unwrap(), - Duration::seconds(120) + parse_relative_time_at_date(now, "this thur").unwrap(), + now.checked_add_days(Days::new(1)).unwrap() ); - assert_eq!(parse_relative_time("2m").unwrap(), Duration::seconds(120)); - assert_eq!(parse_relative_time("min").unwrap(), Duration::seconds(60)); - assert_eq!( - parse_relative_time("1 second").unwrap(), - Duration::seconds(1) + parse_relative_time_at_date(now, "this thu").unwrap(), + now.checked_add_days(Days::new(1)).unwrap() ); - assert_eq!(parse_relative_time("1 s").unwrap(), Duration::seconds(1)); assert_eq!( - parse_relative_time("2 seconds").unwrap(), - Duration::seconds(2) + parse_relative_time_at_date(now, "this friday").unwrap(), + now.checked_add_days(Days::new(2)).unwrap() ); - assert_eq!(parse_relative_time("2 secs").unwrap(), Duration::seconds(2)); - assert_eq!(parse_relative_time("2 sec").unwrap(), Duration::seconds(2)); - assert_eq!(parse_relative_time("sec").unwrap(), Duration::seconds(1)); - - assert_eq!(parse_relative_time("now").unwrap(), Duration::seconds(0)); - assert_eq!(parse_relative_time("today").unwrap(), Duration::seconds(0)); - assert_eq!( - parse_relative_time("1 year 2 months 4 weeks 3 days and 2 seconds").unwrap(), - Duration::seconds(39_398_402) + parse_relative_time_at_date(now, "this fri").unwrap(), + now.checked_add_days(Days::new(2)).unwrap() ); assert_eq!( - parse_relative_time("1 year 2 months 4 weeks 3 days and 2 seconds ago").unwrap(), - Duration::seconds(-39_398_402) + parse_relative_time_at_date(now, "this saturday").unwrap(), + now.checked_add_days(Days::new(3)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "this sat").unwrap(), + now.checked_add_days(Days::new(3)).unwrap() + ); + // "this" with a day of the week that comes before today should return the next instance of + // that day + assert_eq!( + parse_relative_time_at_date(now, "this sunday").unwrap(), + now.checked_add_days(Days::new(4)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "this sun").unwrap(), + now.checked_add_days(Days::new(4)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "this monday").unwrap(), + now.checked_add_days(Days::new(5)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "this mon").unwrap(), + now.checked_add_days(Days::new(5)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "this tuesday").unwrap(), + now.checked_add_days(Days::new(6)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "this tue").unwrap(), + now.checked_add_days(Days::new(6)).unwrap() ); } #[test] - #[should_panic] - fn test_display_parse_duration_error_through_parse_relative_time() { - let invalid_input = "9223372036854775807 seconds and 1 second"; - let _ = parse_relative_time(invalid_input).unwrap(); + fn test_parse_relative_time_at_date_last_weekday() { + // Jan 1 2025 is a Wed + let now = Utc.from_utc_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + )); + // Check "last " + assert_eq!( + parse_relative_time_at_date(now, "last wed").unwrap(), + now.checked_sub_days(Days::new(7)).unwrap() + ); + // Check "last " + assert_eq!( + parse_relative_time_at_date(now, "last thu").unwrap(), + now.checked_sub_days(Days::new(6)).unwrap() + ); + // Check "last " + assert_eq!( + parse_relative_time_at_date(now, "last tue").unwrap(), + now.checked_sub_days(Days::new(1)).unwrap() + ); } #[test] - fn test_display_should_fail() { - let invalid_input = "Thu Jan 01 12:34:00 2015"; - let error = parse_relative_time(invalid_input).unwrap_err(); + fn test_parse_relative_time_at_date_next_weekday() { + // Jan 1 2025 is a Wed + let now = Utc.from_utc_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + )); + // Check "next " + assert_eq!( + parse_relative_time_at_date(now, "next wed").unwrap(), + now.checked_add_days(Days::new(7)).unwrap() + ); + // Check "next " + assert_eq!( + parse_relative_time_at_date(now, "next thu").unwrap(), + now.checked_add_days(Days::new(1)).unwrap() + ); + // Check "next " + assert_eq!( + parse_relative_time_at_date(now, "next tue").unwrap(), + now.checked_add_days(Days::new(6)).unwrap() + ); + } + #[test] + fn test_parse_relative_time_at_date_number_weekday() { + // Jan 1 2025 is a Wed + let now = Utc.from_utc_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + )); assert_eq!( - format!("{error}"), - "Invalid input string: cannot be parsed as a relative time" + parse_relative_time_at_date(now, "1 wed").unwrap(), + now.checked_add_days(Days::new(7)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "1 thu").unwrap(), + now.checked_add_days(Days::new(1)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "1 tue").unwrap(), + now.checked_add_days(Days::new(6)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "2 wed").unwrap(), + now.checked_add_days(Days::new(14)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "2 thu").unwrap(), + now.checked_add_days(Days::new(8)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "2 tue").unwrap(), + now.checked_add_days(Days::new(13)).unwrap() ); } #[test] - fn test_parse_relative_time_at_date_day() { - let today = Utc::now().date_naive(); - let yesterday = today - Duration::days(1); + fn test_parse_relative_time_at_date_weekday_truncates_time() { + // Jan 1 2025 is a Wed + let now = Utc.from_utc_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + NaiveTime::from_hms_opt(12, 0, 0).unwrap(), + )); + let now_midnight = Utc.from_utc_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + )); assert_eq!( - parse_relative_time_at_date(yesterday, "2 days").unwrap(), - Duration::days(1) + parse_relative_time_at_date(now, "this wed").unwrap(), + now_midnight + ); + assert_eq!( + parse_relative_time_at_date(now, "last wed").unwrap(), + now_midnight.checked_sub_days(Days::new(7)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "next wed").unwrap(), + now_midnight.checked_add_days(Days::new(7)).unwrap() ); } #[test] - fn test_invalid_input_at_date_relative() { - let today = Utc::now().date_naive(); - let result = parse_relative_time_at_date(today, "foobar"); - println!("{result:?}"); - assert_eq!(result, Err(ParseDateTimeError::InvalidInput)); + fn test_parse_relative_time_at_date_invalid_weekday() { + // Jan 1 2025 is a Wed + let now = Utc.from_utc_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + )); + assert_eq!( + parse_relative_time_at_date(now, "this fooday"), + Err(ParseDateTimeError::InvalidInput) + ); + } - let result = parse_relative_time_at_date(today, "invalid 1r"); - assert_eq!(result, Err(ParseDateTimeError::InvalidInput)); + #[test] + fn test_parse_relative_time_at_date_with_uppercase() { + let tests = vec!["today", "last week", "next month", "1 year ago"]; + let now = Utc::now(); + for t in tests { + assert_eq!( + parse_relative_time_at_date(now, &t.to_uppercase()).unwrap(), + parse_relative_time_at_date(now, t).unwrap(), + ); + } } } diff --git a/src/parse_time_only_str.rs b/src/parse_time_only_str.rs index 69ad488..6816953 100644 --- a/src/parse_time_only_str.rs +++ b/src/parse_time_only_str.rs @@ -4,7 +4,68 @@ use regex::Regex; mod time_only_formats { pub const HH_MM: &str = "%R"; pub const HH_MM_SS: &str = "%T"; - pub const TWELVEHOUR: &str = "%r"; + pub const TWELVE_HOUR: &str = "%r"; +} + +/// Convert a military time zone string to a time zone offset. +/// +/// Military time zones are the letters A through Z except J. They are +/// described in RFC 5322. +fn to_offset(tz: &str) -> Option { + let hour = match tz { + "A" => 1, + "B" => 2, + "C" => 3, + "D" => 4, + "E" => 5, + "F" => 6, + "G" => 7, + "H" => 8, + "I" => 9, + "K" => 10, + "L" => 11, + "M" => 12, + "N" => -1, + "O" => -2, + "P" => -3, + "Q" => -4, + "R" => -5, + "S" => -6, + "T" => -7, + "U" => -8, + "V" => -9, + "W" => -10, + "X" => -11, + "Y" => -12, + "Z" => 0, + _ => return None, + }; + let offset_in_sec = hour * 3600; + FixedOffset::east_opt(offset_in_sec) +} + +/// Parse a time string without an offset and apply an offset to it. +/// +/// Multiple formats are attempted when parsing the string. +fn parse_time_with_offset_multi( + date: DateTime, + offset: FixedOffset, + s: &str, +) -> Option> { + for fmt in [ + time_only_formats::HH_MM, + time_only_formats::HH_MM_SS, + time_only_formats::TWELVE_HOUR, + ] { + let Ok(parsed) = NaiveTime::parse_from_str(s, fmt) else { + continue; + }; + let parsed_dt = date.date_naive().and_time(parsed); + if let Some(dt) = offset.from_local_datetime(&parsed_dt).single() { + return Some(dt); + } + } + None } pub(crate) fn parse_time_only(date: DateTime, s: &str) -> Option> { @@ -12,6 +73,7 @@ pub(crate) fn parse_time_only(date: DateTime, s: &str) -> Option.*?)(?:(?\+|-)(?[0-9]{1,2}):?(?[0-9]{0,2}))?$").unwrap(); let captures = re.captures(s)?; + // Parse the sign, hour, and minute to get a `FixedOffset`, if possible. let parsed_offset = match captures.name("h") { Some(hours) if !(hours.as_str().is_empty()) => { let mut offset_in_sec = hours.as_str().parse::().unwrap() * 3600; @@ -20,25 +82,40 @@ pub(crate) fn parse_time_only(date: DateTime, s: &str) -> Option().unwrap() * 60; } _ => (), - }; + } offset_in_sec *= if &captures["sign"] == "-" { -1 } else { 1 }; FixedOffset::east_opt(offset_in_sec) } _ => None, }; - for fmt in [ - time_only_formats::HH_MM, - time_only_formats::HH_MM_SS, - time_only_formats::TWELVEHOUR, - ] { - if let Ok(parsed) = NaiveTime::parse_from_str(captures["time"].trim(), fmt) { - let parsed_dt = date.date_naive().and_time(parsed); - let offset = match parsed_offset { - Some(offset) => offset, - None => *date.offset(), - }; - return offset.from_local_datetime(&parsed_dt).single(); + // Parse the time and apply the parsed offset. + let s = captures["time"].trim(); + let offset = match parsed_offset { + Some(offset) => offset, + None => *date.offset(), + }; + if let Some(result) = parse_time_with_offset_multi(date, offset, s) { + return Some(result); + } + + // Military time zones are specified in RFC 5322, Section 4.3 + // "Obsolete Date and Time". + // + // + // We let the parsing above handle "5:00 AM" so at this point we + // should be guaranteed that we don't have an AM/PM suffix. That + // way, we can safely parse "5:00M" here without interference. + let re = Regex::new(r"(?