From 5e277ca17b1817fa0e1cf84178c6ae0e981fcbae Mon Sep 17 00:00:00 2001 From: dcechano Date: Tue, 26 Dec 2023 15:18:45 -0500 Subject: [PATCH 1/2] Add date modification from relative strings --- src/parse_relative_time.rs | 889 ++++++++++++++----------------------- 1 file changed, 344 insertions(+), 545 deletions(-) diff --git a/src/parse_relative_time.rs b/src/parse_relative_time.rs index 7bc0840..d864fd0 100644 --- a/src/parse_relative_time.rs +++ b/src/parse_relative_time.rs @@ -1,92 +1,66 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. use crate::ParseDateTimeError; -use chrono::{Duration, Local, NaiveDate, Utc}; +use chrono::{DateTime, Datelike, Duration, TimeZone, Timelike}; 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) -} +use std::cmp::Ordering; +use std::collections::HashMap; +use std::ops::Add; -/// Parses a duration string and returns a `Duration` instance, with the duration -/// calculated from the specified date. +/// Parses a date modification string and performs the operation on the given `DateTime` +/// object and returns a new `DateTime` as a `Result`. +/// /// /// # Arguments /// -/// * `date` - A `Date` instance representing the base date for the calculation +/// * `date` - A `DateTime` 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, +pub fn dt_from_relative( s: &str, -) -> Result { + date: DateTime, +) -> Result, ParseDateTimeError> { + if s.trim().is_empty() { + return Ok(date); + } + let time_pattern: Regex = Regex::new( - r"(?x) - (?:(?P[-+]?\d*)\s*)? + r"(?ix) + (?:(?P[-+]?\s*\d*)\s*)? (\s*(?Pnext|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)? + (?Pyesterday|tomorrow|now|today)? (\s*(?Pand|,)?\s*)? (\s*(?Pago)?)?", - )?; + ).unwrap(); - 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 mut chrono_map: HashMap = HashMap::new(); + let mut time: Option = None; + + for capture in time_pattern.captures_iter(s.trim()) { + captures_processed += 1; let value_str = capture .name("value") .ok_or(ParseDateTimeError::InvalidInput)? .as_str(); - let value = if value_str.is_empty() { - 1 - } else { + + let mut value = if !value_str.is_empty() { value_str + .chars() + .filter(|char| !char.is_ascii_whitespace()) + .collect::() .parse::() .map_err(|_| ParseDateTimeError::InvalidInput)? + } else { + 1 }; if let Some(direction) = capture.name("direction") { @@ -95,528 +69,353 @@ pub fn parse_relative_time_at_date( } } - 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(); + if value > 0 && is_ago { + value *= -1; } - } - // Check if the entire input string has been captured - if total_length != s.len() { - return Err(ParseDateTimeError::InvalidInput); + match (capture.name("absolute"), capture.name("relative")) { + (None, None) => { + // time cannot be set twice and time cannot be negative + if value < 0 || time.is_some() { + return Err(ParseDateTimeError::InvalidInput); + } + // Time values cannot start with '+' or '-' to be consistent with GNU + if value_str.starts_with('+') || value_str.starts_with('-') { + return Err(ParseDateTimeError::InvalidInput); + } + time = Some(value as u32); + } + (Some(absolute), None) => { + process_absolute( + absolute.as_str().to_ascii_lowercase(), + &mut chrono_map, + value, + )?; + } + (None, Some(relative)) => { + // time cannot be set twice and time cannot be negative + if value < 0 || time.is_some() { + return Err(ParseDateTimeError::InvalidInput); + } + // Use value_str as a way to check if user passed in a value. + // If they did not then we should not interpret `value = 1` as a time + if !value_str.is_empty() { + time = Some(value as u32); + } + process_relative(relative.as_str().to_string(), &mut chrono_map)?; + } + (Some(_), Some(_)) => { + /* Doesn't appear to be possibly due to the way the + regular expression is structured, and how the iterator works. + There is a test case in test_edge_cases() that passes. + */ + } + } } - 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) - ); + return Err(ParseDateTimeError::InvalidInput); } - #[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) - ); - } + let mut datetime = match time { + None => date, + Some(time) => { + let hour = time / 100; + let minute = time % 100; + if hour >= 24 || minute >= 60 { + return Err(ParseDateTimeError::InvalidInput); + } + date.with_hour(hour).unwrap().with_minute(minute).unwrap() + } + }; - #[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)); + if let Some(months) = chrono_map.remove(&ChronoUnit::Month) { + process_months(&mut datetime, months); } - #[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)); + // Not doing things like months/years before other elements leads to improper output. + let sorted = { + let mut v = chrono_map.into_iter().collect::>(); + v.sort_by(|a, b| a.0.cmp(&b.0)); + v + }; + for (chrono, value) in sorted.into_iter() { + match chrono { + ChronoUnit::Month => { /* Not possible */ } + ChronoUnit::Fortnight => { + datetime = datetime.add(Duration::weeks(value * 2)); + } + ChronoUnit::Week => { + datetime = datetime.add(Duration::weeks(value)); + } + ChronoUnit::Day => { + datetime = datetime.add(Duration::days(value)); + } + ChronoUnit::Hour => { + datetime = datetime.add(Duration::hours(value)); + } + ChronoUnit::Minute => { + datetime = datetime.add(Duration::minutes(value)); + } + ChronoUnit::Second => { + datetime = datetime.add(Duration::seconds(value)); + } + } } - - #[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) - ); + Ok(datetime) +} +#[allow(clippy::map_entry)] +fn add_unit(map: &mut HashMap, unit: ChronoUnit, time: i64) { + if map.contains_key(&unit) { + *map.get_mut(&unit).unwrap() += time; + } else { + map.insert(unit, time); } +} - #[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) - ); - } +#[allow(clippy::match_overlapping_arm, overlapping_range_endpoints)] +fn process_months(date: &mut DateTime, months: i64) { + let mut years = months / 12; + let current_month = date.month() as i64; + let potential_month = current_month + months % 12; + const JANUARY: i64 = 1; + const DECEMBER: i64 = 12; + let new_month = match potential_month { + JANUARY..=DECEMBER => potential_month, + -12..=JANUARY => { + years -= 1; + DECEMBER + potential_month + } + DECEMBER.. => { + years += 1; + potential_month - DECEMBER + } + _ => panic!("IMPOSSIBLE!"), + } as u32; + + *date = date + .with_day(28) + .unwrap() + .with_month(new_month) + .unwrap() + .with_year(date.year() + years as i32) + .unwrap() + .add(Duration::days(date.day() as i64 - 28)); +} - #[test] - fn test_invalid_input() { - let result = parse_relative_time("foobar"); - println!("{result:?}"); - assert_eq!(result, Err(ParseDateTimeError::InvalidInput)); +fn process_absolute( + unit: String, + chrono_map: &mut HashMap, + value: i64, +) -> Result<(), ParseDateTimeError> { + match unit.as_bytes() { + b"years" | b"year" => add_unit(chrono_map, ChronoUnit::Month, value * 12), + b"months" | b"month" => add_unit(chrono_map, ChronoUnit::Month, value), + b"fortnights" | b"fortnight" => add_unit(chrono_map, ChronoUnit::Fortnight, value), + b"weeks" | b"week" => add_unit(chrono_map, ChronoUnit::Week, value), + b"days" | b"day" => add_unit(chrono_map, ChronoUnit::Day, value), + b"hours" | b"hour" | b"h" => add_unit(chrono_map, ChronoUnit::Hour, value), + b"minutes" | b"minute" | b"mins" | b"min" | b"m" => { + add_unit(chrono_map, ChronoUnit::Minute, value) + } + b"seconds" | b"second" | b"secs" | b"sec" | b"s" => { + add_unit(chrono_map, ChronoUnit::Second, value) + } + _ => return Err(ParseDateTimeError::InvalidInput), + }; + Ok(()) +} - 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), - }*/ +fn process_relative( + unit: String, + chrono_map: &mut HashMap, +) -> Result<(), ParseDateTimeError> { + match unit.as_bytes() { + b"yesterday" => add_unit(chrono_map, ChronoUnit::Day, -1), + b"tomorrow" => add_unit(chrono_map, ChronoUnit::Day, 1), + b"now" | b"today" => { /*No processing needed*/ } + _ => return Err(ParseDateTimeError::InvalidInput), } + Ok(()) +} - #[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) - ); +#[derive(Copy, Clone, Hash, Eq, PartialEq)] +enum ChronoUnit { + Month, + Fortnight, + Week, + Day, + Hour, + Minute, + Second, +} - assert_eq!( - parse_relative_time_at_date(date, "2 hours").unwrap(), - Duration::days(days_diff) + Duration::hours(2) - ); +impl ChronoUnit { + fn map_to_int(&self) -> u8 { + match self { + ChronoUnit::Month => 7, + ChronoUnit::Fortnight => 6, + ChronoUnit::Week => 5, + ChronoUnit::Day => 4, + ChronoUnit::Hour => 3, + ChronoUnit::Minute => 2, + ChronoUnit::Second => 1, + } } +} - #[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) - )); +impl PartialOrd for ChronoUnit { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.map_to_int().cmp(&other.map_to_int())) } +} - #[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) - ); +impl Ord for ChronoUnit { + fn cmp(&self, other: &Self) -> Ordering { + self.map_to_int().cmp(&other.map_to_int()) } +} - #[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)); +#[cfg(test)] +mod tests { - 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) - ); - } + use super::dt_from_relative; + use chrono::DateTime; #[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" - ); + fn test_parse_date_from_modifier_ok() { + let format = "%Y %b %d %H:%M:%S.%f %z"; + let input = [ + ( + "1000", + DateTime::parse_from_str("2022 May 15 10:00:00.0 +0000", format).unwrap(), + ), + ( + "1000 yesterday", + DateTime::parse_from_str("2022 May 14 10:00:00.0 +0000", format).unwrap(), + ), + ( + "1000 yesterday next month", + DateTime::parse_from_str("2022 Jun 14 10:00:00.0 +0000", format).unwrap(), + ), + ( + "1000 yesterday month", + DateTime::parse_from_str("2022 Jun 14 10:00:00.0 +0000", format).unwrap(), + ), + ( + "last year", + DateTime::parse_from_str("2021 May 15 00:00:00.0 +0000", format).unwrap(), + ), + ( + "yesterday 1223", + DateTime::parse_from_str("2022 May 14 12:23:00.0 +0000", format).unwrap(), + ), + ( + "yesterday month", + DateTime::parse_from_str("2022 Jun 14 00:00:00.0 +0000", format).unwrap(), + ), + ( + "+01MONTH", + DateTime::parse_from_str("2022 Jun 15 00:00:00.0 +0000", format).unwrap(), + ), + ( + "+01MONTH 1545", + DateTime::parse_from_str("2022 Jun 15 15:45:00.0 +0000", format).unwrap(), + ), + ( + "00001year-000000001year+\t12months", + DateTime::parse_from_str("2023 May 15 00:00:00.0 +0000", format).unwrap(), + ), + ( + "", + DateTime::parse_from_str("2022 May 15 00:00:00.0 +0000", format).unwrap(), + ), + ( + "30SecONDS1houR", + DateTime::parse_from_str("2022 May 15 01:00:30.0 +0000", format).unwrap(), + ), + ( + "30 \t\n\t SECONDS000050000houR-10000yearS", + DateTime::parse_from_str("-7972 Jan 27 08:00:30.0 +0000", format).unwrap(), + ), + ( + "+0000111MONTHs - 20 yearS 100000day", + DateTime::parse_from_str("2285 May 30 00:00:00.0 +0000", format).unwrap(), + ), + ( + "100 week + 0024HOUrs - 50 minutes", + DateTime::parse_from_str("2024 Apr 14 23:10:00.0 +0000", format).unwrap(), + ), + ( + "-100 MONTHS 300 days + 20 \t YEARS 130", + DateTime::parse_from_str("2034 Nov 11 01:30:00.0 +0000", format).unwrap(), + ), + ]; + + let date0 = DateTime::parse_from_str("2022 May 15 00:00:00.0 +0000", format).unwrap(); + for (modifier, expected) in input { + assert_eq!(dt_from_relative(modifier, date0).unwrap(), expected); + } } #[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) - ); + fn test_edge_cases() { + let format = "%Y %b %d %H:%M:%S.%f %z"; + let input = [ + ( + "1 month 1230", + DateTime::parse_from_str("2022 Aug 31 00:00:00.0 +0000", format).unwrap(), + DateTime::parse_from_str("2022 Oct 1 12:30:00.0 +0000", format).unwrap(), + ), + ( + "2 month 1230", + DateTime::parse_from_str("2022 Aug 31 00:00:00.0 +0000", format).unwrap(), + DateTime::parse_from_str("2022 Oct 31 12:30:00.0 +0000", format).unwrap(), + ), + ( + "year 1230", + DateTime::parse_from_str("2020 Feb 29 00:00:00.0 +0000", format).unwrap(), + DateTime::parse_from_str("2021 Mar 1 12:30:00.0 +0000", format).unwrap(), + ), + ( + "100 year 1230", + DateTime::parse_from_str("2020 Feb 29 00:00:00.0 +0000", format).unwrap(), + DateTime::parse_from_str("2120 Feb 29 12:30:00.0 +0000", format).unwrap(), + ), + ( + "101 year 1230", + DateTime::parse_from_str("2020 Feb 29 00:00:00.0 +0000", format).unwrap(), + DateTime::parse_from_str("2121 Mar 1 12:30:00.0 +0000", format).unwrap(), + ), + ( + " month yesterday", + DateTime::parse_from_str("2020 Feb 29 00:00:00.0 +1000", format).unwrap(), + DateTime::parse_from_str("2020 Mar 28 00:00:00.0 +1000", format).unwrap(), + ), + ]; + + for (modifier, input_dt, expected_dt) in input { + assert_eq!(dt_from_relative(modifier, input_dt).unwrap(), expected_dt); + } } #[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)); + fn test_parse_date_from_modifier_err() { + let format = "%Y %b %d %H:%M:%S.%f %z"; + + let input = [ + "100000000000000000000000000000000000000 Years", + "1000 days 1000 100", + "1000 1200", + "1000 yesterday 1200", + ]; + + let date0 = DateTime::parse_from_str("2022 May 15 00:00:00.0 +0000", format).unwrap(); + for modifier in input.into_iter() { + assert!(dt_from_relative(modifier, date0).is_err()); + } } } From 5bde27feb69a50b15f946c56ceb749057b210630 Mon Sep 17 00:00:00 2001 From: dcechano Date: Tue, 26 Dec 2023 15:19:18 -0500 Subject: [PATCH 2/2] Add support for date parsing with relative modifiers --- src/lib.rs | 348 ++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 254 insertions(+), 94 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index de65b56..bcc4571 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,3 @@ -// 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: //! @@ -8,24 +6,24 @@ //! * unix timestamps, e.g., "@12" //! * relative time to now, e.g. "+1 hour" //! -use regex::Error as RegexError; +use regex::{Error as RegexError, Regex}; use std::error::Error; use std::fmt::{self, Display}; +use chrono::{ + DateTime, Datelike, Duration, FixedOffset, Local, LocalResult, NaiveDate, NaiveDateTime, + TimeZone, Timelike, +}; + +use crate::parse_relative_time::dt_from_relative; +use parse_timestamp::parse_timestamp; + // Expose parse_datetime mod parse_relative_time; mod parse_timestamp; mod parse_weekday; -use chrono::{ - DateTime, Datelike, Duration, FixedOffset, Local, LocalResult, NaiveDateTime, TimeZone, - Timelike, -}; - -use parse_relative_time::parse_relative_time; -use parse_timestamp::parse_timestamp; - #[derive(Debug, PartialEq)] pub enum ParseDateTimeError { InvalidRegex(RegexError), @@ -76,7 +74,7 @@ mod format { pub const ZULU_OFFSET: &str = "Z%#z"; } -/// Parses a time string and returns a `DateTime` representing the +/// Parses a time string with optional modifiers and returns a `DateTime` representing the /// absolute time of the string. /// /// # Arguments @@ -86,49 +84,74 @@ mod format { /// # Examples /// /// ``` -/// 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()); +/// use chrono::{DateTime, Utc, TimeZone, Local, FixedOffset}; +/// let date = parse_datetime::parse_datetime("2023-06-03 12:00:01Z +16 days"); +/// assert_eq!(date.unwrap(), Utc.with_ymd_and_hms(2023, 06, 19, 12, 00, 01).unwrap()); +/// +/// let time = parse_datetime::parse_datetime("2023-06-03 00:00:00Z tomorrow 1230"); +/// assert_eq!(time.unwrap(), Utc.with_ymd_and_hms(2023, 06, 04, 12, 30, 00).unwrap()); /// ``` /// +/// # Formats /// -/// # Returns +/// %Y-%m-%d /// -/// * `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 +/// %Y%m%d /// -/// # Errors +/// %a %b %e %H:%M:%S %Y /// -/// 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. +/// %Y%m%d%H%M.%S /// -/// # Arguments +/// %Y-%m-%d %H:%M:%S.%f /// -/// * date - The date represented in local time -/// * `s` - A string slice representing the time. +/// %Y-%m-%d %H:%M:%S /// -/// # Examples +/// %Y-%m-%d %H:%M /// -/// ``` -/// use chrono::{Duration, Local}; -/// use parse_datetime::parse_datetime_at_date; +/// %Y%m%d%H%M /// -/// let now = Local::now(); -/// let after = parse_datetime_at_date(now, "+3 days"); +/// %Y%m%d%H%M %z /// -/// assert_eq!( -/// (now + Duration::days(3)).naive_utc(), -/// after.unwrap().naive_utc() -/// ); -/// ``` +/// %Y%m%d%H%MUTC%z +/// +/// %Y%m%d%H%MZ%z +/// +/// %Y-%m-%d %H:%M %z +/// +/// %Y-%m-%dT%H:%M:%S +/// +/// UTC%#z +/// +/// Z%#z +/// +/// +/// # Modifiers +/// +/// Years +/// +/// Months +/// +/// Fortnights +/// +/// Weeks +/// +/// Days +/// +/// Hours +/// +/// Minutes +/// +/// Seconds +/// +/// Tomorrow +/// +/// Yesterday +/// +/// Now +/// +/// Today +/// +/// Time (represented as an isolated 4 digit number) /// /// # Returns /// @@ -138,23 +161,27 @@ pub fn parse_datetime + Clone>( /// # Errors /// /// This function will return `Err(ParseDateTimeError::InvalidInput)` if the input string -/// cannot be parsed as a relative time. -pub fn parse_datetime_at_date + Clone>( - date: DateTime, +/// cannot be parsed. +pub fn parse_datetime + Clone>( s: S, ) -> Result, ParseDateTimeError> { - // TODO: Replace with a proper customiseable parsing solution using `nom`, `grmtools`, or - // similar + if let Ok(parsed) = try_parse(s.as_ref()) { + return Ok(parsed); + } - // 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); + if let Ok((parsed, modifier)) = DateTime::parse_and_remainder(s.as_ref(), fmt) { + if modifier.is_empty() { + return Ok(parsed); + } + if let Ok(dt) = dt_from_relative(modifier, parsed) { + return Ok(dt); + } } } @@ -162,56 +189,45 @@ pub fn parse_datetime_at_date + Clone>( for fmt in [ format::YYYYMMDDHHMMS_T_SEP, format::YYYYMMDDHHMM, - format::YYYYMMDDHHMMS, format::YYYYMMDDHHMMSS, + format::YYYYMMDDHHMMS, 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); - } + if let Ok((parsed, modifier)) = NaiveDateTime::parse_and_remainder(s.as_ref(), fmt) { + if let Ok(dt) = naive_dt_to_fixed_offset(Local::now(), parsed) { + if modifier.is_empty() { + return Ok(dt); + } else if let Ok(dt) = dt_from_relative(modifier, dt) { + return Ok(dt); + } + }; } } // parse weekday - if let Some(weekday) = parse_weekday::parse_weekday(s.as_ref()) { - let mut beginning_of_day = date - .with_hour(0) - .unwrap() - .with_minute(0) - .unwrap() - .with_second(0) - .unwrap() - .with_nanosecond(0) - .unwrap(); - - while beginning_of_day.weekday() != weekday { - beginning_of_day += Duration::days(1); - } - - let dt = DateTime::::from(beginning_of_day); - - return Ok(dt); + if let Ok(date) = parse_weekday(Local::now().into(), s.as_ref()) { + return Ok(date); } // Parse epoch seconds if let Ok(timestamp) = parse_timestamp(s.as_ref()) { if let Some(timestamp_date) = NaiveDateTime::from_timestamp_opt(timestamp, 0) { - if let Ok(dt) = naive_dt_to_fixed_offset(date, timestamp_date) { + if let Ok(dt) = naive_dt_to_fixed_offset(Local::now(), timestamp_date) { return Ok(dt); } } } - let ts = s.as_ref().to_owned() + "0000"; // Parse date only formats - assume midnight local timezone for fmt in [format::ISO_8601, format::ISO_8601_NO_SEP] { - let f = fmt.to_owned() + "%H%M"; - if let Ok(parsed) = NaiveDateTime::parse_from_str(&ts, &f) { - if let Ok(dt) = naive_dt_to_fixed_offset(date, parsed) { - return Ok(dt); + if let Ok((date, remainder)) = NaiveDate::parse_and_remainder(s.as_ref(), fmt) { + let ndt = date.and_hms_opt(0, 0, 0).unwrap(); + if let Ok(dt) = naive_dt_to_fixed_offset(Local::now(), ndt) { + if let Ok(dt) = dt_from_relative(remainder, dt) { + return Ok(dt); + } } } } @@ -220,27 +236,123 @@ pub fn parse_datetime_at_date + Clone>( // 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(); + let ts = format!("{}", Local::now().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); + if let Ok((parsed, modifier)) = DateTime::parse_and_remainder(&ts, &f) { + if modifier.trim().is_empty() { + return Ok(parsed); + // if the is a non empty remainder we check to see if the + // first letter is a space. If it is not we reject the input + // because it could be left over form an invalid offset. + } else if !modifier.as_bytes()[0].is_ascii_whitespace() { + return Err(ParseDateTimeError::InvalidInput); + } + if let Ok(dt) = dt_from_relative(modifier, parsed) { + return Ok(dt); + } } } - // Parse relative time. - if let Ok(relative_time) = parse_relative_time(s.as_ref()) { - let current_time = DateTime::::from(date); + parse_datetime_at_date(Local::now(), s.as_ref()) +} - if let Some(date_time) = current_time.checked_add_signed(relative_time) { - return Ok(date_time); +fn try_parse>(s: S) -> Result, ParseDateTimeError> { + let re = Regex::new(r"(?ix) + (?:[+-]?\s*\d+\s*)? + (\s*(?:years?|months?|fortnights?|weeks?|days?|hours?|h|minutes?|mins?|m|seconds?|secs?|s)| + (\s*(?:next|last)\s*)| + (\s*(?:yesterday|tomorrow|now|today)\s*)| + (\s*(?:and|,)\s*))").unwrap(); + + match re.find(s.as_ref()) { + None => s + .as_ref() + .parse::>() + .map_err(|_| ParseDateTimeError::InvalidInput), + Some(matcher) => { + let pivot = matcher.start(); + let date = &s.as_ref()[..pivot]; + let modifier = &s.as_ref()[pivot..]; + if let Ok(dt) = date.parse::>() { + dt_from_relative(modifier, dt) + } else if let Ok(dt) = date.parse::() { + let ndt = dt.and_hms_opt(0, 0, 0).unwrap(); + dt_from_relative( + modifier, + naive_dt_to_fixed_offset(Local::now(), ndt).unwrap(), + ) + } else { + Err(ParseDateTimeError::InvalidInput) + } } } +} - // Default parse and failure - s.as_ref() - .parse() - .map_err(|_| (ParseDateTimeError::InvalidInput)) +fn parse_weekday>( + date: DateTime, + s: S, +) -> Result, ParseDateTimeError> { + if let Some(weekday) = parse_weekday::parse_weekday(s.as_ref()) { + let mut beginning_of_day = date + .with_hour(0) + .unwrap() + .with_minute(0) + .unwrap() + .with_second(0) + .unwrap() + .with_nanosecond(0) + .unwrap(); + + while beginning_of_day.weekday() != weekday { + beginning_of_day += Duration::days(1); + } + return Ok(beginning_of_day); + } + Err(ParseDateTimeError::InvalidInput) +} + +/// 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, Local}; +/// use parse_datetime::parse_datetime_at_date; +/// +/// let now = Local::now(); +/// let after = parse_datetime_at_date(now, "+3 days"); +/// +/// assert_eq!( +/// (now + Duration::days(3)).naive_utc(), +/// after.unwrap().naive_utc() +/// ); +/// ``` +/// +/// # Returns +/// +/// * `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(ParseDateTimeError::InvalidInput)` if the input string +/// cannot be parsed as a relative time. +pub fn parse_datetime_at_date + Clone>( + date: DateTime, + s: S, +) -> Result, ParseDateTimeError> { + if let Ok(dt) = parse_weekday(date.into(), s.as_ref()) { + return Ok(dt); + } + // Parse relative time. + dt_from_relative(s.as_ref(), date.fixed_offset()) } // Convert NaiveDateTime to DateTime by assuming the offset @@ -365,6 +477,8 @@ mod tests { #[cfg(test)] mod relative_time { use crate::parse_datetime; + use chrono::DateTime; + #[test] fn test_positive_offsets() { let relative_times = vec![ @@ -379,6 +493,52 @@ mod tests { assert_eq!(parse_datetime(relative_time).is_ok(), true); } } + + #[test] + fn test_date_with_modifiers() { + let format = "%Y %b %d %H:%M:%S.%f %z"; + let input = [ + ( + parse_datetime("2022-08-31 00:00:00 +0000 1 month 1230").unwrap(), + DateTime::parse_from_str("2022 Oct 1 12:30:00.0 +0000", format).unwrap(), + ), + ( + parse_datetime("2022-08-31 00:00:00.0 +0000 2 month 1230").unwrap(), + DateTime::parse_from_str("2022 Oct 31 12:30:00.0 +0000", format).unwrap(), + ), + ( + parse_datetime("2020-02-29 00:00:00.0 +0000 1 year 1230").unwrap(), + DateTime::parse_from_str("2021 Mar 1 12:30:00.0 +0000", format).unwrap(), + ), + ( + parse_datetime("2020-02-29 00:00:00.0 +0500 100 year 1230").unwrap(), + DateTime::parse_from_str("2120 Feb 29 12:30:00.0 +0500", format).unwrap(), + ), + ( + parse_datetime("2020-02-29 00:00:00.0 -0500 101 year 1230").unwrap(), + DateTime::parse_from_str("2121 Mar 1 12:30:00.0 -0500", format).unwrap(), + ), + ( + parse_datetime("2020-02-29 00:00:00.0 +1000 1 month yesterday").unwrap(), + DateTime::parse_from_str("2020 Mar 28 00:00:00.0 +1000", format).unwrap(), + ), + ( + parse_datetime("2022-08-31 00:00:00.0 +0000 1 month 1230").unwrap(), + DateTime::parse_from_str("2022 Oct 1 12:30:00.0 +0000", format).unwrap(), + ), + ( + parse_datetime("2022-08-31 00:00:00.0 +0000 +12 seconds").unwrap(), + DateTime::parse_from_str("2022 Aug 31 00:00:12.0 +0000", format).unwrap(), + ), + ( + parse_datetime("2022-08-31").unwrap(), + DateTime::parse_from_str("2022 Aug 31 00:00:00.0 +0000", format).unwrap(), + ), + ]; + for (parsed, expected) in input { + assert_eq!(parsed, expected); + } + } } #[cfg(test)] @@ -433,9 +593,10 @@ mod tests { #[cfg(test)] mod timestamp { - use crate::parse_datetime; use chrono::{TimeZone, Utc}; + use crate::parse_datetime; + #[test] fn test_positive_and_negative_offsets() { let offsets: Vec = vec![ @@ -478,7 +639,6 @@ mod tests { #[test] fn test_invalid_input() { let result = parse_datetime("foobar"); - println!("{result:?}"); assert_eq!(result, Err(ParseDateTimeError::InvalidInput)); let result = parse_datetime("invalid 1");