parse_datetime-0.5.0/.cargo_vcs_info.json0000644000000001360000000000100140240ustar { "git": { "sha1": "02e5c2298544ff77bd6ada003e58f618df1ad0b8" }, "path_in_vcs": "" }parse_datetime-0.5.0/.github/workflows/ci.yml000064400000000000000000000142141046102023000173310ustar 00000000000000on: [push, pull_request] name: Basic CI env: CARGO_TERM_COLOR: always jobs: check: name: cargo check runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, macOS-latest, windows-latest] steps: - uses: actions/checkout@v4 - uses: actions-rs/toolchain@v1 with: profile: minimal toolchain: stable override: true - uses: actions-rs/cargo@v1 with: command: check test: name: cargo test runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, macOS-latest, windows-latest] steps: - uses: actions/checkout@v4 - uses: actions-rs/toolchain@v1 with: profile: minimal toolchain: stable override: true - uses: actions-rs/cargo@v1 with: command: test fmt: name: cargo fmt --all -- --check runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions-rs/toolchain@v1 with: profile: minimal toolchain: stable override: true - run: rustup component add rustfmt - uses: actions-rs/cargo@v1 with: command: fmt args: --all -- --check clippy: name: cargo clippy -- -D warnings runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, macOS-latest, windows-latest] steps: - uses: actions/checkout@v4 - uses: actions-rs/toolchain@v1 with: profile: minimal toolchain: stable override: true - run: rustup component add clippy - uses: actions-rs/cargo@v1 with: command: clippy args: -- -D warnings coverage: name: Code Coverage runs-on: ${{ matrix.job.os }} strategy: fail-fast: true matrix: job: - { os: ubuntu-latest , features: unix } - { os: macos-latest , features: macos } - { os: windows-latest , features: windows } steps: - uses: actions/checkout@v4 - 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 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: actions-rs/toolchain@v1 with: toolchain: ${{ steps.vars.outputs.TOOLCHAIN }} default: true profile: minimal # minimal component installation (ie, no documentation) - name: Test uses: actions-rs/cargo@v1 with: command: test args: ${{ steps.vars.outputs.CARGO_FEATURES_OPTION }} --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" RUSTDOCFLAGS: "-Cpanic=abort" - name: "`grcov` ~ install" id: build_grcov shell: bash run: | git clone 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 . cd - # Uncomment when the upstream issue # https://github.com/mozilla/grcov/issues/849 is fixed # uses: actions-rs/install@v0.1 # with: # crate: grcov # version: latest # use-tool-cache: false - name: Generate coverage data (via `grcov`) id: coverage shell: bash run: | ## Generate coverage data COVERAGE_REPORT_DIR="target/debug" 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 # 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\()" 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 with: # token: ${{ secrets.CODECOV_TOKEN }} file: ${{ steps.coverage.outputs.report }} ## flags: IntegrationTests, UnitTests, ${{ steps.vars.outputs.CODECOV_FLAGS }} flags: ${{ steps.vars.outputs.CODECOV_FLAGS }} name: codecov-umbrella fail_ci_if_error: false fuzz: name: Run the fuzzers runs-on: ubuntu-latest env: RUN_FOR: 60 steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@nightly - name: Install `cargo-fuzz` run: cargo install cargo-fuzz - uses: Swatinem/rust-cache@v2 - name: Run from_str 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 parse_datetime-0.5.0/.gitignore000064400000000000000000000000101046102023000145730ustar 00000000000000target/ parse_datetime-0.5.0/.pre-commit-config.yaml000064400000000000000000000010561046102023000170770ustar 00000000000000repos: - repo: local hooks: - id: rust-linting name: Rust linting description: Run cargo fmt on files included in the commit. entry: cargo +nightly fmt -- pass_filenames: true types: [file, rust] language: system - id: rust-clippy name: Rust clippy description: Run cargo clippy on files included in the commit. entry: cargo +nightly clippy --workspace --all-targets --all-features -- pass_filenames: false types: [file, rust] language: system parse_datetime-0.5.0/Cargo.toml0000644000000015450000000000100120270ustar # THIS FILE IS AUTOMATICALLY GENERATED BY CARGO # # When uploading crates to the registry Cargo will automatically # "normalize" Cargo.toml files for maximal compatibility # with all versions of Cargo and also rewrite `path` dependencies # to registry (e.g., crates.io) dependencies. # # If you are reading this file be aware that the original Cargo.toml # will likely look very different (and much more reasonable). # See Cargo.toml.orig for the original contents. [package] edition = "2021" name = "parse_datetime" version = "0.5.0" description = "parsing human-readable time strings and converting them to a DateTime" readme = "README.md" license = "MIT" repository = "https://github.com/uutils/parse_datetime" [dependencies.chrono] version = "0.4" features = [ "std", "alloc", "clock", ] default-features = false [dependencies.regex] version = "1.9" parse_datetime-0.5.0/Cargo.toml.orig000064400000000000000000000005551046102023000155100ustar 00000000000000[package] name = "parse_datetime" description = "parsing human-readable time strings and converting them to a DateTime" version = "0.5.0" edition = "2021" license = "MIT" repository = "https://github.com/uutils/parse_datetime" readme = "README.md" [dependencies] regex = "1.9" chrono = { version="0.4", default-features=false, features=["std", "alloc", "clock"] } parse_datetime-0.5.0/LICENSE000064400000000000000000000020731046102023000136230ustar 00000000000000MIT License Copyright (c) 2023 Sylvestre Ledru and others Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. parse_datetime-0.5.0/README.md000064400000000000000000000053211046102023000140740ustar 00000000000000# parse_datetime [![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) A Rust crate for parsing human-readable relative time strings and human-readable datetime strings and converting them to a `DateTime`. ## Features - Parses a variety of human-readable and standard time formats. - Supports positive and negative durations. - Allows for chaining time units (e.g., "1 hour 2 minutes" or "2 days and 2 hours"). - Calculate durations relative to a specified date. - Relies on Chrono ## Usage Add this to your `Cargo.toml`: ```toml [dependencies] parse_datetime = "0.4.0" ``` Then, import the crate and use the `parse_datetime_at_date` function: ```rs use chrono::{Duration, Local}; use parse_datetime::parse_datetime_at_date; let now = Local::now(); let after = parse_datetime_at_date(now, "+3 days"); assert_eq!( (now + Duration::days(3)).naive_utc(), after.unwrap().naive_utc() ); ``` For DateTime parsing, import the `parse_datetime` module: ```rs use parse_datetime::parse_datetime::from_str; use chrono::{Local, TimeZone}; let dt = from_str("2021-02-14 06:37:47"); assert_eq!(dt.unwrap(), Local.with_ymd_and_hms(2021, 2, 14, 6, 37, 47).unwrap()); ``` ### Supported Formats The `parse_datetime` and `parse_datetime_at_date` functions support absolute datetime and the following relative times: - `num` `unit` (e.g., "-1 hour", "+3 days") - `unit` (e.g., "hour", "day") - "now" or "today" - "yesterday" - "tomorrow" - use "ago" for the past - use "next" or "last" with `unit` (e.g., "next week", "last year") - combined units with "and" or "," (e.g., "2 years and 1 month", "1 day, 2 hours" or "2 weeks 1 second") - unix timestamps (for example "@0" "@1344000") `num` can be a positive or negative integer. `unit` can be one of the following: "fortnight", "week", "day", "hour", "minute", "min", "second", "sec" and their plural forms. ## Return Values ### parse_datetime and parse_datetime_at_date The `parse_datetime` and `parse_datetime_at_date` function return: - `Ok(DateTime)` - If the input string can be parsed as a datetime - `Err(ParseDateTimeError::InvalidInput)` - If the input string cannot be parsed ## Fuzzer To run the fuzzer: ``` $ cd fuzz $ cargo install cargo-fuzz $ cargo +nightly fuzz run fuzz_parse_datetime ``` ## License This project is licensed under the [MIT License](LICENSE). ## Note At some point, this crate was called humantime_to_duration. It has been renamed to cover more cases. parse_datetime-0.5.0/renovate.json000064400000000000000000000001531046102023000153310ustar 00000000000000{ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "config:base" ] } parse_datetime-0.5.0/src/lib.rs000064400000000000000000000304011046102023000145150ustar 00000000000000// For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. //! A Rust crate for parsing human-readable relative time strings and human-readable datetime strings and converting them to a `DateTime`. //! The function supports the following formats for time: //! //! * ISO formats //! * timezone offsets, e.g., "UTC-0100" //! * unix timestamps, e.g., "@12" //! * relative time to now, e.g. "+1 hour" //! use regex::Error as RegexError; use std::error::Error; use std::fmt::{self, Display}; // Expose parse_datetime mod parse_relative_time; use chrono::{DateTime, FixedOffset, Local, LocalResult, NaiveDateTime, TimeZone}; use parse_relative_time::parse_relative_time; #[derive(Debug, PartialEq)] pub enum ParseDateTimeError { InvalidRegex(RegexError), InvalidInput, } impl Display for ParseDateTimeError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidRegex(err) => { write!(f, "Invalid regex for time pattern: {err}") } Self::InvalidInput => { write!( f, "Invalid input string: cannot be parsed as a relative time" ) } } } } impl Error for ParseDateTimeError {} impl From for ParseDateTimeError { fn from(err: RegexError) -> Self { Self::InvalidRegex(err) } } /// Formats that parse input can take. /// Taken from `touch` coreutils mod format { pub const ISO_8601: &str = "%Y-%m-%d"; pub const ISO_8601_NO_SEP: &str = "%Y%m%d"; 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"; pub const YYYYMMDDHHMMS: &str = "%Y-%m-%d %H:%M:%S"; pub const YYYY_MM_DD_HH_MM: &str = "%Y-%m-%d %H:%M"; pub const YYYYMMDDHHMM: &str = "%Y%m%d%H%M"; pub const YYYYMMDDHHMM_OFFSET: &str = "%Y%m%d%H%M %z"; 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 YYYYMMDDHHMMS_T_SEP: &str = "%Y-%m-%dT%H:%M:%S"; pub const UTC_OFFSET: &str = "UTC%#z"; pub const ZULU_OFFSET: &str = "Z%#z"; } /// Parses a time string and returns a `DateTime` representing the /// absolute time of the string. /// /// # Arguments /// /// * `s` - A string slice representing the time. /// /// # Examples /// /// ``` /// use chrono::{DateTime, Utc, TimeZone}; /// let time = parse_datetime::parse_datetime("2023-06-03 12:00:01Z"); /// assert_eq!(time.unwrap(), Utc.with_ymd_and_hms(2023, 06, 03, 12, 00, 01).unwrap()); /// ``` /// /// /// # Returns /// /// * `Ok(DateTime)` - If the input string can be parsed as a time /// * `Err(ParseDateTimeError)` - If the input string cannot be parsed as a relative time /// /// # Errors /// /// This function will return `Err(ParseDateTimeError::InvalidInput)` if the input string /// cannot be parsed as a relative time. pub fn parse_datetime + Clone>( s: S, ) -> Result, ParseDateTimeError> { parse_datetime_at_date(Local::now(), s) } /// Parses a time string at a specific date and returns a `DateTime` representing the /// absolute time of the string. /// /// # Arguments /// /// * date - The date represented in local time /// * `s` - A string slice representing the time. /// /// # Examples /// /// ``` /// use chrono::{Duration, Local}; /// use parse_datetime::parse_datetime_at_date; /// /// let now = Local::now(); /// let after = parse_datetime_at_date(now, "+3 days"); /// /// assert_eq!( /// (now + Duration::days(3)).naive_utc(), /// after.unwrap().naive_utc() /// ); /// ``` /// /// # Returns /// /// * `Ok(DateTime)` - If the input string can be parsed as a time /// * `Err(ParseDateTimeError)` - If the input string cannot be parsed as a relative time /// /// # Errors /// /// This function will return `Err(ParseDateTimeError::InvalidInput)` if the input string /// cannot be parsed as a relative time. pub fn parse_datetime_at_date + Clone>( date: DateTime, s: S, ) -> Result, ParseDateTimeError> { // 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); } } // 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); } } } // Parse epoch seconds if s.as_ref().bytes().next() == Some(b'@') { if let Ok(parsed) = NaiveDateTime::parse_from_str(&s.as_ref()[1..], "%s") { if let Ok(dt) = naive_dt_to_fixed_offset(date, parsed) { return Ok(dt); } } } 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); } } } // Parse offsets. chrono doesn't provide any functionality to parse // 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); } } // Default parse and failure s.as_ref() .parse() .map_err(|_| (ParseDateTimeError::InvalidInput)) } // Convert NaiveDateTime to DateTime by assuming the offset // is local time fn naive_dt_to_fixed_offset( local: DateTime, dt: NaiveDateTime, ) -> Result, ()> { match local.offset().from_local_datetime(&dt) { LocalResult::Single(dt) => Ok(dt), _ => Err(()), } } #[cfg(test)] mod tests { static TEST_TIME: i64 = 1613371067; #[cfg(test)] mod iso_8601 { use std::env; use crate::ParseDateTimeError; use crate::{parse_datetime, tests::TEST_TIME}; #[test] fn test_t_sep() { env::set_var("TZ", "UTC"); let dt = "2021-02-15T06:37:47"; let actual = parse_datetime(dt); assert_eq!(actual.unwrap().timestamp(), TEST_TIME); } #[test] fn test_space_sep() { env::set_var("TZ", "UTC"); let dt = "2021-02-15 06:37:47"; let actual = parse_datetime(dt); assert_eq!(actual.unwrap().timestamp(), TEST_TIME); } #[test] fn test_space_sep_offset() { env::set_var("TZ", "UTC"); let dt = "2021-02-14 22:37:47 -0800"; let actual = parse_datetime(dt); assert_eq!(actual.unwrap().timestamp(), TEST_TIME); } #[test] fn test_t_sep_offset() { env::set_var("TZ", "UTC"); let dt = "2021-02-14T22:37:47 -0800"; 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"]; for dt in invalid_dts { assert_eq!(parse_datetime(dt), Err(ParseDateTimeError::InvalidInput)); } } #[test] fn test_epoch_seconds() { env::set_var("TZ", "UTC"); let dt = "@1613371067"; let actual = parse_datetime(dt); assert_eq!(actual.unwrap().timestamp(), TEST_TIME); } } #[cfg(test)] mod offsets { use chrono::Local; use crate::parse_datetime; use crate::ParseDateTimeError; #[test] fn test_positive_offsets() { let offsets = vec![ "UTC+07:00", "UTC+0700", "UTC+07", "Z+07:00", "Z+0700", "Z+07", ]; let expected = format!("{}{}", Local::now().format("%Y%m%d"), "0000+0700"); for offset in offsets { let actual = parse_datetime(offset).unwrap(); assert_eq!(expected, format!("{}", actual.format("%Y%m%d%H%M%z"))); } } #[test] fn test_partial_offset() { let offsets = vec!["UTC+00:15", "UTC+0015", "Z+00:15", "Z+0015"]; let expected = format!("{}{}", Local::now().format("%Y%m%d"), "0000+0015"); for offset in offsets { let actual = parse_datetime(offset).unwrap(); assert_eq!(expected, format!("{}", actual.format("%Y%m%d%H%M%z"))); } } #[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) ); } } } #[cfg(test)] mod relative_time { use crate::parse_datetime; #[test] fn test_positive_offsets() { let relative_times = vec![ "today", "yesterday", "1 minute", "3 hours", "1 year 3 months", ]; for relative_time in relative_times { assert_eq!(parse_datetime(relative_time).is_ok(), true); } } } #[cfg(test)] mod timestamp { use crate::parse_datetime; use chrono::{TimeZone, Utc}; #[test] fn test_positive_offsets() { let offsets: Vec = vec![ 0, 1, 2, 10, 100, 150, 2000, 1234400000, 1334400000, 1692582913, 2092582910, ]; for offset in offsets { let time = Utc.timestamp_opt(offset, 0).unwrap(); let dt = parse_datetime(format!("@{}", offset)); assert_eq!(dt.unwrap(), time); } } } /// Used to test example code presented in the README. mod readme_test { use crate::parse_datetime; use chrono::{Local, TimeZone}; #[test] fn test_readme_code() { let dt = parse_datetime("2021-02-14 06:37:47"); assert_eq!( dt.unwrap(), Local.with_ymd_and_hms(2021, 2, 14, 6, 37, 47).unwrap() ); } } mod invalid_test { use crate::parse_datetime; use crate::ParseDateTimeError; #[test] fn test_invalid_input() { let result = parse_datetime("foobar"); println!("{result:?}"); assert_eq!(result, Err(ParseDateTimeError::InvalidInput)); let result = parse_datetime("invalid 1"); assert_eq!(result, Err(ParseDateTimeError::InvalidInput)); } } } parse_datetime-0.5.0/src/parse_relative_time.rs000064400000000000000000000456411046102023000200060ustar 00000000000000use crate::ParseDateTimeError; use chrono::{Duration, Local, NaiveDate, Utc}; use regex::Regex; /// Parses a relative time string and returns a `Duration` representing the /// relative time. ///Regex /// # Arguments /// /// * `s` - A string slice representing the relative time. /// /// /// # Supported formats /// /// The function supports the following formats for relative time: /// /// * `num` `unit` (e.g., "-1 hour", "+3 days") /// * `unit` (e.g., "hour", "day") /// * "now" or "today" /// * "yesterday" /// * "tomorrow" /// * use "ago" for the past /// /// `[num]` can be a positive or negative integer. /// [unit] can be one of the following: "fortnight", "week", "day", "hour", /// "minute", "min", "second", "sec" and their plural forms. /// /// It is also possible to pass "1 hour 2 minutes" or "2 days and 2 hours" /// /// # Returns /// /// * `Ok(Duration)` - If the input string can be parsed as a relative time /// * `Err(ParseDateTimeError)` - If the input string cannot be parsed as a 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(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, s: &str, ) -> Result { 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) (\s*(?Pand|,)?\s*)? (\s*(?Pago)?)?", )?; let mut total_duration = Duration::seconds(0); let mut is_ago = s.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 .name("value") .ok_or(ParseDateTimeError::InvalidInput)? .as_str(); let value = if value_str.is_empty() { 1 } else { value_str .parse::() .map_err(|_| ParseDateTimeError::InvalidInput)? }; if let Some(direction) = capture.name("direction") { if direction.as_str() == "last" { is_ago = true; } } let unit = capture .name("unit") .ok_or(ParseDateTimeError::InvalidInput)? .as_str(); if capture.name("ago").is_some() { 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 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) { total_length += m.end() - m.start(); } } // Check if the entire input string has been captured if total_length != s.len() { return Err(ParseDateTimeError::InvalidInput); } if captures_processed == 0 { Err(ParseDateTimeError::InvalidInput) } else { let time_now = Local::now().date_naive(); let date_duration = date - time_now; Ok(total_duration + date_duration) } } #[cfg(test)] mod tests { use super::ParseDateTimeError; use super::{parse_relative_time, parse_relative_time_at_date}; use chrono::{Duration, Local, NaiveDate, Utc}; #[test] fn test_years() { assert_eq!( parse_relative_time("1 year").unwrap(), Duration::seconds(31_536_000) ); assert_eq!( parse_relative_time("-2 years").unwrap(), Duration::seconds(-63_072_000) ); assert_eq!( parse_relative_time("2 years ago").unwrap(), Duration::seconds(-63_072_000) ); assert_eq!( parse_relative_time("year").unwrap(), Duration::seconds(31_536_000) ); } #[test] fn test_months() { assert_eq!( parse_relative_time("1 month").unwrap(), Duration::seconds(2_592_000) ); assert_eq!( parse_relative_time("1 month and 2 weeks").unwrap(), Duration::seconds(3_801_600) ); assert_eq!( parse_relative_time("1 month and 2 weeks ago").unwrap(), Duration::seconds(-3_801_600) ); assert_eq!( parse_relative_time("2 months").unwrap(), Duration::seconds(5_184_000) ); assert_eq!( parse_relative_time("month").unwrap(), Duration::seconds(2_592_000) ); } #[test] fn test_fortnights() { assert_eq!( parse_relative_time("1 fortnight").unwrap(), Duration::seconds(1_209_600) ); assert_eq!( parse_relative_time("3 fortnights").unwrap(), Duration::seconds(3_628_800) ); assert_eq!( parse_relative_time("fortnight").unwrap(), Duration::seconds(1_209_600) ); } #[test] fn test_weeks() { assert_eq!( parse_relative_time("1 week").unwrap(), Duration::seconds(604_800) ); assert_eq!( parse_relative_time("1 week 3 days").unwrap(), Duration::seconds(864_000) ); assert_eq!( parse_relative_time("1 week 3 days ago").unwrap(), Duration::seconds(-864_000) ); assert_eq!( parse_relative_time("-2 weeks").unwrap(), Duration::seconds(-1_209_600) ); assert_eq!( parse_relative_time("2 weeks ago").unwrap(), Duration::seconds(-1_209_600) ); assert_eq!( parse_relative_time("week").unwrap(), Duration::seconds(604_800) ); } #[test] fn test_days() { assert_eq!( parse_relative_time("1 day").unwrap(), Duration::seconds(86400) ); assert_eq!( parse_relative_time("2 days ago").unwrap(), Duration::seconds(-172_800) ); assert_eq!( parse_relative_time("-2 days").unwrap(), Duration::seconds(-172_800) ); assert_eq!( parse_relative_time("day").unwrap(), Duration::seconds(86400) ); } #[test] fn test_hours() { assert_eq!( parse_relative_time("1 hour").unwrap(), Duration::seconds(3600) ); assert_eq!( parse_relative_time("1 hour ago").unwrap(), Duration::seconds(-3600) ); assert_eq!( parse_relative_time("-2 hours").unwrap(), Duration::seconds(-7200) ); assert_eq!( parse_relative_time("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)); } #[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)); } #[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_relative_time("yesterday").unwrap(), Duration::seconds(-86400) ); assert_eq!( parse_relative_time("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)); assert_eq!( parse_relative_time("2weeks 1hour").unwrap(), Duration::seconds(1_213_200) ); assert_eq!( parse_relative_time("2weeks 1hour ago").unwrap(), Duration::seconds(-1_213_200) ); assert_eq!( parse_relative_time("+4months").unwrap(), Duration::days(4 * 30) ); assert_eq!( parse_relative_time("-2years").unwrap(), Duration::days(-2 * 365) ); assert_eq!( parse_relative_time("15minutes").unwrap(), Duration::minutes(15) ); assert_eq!( parse_relative_time("-30seconds").unwrap(), Duration::seconds(-30) ); assert_eq!( parse_relative_time("30seconds ago").unwrap(), Duration::seconds(-30) ); } #[test] fn test_invalid_input() { let result = parse_relative_time("foobar"); println!("{result:?}"); assert_eq!(result, Err(ParseDateTimeError::InvalidInput)); let result = parse_relative_time("invalid 1"); assert_eq!(result, Err(ParseDateTimeError::InvalidInput)); // Fails for now with a panic /* let result = parse_relative_time("777777777777777771m"); match result { Err(ParseDateTimeError::InvalidInput) => assert!(true), _ => assert!(false), }*/ } #[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(); assert_eq!( parse_relative_time_at_date(date, "1 day").unwrap(), Duration::days(days_diff + 1) ); assert_eq!( parse_relative_time_at_date(date, "2 hours").unwrap(), Duration::days(days_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() { assert_eq!( parse_relative_time("last hour").unwrap(), Duration::seconds(-3600) ); assert_eq!( parse_relative_time("next year").unwrap(), Duration::days(365) ); assert_eq!(parse_relative_time("next week").unwrap(), Duration::days(7)); assert_eq!( parse_relative_time("last month").unwrap(), Duration::days(-30) ); } #[test] fn test_duration_parsing() { assert_eq!( parse_relative_time("1 year").unwrap(), Duration::seconds(31_536_000) ); assert_eq!( parse_relative_time("-2 years").unwrap(), Duration::seconds(-63_072_000) ); assert_eq!( parse_relative_time("2 years ago").unwrap(), Duration::seconds(-63_072_000) ); assert_eq!( parse_relative_time("year").unwrap(), Duration::seconds(31_536_000) ); assert_eq!( parse_relative_time("1 month").unwrap(), Duration::seconds(2_592_000) ); assert_eq!( parse_relative_time("1 month and 2 weeks").unwrap(), Duration::seconds(3_801_600) ); assert_eq!( parse_relative_time("1 month, 2 weeks").unwrap(), Duration::seconds(3_801_600) ); assert_eq!( parse_relative_time("1 months 2 weeks").unwrap(), Duration::seconds(3_801_600) ); assert_eq!( parse_relative_time("1 month and 2 weeks ago").unwrap(), Duration::seconds(-3_801_600) ); assert_eq!( parse_relative_time("2 months").unwrap(), Duration::seconds(5_184_000) ); assert_eq!( parse_relative_time("month").unwrap(), Duration::seconds(2_592_000) ); assert_eq!( parse_relative_time("1 fortnight").unwrap(), Duration::seconds(1_209_600) ); assert_eq!( parse_relative_time("3 fortnights").unwrap(), Duration::seconds(3_628_800) ); assert_eq!( parse_relative_time("fortnight").unwrap(), Duration::seconds(1_209_600) ); assert_eq!( parse_relative_time("1 week").unwrap(), Duration::seconds(604_800) ); assert_eq!( parse_relative_time("1 week 3 days").unwrap(), Duration::seconds(864_000) ); assert_eq!( parse_relative_time("1 week 3 days ago").unwrap(), Duration::seconds(-864_000) ); assert_eq!( parse_relative_time("-2 weeks").unwrap(), Duration::seconds(-1_209_600) ); assert_eq!( parse_relative_time("2 weeks ago").unwrap(), Duration::seconds(-1_209_600) ); assert_eq!( parse_relative_time("week").unwrap(), Duration::seconds(604_800) ); assert_eq!( parse_relative_time("1 day").unwrap(), Duration::seconds(86_400) ); assert_eq!( parse_relative_time("2 days ago").unwrap(), Duration::seconds(-172_800) ); assert_eq!( parse_relative_time("-2 days").unwrap(), Duration::seconds(-172_800) ); assert_eq!( parse_relative_time("day").unwrap(), Duration::seconds(86_400) ); assert_eq!( parse_relative_time("1 hour").unwrap(), Duration::seconds(3_600) ); assert_eq!( parse_relative_time("1 h").unwrap(), Duration::seconds(3_600) ); assert_eq!( parse_relative_time("1 hour ago").unwrap(), Duration::seconds(-3_600) ); assert_eq!( parse_relative_time("-2 hours").unwrap(), Duration::seconds(-7_200) ); assert_eq!( parse_relative_time("hour").unwrap(), Duration::seconds(3_600) ); assert_eq!( parse_relative_time("1 minute").unwrap(), Duration::seconds(60) ); assert_eq!(parse_relative_time("1 min").unwrap(), Duration::seconds(60)); assert_eq!( parse_relative_time("2 minutes").unwrap(), Duration::seconds(120) ); assert_eq!( parse_relative_time("2 mins").unwrap(), Duration::seconds(120) ); 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) ); assert_eq!(parse_relative_time("1 s").unwrap(), Duration::seconds(1)); assert_eq!( parse_relative_time("2 seconds").unwrap(), Duration::seconds(2) ); 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) ); assert_eq!( parse_relative_time("1 year 2 months 4 weeks 3 days and 2 seconds ago").unwrap(), Duration::seconds(-39_398_402) ); } #[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(); } #[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(); assert_eq!( format!("{error}"), "Invalid input string: cannot be parsed as a relative time" ); } #[test] fn test_parse_relative_time_at_date_day() { let today = Utc::now().date_naive(); let yesterday = today - Duration::days(1); assert_eq!( parse_relative_time_at_date(yesterday, "2 days").unwrap(), Duration::days(1) ); } #[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)); let result = parse_relative_time_at_date(today, "invalid 1r"); assert_eq!(result, Err(ParseDateTimeError::InvalidInput)); } } parse_datetime-0.5.0/tests/simple.rs000064400000000000000000000000011046102023000156040ustar 00000000000000