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) + ); + } }