From f10749eadeb689dbf1b169ddb24ec8caec5c595f Mon Sep 17 00:00:00 2001 From: Patrick Klitzke Date: Wed, 23 Aug 2023 08:51:58 +0900 Subject: [PATCH] Refactor parse_datetime to only expose parse_datetime function Create new file parse_relative_time.rs with the relative time helper function. Renames from_str to parse_datetime and parse_relative time. Adds function parse_datetime_at_date. --- .github/workflows/ci.yml | 8 +- Cargo.toml | 2 +- README.md | 44 +- fuzz/.gitignore | 1 + fuzz/Cargo.toml | 12 +- .../{from_str.rs => parse_datetime.rs} | 2 +- fuzz/fuzz_targets/parse_datetime_from_str.rs | 8 - src/lib.rs | 599 +++++++++-------- src/parse_datetime.rs | 279 -------- src/parse_relative_time.rs | 620 ++++++++++++++++++ tests/simple.rs | 147 ----- 11 files changed, 955 insertions(+), 767 deletions(-) create mode 100644 fuzz/.gitignore rename fuzz/fuzz_targets/{from_str.rs => parse_datetime.rs} (73%) delete mode 100644 fuzz/fuzz_targets/parse_datetime_from_str.rs delete mode 100644 src/parse_datetime.rs create mode 100644 src/parse_relative_time.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bece31..b951cbc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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.toml b/Cargo.toml index 23f5f4f..9e9fd40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "parse_datetime" -description = " parsing human-readable relative time strings and converting them to a Duration" +description = "parsing human-readable time strings and converting them to a DateTime" version = "0.4.0" edition = "2021" license = "MIT" diff --git a/README.md b/README.md index 05e955f..86a2490 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 ollowing relative times: - `num` `unit` (e.g., "-1 hour", "+3 days") - `unit` (e.g., "hour", "day") @@ -60,34 +60,28 @@ The `from_str` and `from_str_at_date` functions support the following formats fo - 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. - -### parse_datetime - -The `from_str` function returns: +The `parse_datetime` and `parse_datetime_at_date` function return: - `Ok(DateTime)` - If the input string can be parsed as a datetime -- `Err(ParseDurationError::InvalidInput)` - If the input string cannot be parsed +- `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.toml b/fuzz/Cargo.toml index 1435c07..e138e55 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "fuzz_from_str" +name = "fuzz_parse_datetime" version = "0.1.0" edition = "2018" @@ -16,13 +16,7 @@ chrono = "0.4" 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 614b355..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,355 +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; +/// +/// let now = Local::now(); +/// let after = parse_datetime_at_date(now, "+3 days"); /// -/// 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))); +/// 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*)? - (\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(ParseDurationError::InvalidInput)? - .as_str(); - let value = if value_str.is_empty() { - 1 - } else { - value_str - .parse::() - .map_err(|_| ParseDurationError::InvalidInput)? - }; - - if let Some(direction) = capture.name("direction") { - if direction.as_str() == "last" { - 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); } } + } - let unit = capture - .name("unit") - .ok_or(ParseDurationError::InvalidInput)? - .as_str(); + // 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); + } + } + } - if capture.name("ago").is_some() { - is_ago = true; + 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_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)); + #[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_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 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_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) - ); + #[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_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) - )); + /// 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_direction() { - assert_eq!(from_str("last hour").unwrap(), Duration::seconds(-3600)); - assert_eq!(from_str("next year").unwrap(), Duration::days(365)); - assert_eq!(from_str("next week").unwrap(), Duration::days(7)); - assert_eq!(from_str("last month").unwrap(), Duration::days(-30)); + 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 026c9e4..0000000 --- a/src/parse_datetime.rs +++ /dev/null @@ -1,279 +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)); - } - } - - #[test] - fn test_epoch_seconds() { - env::set_var("TZ", "UTC"); - let dt = "@1613371067"; - let actual = from_str(dt); - assert_eq!(actual.unwrap().timestamp(), TEST_TIME); - } - } - - #[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)); - } - } - } - - #[cfg(test)] - mod timestamp { - use crate::parse_datetime::from_str; - 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 = from_str(format!("@{}", offset)); - assert_eq!(dt.unwrap(), time); - } - } - } - - /// 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)); -}