diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bece31..3b663b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: matrix: os: [ubuntu-latest, macOS-latest, windows-latest] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions-rs/toolchain@v1 with: profile: minimal @@ -30,7 +30,7 @@ jobs: matrix: os: [ubuntu-latest, macOS-latest, windows-latest] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions-rs/toolchain@v1 with: profile: minimal @@ -44,7 +44,7 @@ jobs: name: cargo fmt --all -- --check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions-rs/toolchain@v1 with: profile: minimal @@ -63,7 +63,7 @@ jobs: matrix: os: [ubuntu-latest, macOS-latest, windows-latest] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions-rs/toolchain@v1 with: profile: minimal @@ -86,7 +86,7 @@ jobs: - { os: macos-latest , features: macos } - { os: windows-latest , features: windows } steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Initialize workflow variables id: vars shell: bash @@ -155,7 +155,7 @@ jobs: 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@v3 + uses: codecov/codecov-action@v4 # if: steps.vars.outputs.HAS_CODECOV_TOKEN with: # token: ${{ secrets.CODECOV_TOKEN }} @@ -171,7 +171,7 @@ jobs: env: RUN_FOR: 60 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@nightly - name: Install `cargo-fuzz` run: cargo install cargo-fuzz @@ -181,10 +181,4 @@ jobs: run: | ## Run it cd fuzz - cargo +nightly fuzz run fuzz_from_str -- -max_total_time=${{ env.RUN_FOR }} -detect_leaks=0 - - name: Run fuzz_parse_datetime_from_str for XX seconds - shell: bash - run: | - ## Run it - cd fuzz - cargo +nightly fuzz run fuzz_parse_datetime_from_str -- -max_total_time=${{ env.RUN_FOR }} -detect_leaks=0 + cargo +nightly fuzz run fuzz_parse_datetime -- -max_total_time=${{ env.RUN_FOR }} -detect_leaks=0 diff --git a/Cargo.lock b/Cargo.lock index 8cd1830..6af6ced 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -135,7 +135,7 @@ checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" [[package]] name = "parse_datetime" -version = "0.4.0" +version = "0.5.0" dependencies = [ "chrono", "regex", @@ -161,9 +161,21 @@ dependencies = [ [[package]] name = "regex" -version = "1.8.1" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2eae68fc220f7cf2532e4494aded17545fce192d59cd996e0fe7887f4ceb575" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af83e617f331cc6ae2da5443c602dfa5af81e517212d9d611a5b3ba1777b5370" +checksum = "e9aaecc05d5c4b5f7da074b9a0d1a0867e71fd36e7fc0482d8bcfe8e8fc56290" dependencies = [ "aho-corasick", "memchr", @@ -172,9 +184,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.7.1" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5996294f19bd3aae0453a862ad728f60e6600695733dd5df01da90c54363a3c" +checksum = "2ab07dc67230e4a4718e70fd5c20055a4334b121f1f9db8fe63ef39ce9b8c846" [[package]] name = "syn" diff --git a/Cargo.toml b/Cargo.toml index d571e37..b28ba6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "parse_datetime" -description = " parsing human-readable relative time strings and converting them to a Duration" -version = "0.4.0" +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.8" +regex = "1.9" chrono = { version="0.4", default-features=false, features=["std", "alloc", "clock"] } diff --git a/README.md b/README.md index 9cab9aa..31a1147 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![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 converting them to a `Duration`, or parsing human-readable datetime strings and converting them to a `DateTime`. +A Rust crate for parsing human-readable relative time strings and human-readable datetime strings and converting them to a `DateTime`. ## Features @@ -23,23 +23,23 @@ Add this to your `Cargo.toml`: parse_datetime = "0.4.0" ``` -Then, import the crate and use the `from_str` and `from_str_at_date` functions: +Then, import the crate and use the `parse_datetime_at_date` function: + ```rs -use parse_datetime::{from_str, from_str_at_date}; -use chrono::Duration; +use chrono::{Duration, Local}; +use parse_datetime::parse_datetime_at_date; -let duration = from_str("+3 days"); -assert_eq!(duration.unwrap(), Duration::days(3)); +let now = Local::now(); +let after = parse_datetime_at_date(now, "+3 days"); -let today = Utc::today().naive_utc(); -let yesterday = today - Duration::days(1); assert_eq!( - from_str_at_date(yesterday, "2 days").unwrap(), - Duration::days(1) + (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}; @@ -50,7 +50,7 @@ assert_eq!(dt.unwrap(), Local.with_ymd_and_hms(2021, 2, 14, 6, 37, 47).unwrap()) ### Supported Formats -The `from_str` and `from_str_at_date` functions support the following formats for relative time: +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") @@ -58,35 +58,30 @@ The `from_str` and `from_str_at_date` functions support the following formats fo - "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 -### Duration - -The `from_str` and `from_str_at_date` functions return: - -- `Ok(Duration)` - If the input string can be parsed as a relative time -- `Err(ParseDurationError)` - If the input string cannot be parsed as a relative time +### parse_datetime and parse_datetime_at_date -This function will return `Err(ParseDurationError::InvalidInput)` if the input string -cannot be parsed as a relative time. +The `parse_datetime` and `parse_datetime_at_date` function return: -### parse_datetime - -The `from_str` function returns: - -- `Ok(DateTime)` - If the input string can be prsed as a datetime -- `Err(ParseDurationError::InvalidInput)` - If the input string cannot be parsed +- `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: + ``` -$ cargo fuzz run fuzz_from_str +$ cd fuzz +$ cargo install cargo-fuzz +$ cargo +nightly fuzz run fuzz_parse_datetime ``` ## License diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 0000000..a4aa077 --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1 @@ +corpus diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 9ab97bf..5a7c0fd 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -67,10 +67,7 @@ checksum = "ec837a71355b28f6556dbd569b37b3f363091c0bd4b2e735674521b4c5fd9bc5" dependencies = [ "android-tzdata", "iana-time-zone", - "js-sys", "num-traits", - "time", - "wasm-bindgen", "winapi", ] @@ -81,8 +78,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" [[package]] -name = "fuzz_from_str" -version = "0.1.0" +name = "fuzz_parse_datetime" +version = "0.2.0" dependencies = [ "chrono", "libfuzzer-sys", @@ -99,7 +96,7 @@ checksum = "c85e1d9ab2eadba7e5040d4e09cbd6d072b76a557ad64e797c2cb9d4da21d7e4" dependencies = [ "cfg-if", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", ] [[package]] @@ -168,9 +165,9 @@ checksum = "518ef76f2f87365916b142844c16d8fefd85039bc5699050210a7778ee1cd1de" [[package]] name = "memchr" -version = "2.5.0" +version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" +checksum = "5486aed0026218e61b8a01d5fbd5a0a134649abb71a0e53b7bc088529dced86e" [[package]] name = "num-traits" @@ -189,7 +186,7 @@ checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" [[package]] name = "parse_datetime" -version = "0.4.0" +version = "0.5.0" dependencies = [ "chrono", "regex", @@ -251,9 +248,21 @@ dependencies = [ [[package]] name = "regex" -version = "1.8.4" +version = "1.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "697061221ea1b4a94a624f67d0ae2bfe4e22b8a17b6a192afb11046542cc8c47" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0ab3ca65655bb1e41f2a8c8cd662eb4fb035e67c3f78da1d61dffe89d07300f" +checksum = "c2f401f4955220693b56f8ec66ee9c78abffd8d1c4f23dc41a23839eb88f0795" dependencies = [ "aho-corasick", "memchr", @@ -262,9 +271,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.7.2" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436b050e76ed2903236f032a59761c1eb99e1b0aead2c257922771dab1fc8c78" +checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" [[package]] name = "syn" @@ -277,29 +286,12 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "time" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a" -dependencies = [ - "libc", - "wasi 0.10.0+wasi-snapshot-preview1", - "winapi", -] - [[package]] name = "unicode-ident" version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15811caf2415fb889178633e7724bad2509101cde276048e013b9def5e51fa0" -[[package]] -name = "wasi" -version = "0.10.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" - [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 7b705b2..b5a0de8 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -1,7 +1,7 @@ [package] -name = "fuzz_from_str" -version = "0.1.0" -edition = "2018" +name = "fuzz_parse_datetime" +version = "0.2.0" +edition = "2021" [package.metadata] cargo-fuzz = true @@ -9,20 +9,14 @@ cargo-fuzz = true [dependencies] rand = "0.8.5" libfuzzer-sys = "0.4" -regex = "1.8.4" -chrono = "0.4" +regex = "1.9.5" +chrono = { version="0.4", default-features=false, features=["std", "alloc", "clock"] } [dependencies.parse_datetime] path = "../" [[bin]] -name = "fuzz_from_str" -path = "fuzz_targets/from_str.rs" -test = false -doc = false - -[[bin]] -name = "fuzz_parse_datetime_from_str" -path = "fuzz_targets/parse_datetime_from_str.rs" +name = "fuzz_parse_datetime" +path = "fuzz_targets/parse_datetime.rs" test = false doc = false diff --git a/fuzz/fuzz_targets/from_str.rs b/fuzz/fuzz_targets/parse_datetime.rs similarity index 73% rename from fuzz/fuzz_targets/from_str.rs rename to fuzz/fuzz_targets/parse_datetime.rs index 63b55d1..289bbb3 100644 --- a/fuzz/fuzz_targets/from_str.rs +++ b/fuzz/fuzz_targets/parse_datetime.rs @@ -4,5 +4,5 @@ use libfuzzer_sys::fuzz_target; fuzz_target!(|data: &[u8]| { let s = std::str::from_utf8(data).unwrap_or(""); - let _ = parse_datetime::from_str(s); + let _ = parse_datetime::parse_datetime(s); }); diff --git a/fuzz/fuzz_targets/parse_datetime_from_str.rs b/fuzz/fuzz_targets/parse_datetime_from_str.rs deleted file mode 100644 index 7d285e5..0000000 --- a/fuzz/fuzz_targets/parse_datetime_from_str.rs +++ /dev/null @@ -1,8 +0,0 @@ -#![no_main] - -use libfuzzer_sys::fuzz_target; - -fuzz_target!(|data: &[u8]| { - let s = std::str::from_utf8(data).unwrap_or(""); - let _ = parse_datetime::parse_datetime::from_str(s); -}); diff --git a/src/lib.rs b/src/lib.rs index 8a104da..90e191b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,27 +1,37 @@ // 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 -pub mod parse_datetime; +mod parse_relative_time; -use chrono::{Duration, Local, NaiveDate, Utc}; -use regex::{Error as RegexError, Regex}; -use std::error::Error; -use std::fmt::{self, Display}; +use chrono::{DateTime, FixedOffset, Local, LocalResult, NaiveDateTime, TimeZone}; + +use parse_relative_time::parse_relative_time; #[derive(Debug, PartialEq)] -pub enum ParseDurationError { +pub enum ParseDateTimeError { InvalidRegex(RegexError), InvalidInput, } -impl Display for ParseDurationError { +impl Display for ParseDateTimeError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - ParseDurationError::InvalidRegex(err) => { + Self::InvalidRegex(err) => { write!(f, "Invalid regex for time pattern: {err}") } - ParseDurationError::InvalidInput => { + Self::InvalidInput => { write!( f, "Invalid input string: cannot be parsed as a relative time" @@ -31,339 +41,364 @@ impl Display for ParseDurationError { } } -impl Error for ParseDurationError {} +impl Error for ParseDateTimeError {} -impl From for ParseDurationError { +impl From for ParseDateTimeError { fn from(err: RegexError) -> Self { - ParseDurationError::InvalidRegex(err) + Self::InvalidRegex(err) } } -/// Parses a relative time string and returns a `Duration` representing the -/// relative time. +/// 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 relative time. +/// * `s` - A string slice representing the time. /// /// # Examples /// /// ``` -/// use chrono::Duration; -/// let duration = parse_datetime::from_str("+3 days"); -/// assert_eq!(duration.unwrap(), Duration::days(3)); +/// 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()); /// ``` /// -/// # 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(ParseDurationError)` - If the input string cannot be parsed as a relative time +/// * `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(ParseDurationError::InvalidInput)` if the input string +/// 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; -/// use parse_datetime::{from_str, ParseDurationError}; +/// use chrono::{Duration, Local}; +/// use parse_datetime::parse_datetime_at_date; /// -/// assert_eq!(from_str("1 hour, 30 minutes").unwrap(), Duration::minutes(90)); -/// assert_eq!(from_str("tomorrow").unwrap(), Duration::days(1)); -/// assert!(matches!(from_str("invalid"), Err(ParseDurationError::InvalidInput))); +/// 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() +/// ); /// ``` -pub fn from_str(s: &str) -> Result { - from_str_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 +/// # Returns /// -/// * `date` - A `Date` instance representing the base date for the calculation -/// * `s` - A string slice representing the relative time. +/// * `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(ParseDurationError::InvalidInput)` if the input string +/// This function will return `Err(ParseDateTimeError::InvalidInput)` if the input string /// cannot be parsed as a relative time. -/// -/// # Examples -/// -/// ``` -/// use chrono::{Duration, NaiveDate, Utc, Local}; -/// use parse_datetime::{from_str_at_date, ParseDurationError}; -/// let today = Local::now().date().naive_local(); -/// let yesterday = today - Duration::days(1); -/// assert_eq!( -/// from_str_at_date(yesterday, "2 days").unwrap(), -/// Duration::days(1) // 1 day from the specified date + 1 day from the input string -/// ); -/// ``` -pub fn from_str_at_date(date: NaiveDate, s: &str) -> Result { - let time_pattern: Regex = Regex::new( - r"(?x) - (?:(?P[-+]?\d*)\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(ParseDurationError::InvalidInput)? - .as_str(); - let value = if value_str.is_empty() { - 1 - } else { - value_str - .parse::() - .map_err(|_| ParseDurationError::InvalidInput)? - }; - let unit = capture - .name("unit") - .ok_or(ParseDurationError::InvalidInput)? - .as_str(); - - if capture.name("ago").is_some() { - is_ago = true; +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); + } } + } - 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(ParseDurationError::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(ParseDurationError::InvalidInput), - }; - - // Calculate the total length of the matched substring - if let Some(m) = capture.get(0) { - total_length += m.end() - m.start(); + // 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); } } - // Check if the entire input string has been captured - if total_length != s.len() { - return Err(ParseDurationError::InvalidInput); + // 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); + } } - if captures_processed == 0 { - Err(ParseDurationError::InvalidInput) - } else { - let time_now = Local::now().date_naive(); - let date_duration = date - time_now; + // Default parse and failure + s.as_ref() + .parse() + .map_err(|_| (ParseDateTimeError::InvalidInput)) +} - Ok(total_duration + date_duration) +// 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; - use super::ParseDurationError; - use super::{from_str, from_str_at_date}; - use chrono::{Duration, Local, NaiveDate}; - - #[test] - fn test_years() { - assert_eq!(from_str("1 year").unwrap(), Duration::seconds(31_536_000)); - assert_eq!( - from_str("-2 years").unwrap(), - Duration::seconds(-63_072_000) - ); - assert_eq!( - from_str("2 years ago").unwrap(), - Duration::seconds(-63_072_000) - ); - assert_eq!(from_str("year").unwrap(), Duration::seconds(31_536_000)); - } + #[cfg(test)] + mod iso_8601 { + use std::env; - #[test] - fn test_months() { - assert_eq!(from_str("1 month").unwrap(), Duration::seconds(2_592_000)); - assert_eq!( - from_str("1 month and 2 weeks").unwrap(), - Duration::seconds(3_801_600) - ); - assert_eq!( - from_str("1 month and 2 weeks ago").unwrap(), - Duration::seconds(-3_801_600) - ); - assert_eq!(from_str("2 months").unwrap(), Duration::seconds(5_184_000)); - assert_eq!(from_str("month").unwrap(), Duration::seconds(2_592_000)); - } + use crate::ParseDateTimeError; + use crate::{parse_datetime, tests::TEST_TIME}; - #[test] - fn test_fortnights() { - assert_eq!( - from_str("1 fortnight").unwrap(), - Duration::seconds(1_209_600) - ); - assert_eq!( - from_str("3 fortnights").unwrap(), - Duration::seconds(3_628_800) - ); - assert_eq!(from_str("fortnight").unwrap(), Duration::seconds(1_209_600)); - } + #[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_weeks() { - assert_eq!(from_str("1 week").unwrap(), Duration::seconds(604_800)); - assert_eq!( - from_str("1 week 3 days").unwrap(), - Duration::seconds(864_000) - ); - assert_eq!( - from_str("1 week 3 days ago").unwrap(), - Duration::seconds(-864_000) - ); - assert_eq!(from_str("-2 weeks").unwrap(), Duration::seconds(-1_209_600)); - assert_eq!( - from_str("2 weeks ago").unwrap(), - Duration::seconds(-1_209_600) - ); - assert_eq!(from_str("week").unwrap(), Duration::seconds(604_800)); - } + #[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_days() { - assert_eq!(from_str("1 day").unwrap(), Duration::seconds(86400)); - assert_eq!(from_str("2 days ago").unwrap(), Duration::seconds(-172_800)); - assert_eq!(from_str("-2 days").unwrap(), Duration::seconds(-172_800)); - assert_eq!(from_str("day").unwrap(), Duration::seconds(86400)); - } + #[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_hours() { - assert_eq!(from_str("1 hour").unwrap(), Duration::seconds(3600)); - assert_eq!(from_str("1 hour ago").unwrap(), Duration::seconds(-3600)); - assert_eq!(from_str("-2 hours").unwrap(), Duration::seconds(-7200)); - assert_eq!(from_str("hour").unwrap(), Duration::seconds(3600)); - } + #[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 test_minutes() { - assert_eq!(from_str("1 minute").unwrap(), Duration::seconds(60)); - assert_eq!(from_str("2 minutes").unwrap(), Duration::seconds(120)); - assert_eq!(from_str("min").unwrap(), Duration::seconds(60)); - } + #[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_seconds() { - assert_eq!(from_str("1 second").unwrap(), Duration::seconds(1)); - assert_eq!(from_str("2 seconds").unwrap(), Duration::seconds(2)); - assert_eq!(from_str("sec").unwrap(), Duration::seconds(1)); + #[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); + } } - #[test] - fn test_relative_days() { - assert_eq!(from_str("now").unwrap(), Duration::seconds(0)); - assert_eq!(from_str("today").unwrap(), Duration::seconds(0)); - assert_eq!(from_str("yesterday").unwrap(), Duration::seconds(-86400)); - assert_eq!(from_str("tomorrow").unwrap(), Duration::seconds(86400)); + #[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) + ); + } + } } - #[test] - fn test_no_spaces() { - assert_eq!(from_str("-1hour").unwrap(), Duration::hours(-1)); - assert_eq!(from_str("+3days").unwrap(), Duration::days(3)); - assert_eq!(from_str("2weeks").unwrap(), Duration::weeks(2)); - assert_eq!( - from_str("2weeks 1hour").unwrap(), - Duration::seconds(1_213_200) - ); - assert_eq!( - from_str("2weeks 1hour ago").unwrap(), - Duration::seconds(-1_213_200) - ); - assert_eq!(from_str("+4months").unwrap(), Duration::days(4 * 30)); - assert_eq!(from_str("-2years").unwrap(), Duration::days(-2 * 365)); - assert_eq!(from_str("15minutes").unwrap(), Duration::minutes(15)); - assert_eq!(from_str("-30seconds").unwrap(), Duration::seconds(-30)); - assert_eq!(from_str("30seconds ago").unwrap(), Duration::seconds(-30)); + #[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); + } + } } - #[test] - fn test_invalid_input() { - let result = from_str("foobar"); - println!("{result:?}"); - assert_eq!(result, Err(ParseDurationError::InvalidInput)); - - let result = from_str("invalid 1"); - assert_eq!(result, Err(ParseDurationError::InvalidInput)); - // Fails for now with a panic - /* let result = from_str("777777777777777771m"); - match result { - Err(ParseDurationError::InvalidInput) => assert!(true), - _ => assert!(false), - }*/ + #[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); + } + } } - #[test] - fn test_from_str_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!( - from_str_at_date(date, "1 day").unwrap(), - Duration::days(days_diff + 1) - ); - - assert_eq!( - from_str_at_date(date, "2 hours").unwrap(), - Duration::days(days_diff) + Duration::hours(2) - ); + /// 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() + ); + } } - #[test] - fn test_invalid_input_at_date() { - let date = NaiveDate::from_ymd_opt(2014, 9, 5).unwrap(); - assert!(matches!( - from_str_at_date(date, "invalid"), - Err(ParseDurationError::InvalidInput) - )); + 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)); + } } } diff --git a/src/parse_datetime.rs b/src/parse_datetime.rs deleted file mode 100644 index d6f5514..0000000 --- a/src/parse_datetime.rs +++ /dev/null @@ -1,252 +0,0 @@ -// For the full copyright and license information, please view the LICENSE -// file that was distributed with this source code. - -use chrono::{DateTime, FixedOffset, Local, LocalResult, NaiveDateTime, TimeZone}; - -use crate::ParseDurationError; - -/// Formats that parse input can take. -/// Taken from `touch` coreutils -mod format { - pub(crate) const ISO_8601: &str = "%Y-%m-%d"; - pub(crate) const ISO_8601_NO_SEP: &str = "%Y%m%d"; - pub(crate) const POSIX_LOCALE: &str = "%a %b %e %H:%M:%S %Y"; - pub(crate) const YYYYMMDDHHMM_DOT_SS: &str = "%Y%m%d%H%M.%S"; - pub(crate) const YYYYMMDDHHMMSS: &str = "%Y-%m-%d %H:%M:%S.%f"; - pub(crate) const YYYYMMDDHHMMS: &str = "%Y-%m-%d %H:%M:%S"; - pub(crate) const YYYY_MM_DD_HH_MM: &str = "%Y-%m-%d %H:%M"; - pub(crate) const YYYYMMDDHHMM: &str = "%Y%m%d%H%M"; - pub(crate) const YYYYMMDDHHMM_OFFSET: &str = "%Y%m%d%H%M %z"; - pub(crate) const YYYYMMDDHHMM_UTC_OFFSET: &str = "%Y%m%d%H%MUTC%z"; - pub(crate) const YYYYMMDDHHMM_ZULU_OFFSET: &str = "%Y%m%d%H%MZ%z"; - pub(crate) const YYYYMMDDHHMM_HYPHENATED_OFFSET: &str = "%Y-%m-%d %H:%M %z"; - pub(crate) const YYYYMMDDHHMMS_T_SEP: &str = "%Y-%m-%dT%H:%M:%S"; - pub(crate) const UTC_OFFSET: &str = "UTC%#z"; - pub(crate) const ZULU_OFFSET: &str = "Z%#z"; -} - -/// Loosely 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::from_str("2023-06-03 12:00:01Z"); -/// assert_eq!(time.unwrap(), Utc.with_ymd_and_hms(2023, 06, 03, 12, 00, 01).unwrap()); -/// ``` -/// -/// # Supported formats -/// -/// The function supports the following formats for time: -/// -/// * ISO formats -/// * timezone offsets, e.g., "UTC-0100" -/// -/// # Returns -/// -/// * `Ok(DateTime)` - If the input string can be parsed as a time -/// * `Err(ParseDurationError)` - If the input string cannot be parsed as a relative time -/// -/// # Errors -/// -/// This function will return `Err(ParseDurationError::InvalidInput)` if the input string -/// cannot be parsed as a relative time. -/// -pub fn from_str + Clone>(s: S) -> Result, ParseDurationError> { - // 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(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(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(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 local = Local::now(); - let ts = format!("{}", local.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); - } - } - - // Default parse and failure - s.as_ref() - .parse() - .map_err(|_| (ParseDurationError::InvalidInput)) -} - -// Convert NaiveDateTime to DateTime by assuming the offset -// is local time -fn naive_dt_to_fixed_offset(dt: NaiveDateTime) -> Result, ()> { - let now = Local::now(); - match now.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::{ - parse_datetime::from_str, parse_datetime::tests::TEST_TIME, ParseDurationError, - }; - - #[test] - fn test_t_sep() { - env::set_var("TZ", "UTC"); - let dt = "2021-02-15T06:37:47"; - let actual = from_str(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 = from_str(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 = from_str(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 = from_str(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!(from_str(dt), Err(ParseDurationError::InvalidInput)); - } - } - } - - #[cfg(test)] - mod offsets { - use chrono::Local; - - use crate::{parse_datetime::from_str, ParseDurationError}; - - #[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 = from_str(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 = from_str(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!(from_str(offset), Err(ParseDurationError::InvalidInput)); - } - } - } - - /// Used to test example code presented in the README. - mod readme_test { - use crate::parse_datetime::from_str; - use chrono::{Local, TimeZone}; - - #[test] - fn test_readme_code() { - 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() - ); - } - } -} diff --git a/src/parse_relative_time.rs b/src/parse_relative_time.rs new file mode 100644 index 0000000..afa47b5 --- /dev/null +++ b/src/parse_relative_time.rs @@ -0,0 +1,620 @@ +use 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)); + } +} diff --git a/tests/simple.rs b/tests/simple.rs index a538f9d..8b13789 100644 --- a/tests/simple.rs +++ b/tests/simple.rs @@ -1,148 +1 @@ -use chrono::{Duration, Utc}; -use parse_datetime::{from_str, from_str_at_date, ParseDurationError}; -#[test] -fn test_invalid_input() { - let result = from_str("foobar"); - println!("{result:?}"); - assert_eq!(result, Err(ParseDurationError::InvalidInput)); - - let result = from_str("invalid 1"); - assert_eq!(result, Err(ParseDurationError::InvalidInput)); -} - -#[test] -fn test_duration_parsing() { - assert_eq!(from_str("1 year").unwrap(), Duration::seconds(31_536_000)); - assert_eq!( - from_str("-2 years").unwrap(), - Duration::seconds(-63_072_000) - ); - assert_eq!( - from_str("2 years ago").unwrap(), - Duration::seconds(-63_072_000) - ); - assert_eq!(from_str("year").unwrap(), Duration::seconds(31_536_000)); - - assert_eq!(from_str("1 month").unwrap(), Duration::seconds(2_592_000)); - assert_eq!( - from_str("1 month and 2 weeks").unwrap(), - Duration::seconds(3_801_600) - ); - assert_eq!( - from_str("1 month, 2 weeks").unwrap(), - Duration::seconds(3_801_600) - ); - assert_eq!( - from_str("1 months 2 weeks").unwrap(), - Duration::seconds(3_801_600) - ); - assert_eq!( - from_str("1 month and 2 weeks ago").unwrap(), - Duration::seconds(-3_801_600) - ); - assert_eq!(from_str("2 months").unwrap(), Duration::seconds(5_184_000)); - assert_eq!(from_str("month").unwrap(), Duration::seconds(2_592_000)); - - assert_eq!( - from_str("1 fortnight").unwrap(), - Duration::seconds(1_209_600) - ); - assert_eq!( - from_str("3 fortnights").unwrap(), - Duration::seconds(3_628_800) - ); - assert_eq!(from_str("fortnight").unwrap(), Duration::seconds(1_209_600)); - - assert_eq!(from_str("1 week").unwrap(), Duration::seconds(604_800)); - assert_eq!( - from_str("1 week 3 days").unwrap(), - Duration::seconds(864_000) - ); - assert_eq!( - from_str("1 week 3 days ago").unwrap(), - Duration::seconds(-864_000) - ); - assert_eq!(from_str("-2 weeks").unwrap(), Duration::seconds(-1_209_600)); - assert_eq!( - from_str("2 weeks ago").unwrap(), - Duration::seconds(-1_209_600) - ); - assert_eq!(from_str("week").unwrap(), Duration::seconds(604_800)); - - assert_eq!(from_str("1 day").unwrap(), Duration::seconds(86_400)); - assert_eq!(from_str("2 days ago").unwrap(), Duration::seconds(-172_800)); - assert_eq!(from_str("-2 days").unwrap(), Duration::seconds(-172_800)); - assert_eq!(from_str("day").unwrap(), Duration::seconds(86_400)); - - assert_eq!(from_str("1 hour").unwrap(), Duration::seconds(3_600)); - assert_eq!(from_str("1 h").unwrap(), Duration::seconds(3_600)); - assert_eq!(from_str("1 hour ago").unwrap(), Duration::seconds(-3_600)); - assert_eq!(from_str("-2 hours").unwrap(), Duration::seconds(-7_200)); - assert_eq!(from_str("hour").unwrap(), Duration::seconds(3_600)); - - assert_eq!(from_str("1 minute").unwrap(), Duration::seconds(60)); - assert_eq!(from_str("1 min").unwrap(), Duration::seconds(60)); - assert_eq!(from_str("2 minutes").unwrap(), Duration::seconds(120)); - assert_eq!(from_str("2 mins").unwrap(), Duration::seconds(120)); - assert_eq!(from_str("2m").unwrap(), Duration::seconds(120)); - assert_eq!(from_str("min").unwrap(), Duration::seconds(60)); - - assert_eq!(from_str("1 second").unwrap(), Duration::seconds(1)); - assert_eq!(from_str("1 s").unwrap(), Duration::seconds(1)); - assert_eq!(from_str("2 seconds").unwrap(), Duration::seconds(2)); - assert_eq!(from_str("2 secs").unwrap(), Duration::seconds(2)); - assert_eq!(from_str("2 sec").unwrap(), Duration::seconds(2)); - assert_eq!(from_str("sec").unwrap(), Duration::seconds(1)); - - assert_eq!(from_str("now").unwrap(), Duration::seconds(0)); - assert_eq!(from_str("today").unwrap(), Duration::seconds(0)); - - assert_eq!( - from_str("1 year 2 months 4 weeks 3 days and 2 seconds").unwrap(), - Duration::seconds(39_398_402) - ); - assert_eq!( - from_str("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_from_str() { - let invalid_input = "9223372036854775807 seconds and 1 second"; - let _ = from_str(invalid_input).unwrap(); -} - -#[test] -fn test_display_should_fail() { - let invalid_input = "Thu Jan 01 12:34:00 2015"; - let error = from_str(invalid_input).unwrap_err(); - - assert_eq!( - format!("{error}"), - "Invalid input string: cannot be parsed as a relative time" - ); -} - -#[test] -fn test_from_str_at_date_day() { - let today = Utc::now().date_naive(); - let yesterday = today - Duration::days(1); - assert_eq!( - from_str_at_date(yesterday, "2 days").unwrap(), - Duration::days(1) - ); -} - -#[test] -fn test_invalid_input_at_date() { - let today = Utc::now().date_naive(); - let result = from_str_at_date(today, "foobar"); - println!("{result:?}"); - assert_eq!(result, Err(ParseDurationError::InvalidInput)); - - let result = from_str_at_date(today, "invalid 1r"); - assert_eq!(result, Err(ParseDurationError::InvalidInput)); -}