diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c187f34..7bece31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -164,3 +164,27 @@ jobs: flags: ${{ steps.vars.outputs.CODECOV_FLAGS }} name: codecov-umbrella fail_ci_if_error: false + + fuzz: + name: Run the fuzzers + runs-on: ubuntu-latest + env: + RUN_FOR: 60 + steps: + - uses: actions/checkout@v3 + - uses: dtolnay/rust-toolchain@nightly + - name: Install `cargo-fuzz` + run: cargo install cargo-fuzz + - uses: Swatinem/rust-cache@v2 + - name: Run from_str for XX seconds + shell: bash + run: | + ## Run it + cd fuzz + cargo +nightly fuzz run fuzz_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 diff --git a/Cargo.lock b/Cargo.lock index 30d32c6..8cd1830 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -68,14 +68,6 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" -[[package]] -name = "humantime_to_duration" -version = "0.3.1" -dependencies = [ - "chrono", - "regex", -] - [[package]] name = "iana-time-zone" version = "0.1.56" @@ -141,6 +133,14 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +[[package]] +name = "parse_datetime" +version = "0.4.0" +dependencies = [ + "chrono", + "regex", +] + [[package]] name = "proc-macro2" version = "1.0.59" diff --git a/Cargo.toml b/Cargo.toml index a527bb2..d571e37 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,10 @@ [package] -name = "humantime_to_duration" +name = "parse_datetime" description = " parsing human-readable relative time strings and converting them to a Duration" -version = "0.3.1" +version = "0.4.0" edition = "2021" license = "MIT" -repository = "https://github.com/uutils/humantime_to_duration" +repository = "https://github.com/uutils/parse_datetime" readme = "README.md" [dependencies] diff --git a/README.md b/README.md index b68b84f..9cab9aa 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ -# humantime_to_duration +# parse_datetime -[![Crates.io](https://img.shields.io/crates/v/humantime_to_duration.svg)](https://crates.io/crates/humantime_to_duration) -[![License](http://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/uutils/humantime_to_duration/blob/main/LICENSE) -[![CodeCov](https://codecov.io/gh/uutils/humantime_to_duration/branch/main/graph/badge.svg)](https://codecov.io/gh/uutils/humantime_to_duration) +[![Crates.io](https://img.shields.io/crates/v/parse_datetime.svg)](https://crates.io/crates/parse_datetime) +[![License](http://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/uutils/parse_datetime/blob/main/LICENSE) +[![CodeCov](https://codecov.io/gh/uutils/parse_datetime/branch/main/graph/badge.svg)](https://codecov.io/gh/uutils/parse_datetime) -A Rust crate for parsing human-readable relative time strings and converting them to a `Duration`. +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`. ## Features -- Parses a variety of human-readable time formats. +- Parses a variety of human-readable and standard time formats. - Supports positive and negative durations. - Allows for chaining time units (e.g., "1 hour 2 minutes" or "2 days and 2 hours"). - Calculate durations relative to a specified date. @@ -20,12 +20,12 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -humantime_to_duration = "0.3.0" +parse_datetime = "0.4.0" ``` Then, import the crate and use the `from_str` and `from_str_at_date` functions: ```rs -use humantime_to_duration::{from_str, from_str_at_date}; +use parse_datetime::{from_str, from_str_at_date}; use chrono::Duration; let duration = from_str("+3 days"); @@ -39,6 +39,15 @@ assert_eq!( ); ``` +For DateTime parsing, import the `parse_datetime` module: +```rs +use parse_datetime::parse_datetime::from_str; +use chrono::{Local, TimeZone}; + +let dt = from_str("2021-02-14 06:37:47"); +assert_eq!(dt.unwrap(), Local.with_ymd_and_hms(2021, 2, 14, 6, 37, 47).unwrap()); +``` + ### Supported Formats The `from_str` and `from_str_at_date` functions support the following formats for relative time: @@ -56,6 +65,8 @@ The `from_str` and `from_str_at_date` functions support the following formats fo ## 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 @@ -64,6 +75,13 @@ The `from_str` and `from_str_at_date` functions return: 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: + +- `Ok(DateTime)` - If the input string can be prsed as a datetime +- `Err(ParseDurationError::InvalidInput)` - If the input string cannot be parsed + ## Fuzzer To run the fuzzer: @@ -74,3 +92,8 @@ $ cargo fuzz run fuzz_from_str ## License This project is licensed under the [MIT License](LICENSE). + +## Note + +At some point, this crate was called humantime_to_duration. +It has been renamed to cover more cases. diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index ce7cb9a..9ab97bf 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -85,8 +85,8 @@ name = "fuzz_from_str" version = "0.1.0" dependencies = [ "chrono", - "humantime_to_duration", "libfuzzer-sys", + "parse_datetime", "rand", "regex", ] @@ -102,14 +102,6 @@ dependencies = [ "wasi 0.11.0+wasi-snapshot-preview1", ] -[[package]] -name = "humantime_to_duration" -version = "0.3.0" -dependencies = [ - "chrono", - "regex", -] - [[package]] name = "iana-time-zone" version = "0.1.56" @@ -195,6 +187,14 @@ version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" +[[package]] +name = "parse_datetime" +version = "0.4.0" +dependencies = [ + "chrono", + "regex", +] + [[package]] name = "ppv-lite86" version = "0.2.17" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index de1f042..7b705b2 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -12,7 +12,7 @@ libfuzzer-sys = "0.4" regex = "1.8.4" chrono = "0.4" -[dependencies.humantime_to_duration] +[dependencies.parse_datetime] path = "../" [[bin]] @@ -20,3 +20,9 @@ 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" +test = false +doc = false diff --git a/fuzz/fuzz_targets/from_str.rs b/fuzz/fuzz_targets/from_str.rs index 5dd52aa..63b55d1 100644 --- a/fuzz/fuzz_targets/from_str.rs +++ b/fuzz/fuzz_targets/from_str.rs @@ -4,5 +4,5 @@ use libfuzzer_sys::fuzz_target; fuzz_target!(|data: &[u8]| { let s = std::str::from_utf8(data).unwrap_or(""); - let _ = humantime_to_duration::from_str(s); + let _ = parse_datetime::from_str(s); }); diff --git a/fuzz/fuzz_targets/parse_datetime_from_str.rs b/fuzz/fuzz_targets/parse_datetime_from_str.rs new file mode 100644 index 0000000..7d285e5 --- /dev/null +++ b/fuzz/fuzz_targets/parse_datetime_from_str.rs @@ -0,0 +1,8 @@ +#![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 b53891a..8a104da 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,9 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +// Expose parse_datetime +pub mod parse_datetime; + use chrono::{Duration, Local, NaiveDate, Utc}; use regex::{Error as RegexError, Regex}; use std::error::Error; @@ -47,7 +50,7 @@ impl From for ParseDurationError { /// /// ``` /// use chrono::Duration; -/// let duration = humantime_to_duration::from_str("+3 days"); +/// let duration = parse_datetime::from_str("+3 days"); /// assert_eq!(duration.unwrap(), Duration::days(3)); /// ``` /// @@ -82,7 +85,7 @@ impl From for ParseDurationError { /// /// ``` /// use chrono::Duration; -/// use humantime_to_duration::{from_str, ParseDurationError}; +/// use parse_datetime::{from_str, ParseDurationError}; /// /// assert_eq!(from_str("1 hour, 30 minutes").unwrap(), Duration::minutes(90)); /// assert_eq!(from_str("tomorrow").unwrap(), Duration::days(1)); @@ -109,7 +112,7 @@ pub fn from_str(s: &str) -> Result { /// /// ``` /// use chrono::{Duration, NaiveDate, Utc, Local}; -/// use humantime_to_duration::{from_str_at_date, ParseDurationError}; +/// use parse_datetime::{from_str_at_date, ParseDurationError}; /// let today = Local::now().date().naive_local(); /// let yesterday = today - Duration::days(1); /// assert_eq!( diff --git a/src/parse_datetime.rs b/src/parse_datetime.rs new file mode 100644 index 0000000..d6f5514 --- /dev/null +++ b/src/parse_datetime.rs @@ -0,0 +1,252 @@ +// 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/tests/simple.rs b/tests/simple.rs index 366f318..a538f9d 100644 --- a/tests/simple.rs +++ b/tests/simple.rs @@ -1,5 +1,5 @@ use chrono::{Duration, Utc}; -use humantime_to_duration::{from_str, from_str_at_date, ParseDurationError}; +use parse_datetime::{from_str, from_str_at_date, ParseDurationError}; #[test] fn test_invalid_input() {