From 0c0a054f7082061b05a919ea1e938a24080bb8c4 Mon Sep 17 00:00:00 2001 From: Daniel Hofstetter Date: Mon, 17 Feb 2025 14:51:34 +0100 Subject: [PATCH 1/9] README.md: fix incorrect sentence --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8895f43..5a0e0df 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ A Rust crate for parsing human-readable relative time strings and human-readable ## Usage -Add this to your `Cargo.toml`: +Add `parse_datetime` to your `Cargo.toml` with: ``` cargo add parse_datetime From 25825df8b7200a784aad1c48ce2c350e287fcb6c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 26 Feb 2025 11:56:48 +0000 Subject: [PATCH 2/9] Update Rust crate chrono to v0.4.40 --- Cargo.lock | 12 +++++++++--- fuzz/Cargo.lock | 14 ++++++++++---- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ec5e584..46af8c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,14 +52,14 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.39" +version = "0.4.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" +checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" dependencies = [ "android-tzdata", "iana-time-zone", "num-traits", - "windows-targets", + "windows-link", ] [[package]] @@ -279,6 +279,12 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-link" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3" + [[package]] name = "windows-targets" version = "0.52.5" diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index f1ad88b..00a62a6 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -63,14 +63,14 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.39" +version = "0.4.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" +checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" dependencies = [ "android-tzdata", "iana-time-zone", "num-traits", - "windows-targets", + "windows-link", ] [[package]] @@ -184,7 +184,7 @@ checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" [[package]] name = "parse_datetime" -version = "0.7.0" +version = "0.8.0" dependencies = [ "chrono", "nom", @@ -325,6 +325,12 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-link" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3" + [[package]] name = "windows-targets" version = "0.52.6" From e7ed14e8a4175e6c1275336ee8cc99ca2ee25c90 Mon Sep 17 00:00:00 2001 From: Dan Hipschman <48698358+dan-hipschman@users.noreply.github.com> Date: Tue, 22 Apr 2025 00:44:09 -0700 Subject: [PATCH 3/9] Allow spaces between +/- and number (#129) * Allow spaces between +/- and number * Remove space to make fmt job pass --------- Co-authored-by: Daniel Hofstetter --- src/parse_relative_time.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/parse_relative_time.rs b/src/parse_relative_time.rs index 689af5b..782949b 100644 --- a/src/parse_relative_time.rs +++ b/src/parse_relative_time.rs @@ -59,7 +59,7 @@ pub fn parse_relative_time_at_date( } let time_pattern: Regex = Regex::new( r"(?x) - (?:(?P[-+]?\d*)\s*)? + (?:(?P[-+]?\s*\d*)\s*)? (\s*(?Pnext|this|last)?\s*)? (?Pyears?|months?|fortnights?|weeks?|days?|hours?|h|minutes?|mins?|m|seconds?|secs?|s|yesterday|tomorrow|now|today) (\s*(?Pand|,)?\s*)? @@ -73,10 +73,13 @@ pub fn parse_relative_time_at_date( for capture in time_pattern.captures_iter(s) { captures_processed += 1; - let value_str = capture + let value_str: String = capture .name("value") .ok_or(ParseDateTimeError::InvalidInput)? - .as_str(); + .as_str() + .chars() + .filter(|c| !c.is_whitespace()) // Remove potential space between +/- and number + .collect(); let value = if value_str.is_empty() { 1 } else { @@ -510,6 +513,19 @@ mod tests { ); } + #[test] + fn test_spaces() { + let now = Utc::now(); + assert_eq!( + parse_relative_time_at_date(now, "+ 1 hour").unwrap(), + now.checked_add_signed(Duration::hours(1)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "- 1 hour").unwrap(), + now.checked_sub_signed(Duration::hours(1)).unwrap() + ); + } + #[test] fn test_invalid_input() { let result = parse_duration("foobar"); From 46a82b70b3d20b62d590a1f375cc8b34d57d95c8 Mon Sep 17 00:00:00 2001 From: Daniel Hofstetter Date: Tue, 22 Apr 2025 09:52:19 +0200 Subject: [PATCH 4/9] Rename TWELVEHOUR to TWELVE_HOUR --- src/parse_time_only_str.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/parse_time_only_str.rs b/src/parse_time_only_str.rs index 119c2e6..f531d31 100644 --- a/src/parse_time_only_str.rs +++ b/src/parse_time_only_str.rs @@ -4,7 +4,7 @@ use regex::Regex; mod time_only_formats { pub const HH_MM: &str = "%R"; pub const HH_MM_SS: &str = "%T"; - pub const TWELVEHOUR: &str = "%r"; + pub const TWELVE_HOUR: &str = "%r"; } /// Convert a military time zone string to a time zone offset. @@ -55,7 +55,7 @@ fn parse_time_with_offset_multi( for fmt in [ time_only_formats::HH_MM, time_only_formats::HH_MM_SS, - time_only_formats::TWELVEHOUR, + time_only_formats::TWELVE_HOUR, ] { let parsed = match NaiveTime::parse_from_str(s, fmt) { Ok(t) => t, From 9fc966308ed8f25547377fc366697b410ae86e61 Mon Sep 17 00:00:00 2001 From: Daniel Hofstetter Date: Tue, 22 Apr 2025 10:12:20 +0200 Subject: [PATCH 5/9] clippy: remove unnecessary semicolon --- src/parse_time_only_str.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/parse_time_only_str.rs b/src/parse_time_only_str.rs index f531d31..9fe9c1b 100644 --- a/src/parse_time_only_str.rs +++ b/src/parse_time_only_str.rs @@ -84,7 +84,7 @@ pub(crate) fn parse_time_only(date: DateTime, s: &str) -> Option().unwrap() * 60; } _ => (), - }; + } offset_in_sec *= if &captures["sign"] == "-" { -1 } else { 1 }; FixedOffset::east_opt(offset_in_sec) } From 1a93bb4e3e8e7912a64dbc921b3561c6bcd74609 Mon Sep 17 00:00:00 2001 From: Daniel Hofstetter Date: Tue, 22 Apr 2025 10:27:13 +0200 Subject: [PATCH 6/9] clippy: fix warning from needless_continue lint --- src/parse_time_only_str.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/parse_time_only_str.rs b/src/parse_time_only_str.rs index 9fe9c1b..9d485d0 100644 --- a/src/parse_time_only_str.rs +++ b/src/parse_time_only_str.rs @@ -62,9 +62,8 @@ fn parse_time_with_offset_multi( Err(_) => continue, }; let parsed_dt = date.date_naive().and_time(parsed); - match offset.from_local_datetime(&parsed_dt).single() { - Some(dt) => return Some(dt), - None => continue, + if let Some(dt) = offset.from_local_datetime(&parsed_dt).single() { + return Some(dt); } } None From f6b6b3e4ea2531b991d0861c154bf84a93a1a7e3 Mon Sep 17 00:00:00 2001 From: Daniel Hofstetter Date: Tue, 22 Apr 2025 10:31:02 +0200 Subject: [PATCH 7/9] clippy: fix warning from manual_let_else lint --- src/parse_time_only_str.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/parse_time_only_str.rs b/src/parse_time_only_str.rs index 9d485d0..6816953 100644 --- a/src/parse_time_only_str.rs +++ b/src/parse_time_only_str.rs @@ -57,9 +57,8 @@ fn parse_time_with_offset_multi( time_only_formats::HH_MM_SS, time_only_formats::TWELVE_HOUR, ] { - let parsed = match NaiveTime::parse_from_str(s, fmt) { - Ok(t) => t, - Err(_) => continue, + let Ok(parsed) = NaiveTime::parse_from_str(s, fmt) else { + continue; }; let parsed_dt = date.date_naive().and_time(parsed); if let Some(dt) = offset.from_local_datetime(&parsed_dt).single() { From 28b788a300e9854de9e2a4776a06ca1ebf0bc8d2 Mon Sep 17 00:00:00 2001 From: Daniel Hofstetter Date: Tue, 22 Apr 2025 10:34:39 +0200 Subject: [PATCH 8/9] clippy: fix warnings from redundant_else lint --- src/lib.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index f6148be..c94d108 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -271,9 +271,8 @@ where if let Ok(parsed) = DateTime::parse_from_str(&tmp_s[0..n], fmt) { if tmp_s == s.as_ref() { return Some((parsed, n)); - } else { - return Some((parsed, n - 1)); } + return Some((parsed, n - 1)); } } } @@ -351,9 +350,8 @@ where if let Ok(parsed) = DateTime::parse_from_str(&ts, &f) { if tmp_s == s.as_ref() { return Some((parsed, n)); - } else { - return Some((parsed, n - 1)); } + return Some((parsed, n - 1)); } } } From 65fb9140d2142c24a420878f8ee5218d916e9606 Mon Sep 17 00:00:00 2001 From: Dan Hipschman <48698358+dan-hipschman@users.noreply.github.com> Date: Mon, 21 Apr 2025 12:01:00 -0700 Subject: [PATCH 9/9] Parse relative weekdays like "next monday" --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/parse_relative_time.rs | 263 +++++++++++++++++++++++++++++++++---- 3 files changed, 239 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 46af8c9..064a8fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -144,7 +144,7 @@ checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" [[package]] name = "parse_datetime" -version = "0.8.0" +version = "0.9.0" dependencies = [ "chrono", "nom", diff --git a/Cargo.toml b/Cargo.toml index 1c75c90..751b38f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "parse_datetime" description = "parsing human-readable time strings and converting them to a DateTime" -version = "0.8.0" +version = "0.9.0" edition = "2021" license = "MIT" repository = "https://github.com/uutils/parse_datetime" diff --git a/src/parse_relative_time.rs b/src/parse_relative_time.rs index 782949b..1c193bf 100644 --- a/src/parse_relative_time.rs +++ b/src/parse_relative_time.rs @@ -1,8 +1,9 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -use crate::ParseDateTimeError; +use crate::{parse_weekday::parse_weekday, ParseDateTimeError}; use chrono::{ - DateTime, Datelike, Days, Duration, LocalResult, Months, NaiveDate, NaiveDateTime, TimeZone, + DateTime, Datelike, Days, Duration, LocalResult, Months, NaiveDate, NaiveDateTime, NaiveTime, + TimeZone, Weekday, }; use regex::Regex; @@ -61,7 +62,7 @@ pub fn parse_relative_time_at_date( r"(?x) (?:(?P[-+]?\s*\d*)\s*)? (\s*(?Pnext|this|last)?\s*)? - (?Pyears?|months?|fortnights?|weeks?|days?|hours?|h|minutes?|mins?|m|seconds?|secs?|s|yesterday|tomorrow|now|today) + (?Pyears?|months?|fortnights?|weeks?|days?|hours?|h|minutes?|mins?|m|seconds?|secs?|s|yesterday|tomorrow|now|today|(?P[a-z]{3,9}))\b (\s*(?Pand|,)?\s*)? (\s*(?Pago)?)?", )?; @@ -80,16 +81,19 @@ pub fn parse_relative_time_at_date( .chars() .filter(|c| !c.is_whitespace()) // Remove potential space between +/- and number .collect(); + let direction = capture.name("direction").map_or("", |d| d.as_str()); let value = if value_str.is_empty() { - 1 + if direction == "this" { + 0 + } else { + 1 + } } else { value_str .parse::() .map_err(|_| ParseDateTimeError::InvalidInput)? }; - let direction = capture.name("direction").map_or("", |d| d.as_str()); - if direction == "last" { is_ago = true; } @@ -103,27 +107,26 @@ pub fn parse_relative_time_at_date( is_ago = true; } - let new_datetime = if direction == "this" { - add_days(datetime, 0, is_ago) - } else { - match unit { - "years" | "year" => add_months(datetime, value * 12, is_ago), - "months" | "month" => add_months(datetime, value, is_ago), - "fortnights" | "fortnight" => add_days(datetime, value * 14, is_ago), - "weeks" | "week" => add_days(datetime, value * 7, is_ago), - "days" | "day" => add_days(datetime, value, is_ago), - "hours" | "hour" | "h" => add_duration(datetime, Duration::hours(value), is_ago), - "minutes" | "minute" | "mins" | "min" | "m" => { - add_duration(datetime, Duration::minutes(value), is_ago) - } - "seconds" | "second" | "secs" | "sec" | "s" => { - add_duration(datetime, Duration::seconds(value), is_ago) - } - "yesterday" => add_days(datetime, 1, true), - "tomorrow" => add_days(datetime, 1, false), - "now" | "today" => Some(datetime), - _ => None, + let new_datetime = match unit { + "years" | "year" => add_months(datetime, value * 12, is_ago), + "months" | "month" => add_months(datetime, value, is_ago), + "fortnights" | "fortnight" => add_days(datetime, value * 14, is_ago), + "weeks" | "week" => add_days(datetime, value * 7, is_ago), + "days" | "day" => add_days(datetime, value, is_ago), + "hours" | "hour" | "h" => add_duration(datetime, Duration::hours(value), is_ago), + "minutes" | "minute" | "mins" | "min" | "m" => { + add_duration(datetime, Duration::minutes(value), is_ago) + } + "seconds" | "second" | "secs" | "sec" | "s" => { + add_duration(datetime, Duration::seconds(value), is_ago) } + "yesterday" => add_days(datetime, 1, true), + "tomorrow" => add_days(datetime, 1, false), + "now" | "today" => Some(datetime), + _ => capture + .name("weekday") + .and_then(|weekday| parse_weekday(weekday.as_str())) + .and_then(|weekday| adjust_for_weekday(datetime, weekday, value, is_ago)), }; datetime = match new_datetime { Some(dt) => dt, @@ -148,6 +151,25 @@ pub fn parse_relative_time_at_date( } } +fn adjust_for_weekday( + mut datetime: DateTime, + weekday: Weekday, + mut amount: i64, + is_ago: bool, +) -> Option> { + let mut same_day = true; + // last/this/next truncates the time to midnight + datetime = datetime.with_time(NaiveTime::MIN).unwrap(); + while datetime.weekday() != weekday { + datetime = add_days(datetime, 1, is_ago)?; + same_day = false; + } + if !same_day && 0 < amount { + amount -= 1; + } + add_days(datetime, amount * 7, is_ago) +} + fn add_months( datetime: DateTime, months: i64, @@ -810,4 +832,193 @@ mod tests { let result = parse_relative_time_at_date(now, "invalid 1r"); assert_eq!(result, Err(ParseDateTimeError::InvalidInput)); } + + #[test] + fn test_parse_relative_time_at_date_this_weekday() { + // Jan 1 2025 is a Wed + let now = Utc.from_utc_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + )); + // Check "this " + assert_eq!( + parse_relative_time_at_date(now, "this wednesday").unwrap(), + now + ); + assert_eq!(parse_relative_time_at_date(now, "this wed").unwrap(), now); + // Other days + assert_eq!( + parse_relative_time_at_date(now, "this thursday").unwrap(), + now.checked_add_days(Days::new(1)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "this thur").unwrap(), + now.checked_add_days(Days::new(1)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "this thu").unwrap(), + now.checked_add_days(Days::new(1)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "this friday").unwrap(), + now.checked_add_days(Days::new(2)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "this fri").unwrap(), + now.checked_add_days(Days::new(2)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "this saturday").unwrap(), + now.checked_add_days(Days::new(3)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "this sat").unwrap(), + now.checked_add_days(Days::new(3)).unwrap() + ); + // "this" with a day of the week that comes before today should return the next instance of + // that day + assert_eq!( + parse_relative_time_at_date(now, "this sunday").unwrap(), + now.checked_add_days(Days::new(4)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "this sun").unwrap(), + now.checked_add_days(Days::new(4)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "this monday").unwrap(), + now.checked_add_days(Days::new(5)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "this mon").unwrap(), + now.checked_add_days(Days::new(5)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "this tuesday").unwrap(), + now.checked_add_days(Days::new(6)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "this tue").unwrap(), + now.checked_add_days(Days::new(6)).unwrap() + ); + } + + #[test] + fn test_parse_relative_time_at_date_last_weekday() { + // Jan 1 2025 is a Wed + let now = Utc.from_utc_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + )); + // Check "last " + assert_eq!( + parse_relative_time_at_date(now, "last wed").unwrap(), + now.checked_sub_days(Days::new(7)).unwrap() + ); + // Check "last " + assert_eq!( + parse_relative_time_at_date(now, "last thu").unwrap(), + now.checked_sub_days(Days::new(6)).unwrap() + ); + // Check "last " + assert_eq!( + parse_relative_time_at_date(now, "last tue").unwrap(), + now.checked_sub_days(Days::new(1)).unwrap() + ); + } + + #[test] + fn test_parse_relative_time_at_date_next_weekday() { + // Jan 1 2025 is a Wed + let now = Utc.from_utc_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + )); + // Check "next " + assert_eq!( + parse_relative_time_at_date(now, "next wed").unwrap(), + now.checked_add_days(Days::new(7)).unwrap() + ); + // Check "next " + assert_eq!( + parse_relative_time_at_date(now, "next thu").unwrap(), + now.checked_add_days(Days::new(1)).unwrap() + ); + // Check "next " + assert_eq!( + parse_relative_time_at_date(now, "next tue").unwrap(), + now.checked_add_days(Days::new(6)).unwrap() + ); + } + + #[test] + fn test_parse_relative_time_at_date_number_weekday() { + // Jan 1 2025 is a Wed + let now = Utc.from_utc_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + )); + assert_eq!( + parse_relative_time_at_date(now, "1 wed").unwrap(), + now.checked_add_days(Days::new(7)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "1 thu").unwrap(), + now.checked_add_days(Days::new(1)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "1 tue").unwrap(), + now.checked_add_days(Days::new(6)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "2 wed").unwrap(), + now.checked_add_days(Days::new(14)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "2 thu").unwrap(), + now.checked_add_days(Days::new(8)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "2 tue").unwrap(), + now.checked_add_days(Days::new(13)).unwrap() + ); + } + + #[test] + fn test_parse_relative_time_at_date_weekday_truncates_time() { + // Jan 1 2025 is a Wed + let now = Utc.from_utc_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + NaiveTime::from_hms_opt(12, 0, 0).unwrap(), + )); + let now_midnight = Utc.from_utc_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + )); + assert_eq!( + parse_relative_time_at_date(now, "this wed").unwrap(), + now_midnight + ); + assert_eq!( + parse_relative_time_at_date(now, "last wed").unwrap(), + now_midnight.checked_sub_days(Days::new(7)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "next wed").unwrap(), + now_midnight.checked_add_days(Days::new(7)).unwrap() + ); + } + + #[test] + fn test_parse_relative_time_at_date_invalid_weekday() { + // Jan 1 2025 is a Wed + let now = Utc.from_utc_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + )); + assert_eq!( + parse_relative_time_at_date(now, "this fooday"), + Err(ParseDateTimeError::InvalidInput) + ); + } }