diff --git a/src/lib.rs b/src/lib.rs index c94d108..ef8bd22 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,8 @@ use regex::Regex; use std::error::Error; use std::fmt::{self, Display}; +pub(crate) mod parse; + // Expose parse_datetime mod parse_relative_time; mod parse_timestamp; @@ -21,12 +23,12 @@ mod parse_time_only_str; mod parse_weekday; use chrono::{ - DateTime, Datelike, Duration, FixedOffset, Local, LocalResult, MappedLocalTime, NaiveDate, - NaiveDateTime, TimeZone, Timelike, + DateTime, FixedOffset, Local, LocalResult, MappedLocalTime, NaiveDate, NaiveDateTime, TimeZone, }; use parse_relative_time::parse_relative_time_at_date; use parse_timestamp::parse_timestamp; +use parse_weekday::parse_weekday_at_date; #[derive(Debug, PartialEq)] pub enum ParseDateTimeError { @@ -300,23 +302,8 @@ where } // 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); - + if let Ok(dt) = parse_weekday_at_date(date, s.as_ref()) { + let dt = DateTime::::from(dt); return Some((dt, s.as_ref().len())); } diff --git a/src/parse/mod.rs b/src/parse/mod.rs new file mode 100644 index 0000000..9304ec4 --- /dev/null +++ b/src/parse/mod.rs @@ -0,0 +1,41 @@ +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. +use relative_time::relative_times; +use weekday::weekday; + +mod primitive; +mod relative_time; +mod weekday; + +// TODO: more specific errors? +#[derive(Debug)] +pub(crate) struct ParseError; + +pub(crate) use relative_time::RelativeTime; +pub(crate) use relative_time::TimeUnit; +pub(crate) use weekday::WeekdayItem; + +/// Parses a string of relative times into a vector of `RelativeTime` structs. +pub(crate) fn parse_relative_times(input: &str) -> Result, ParseError> { + relative_times(input) + .map(|(_, times)| times) + .map_err(|_| ParseError) +} + +/// Parses a string of weekday into a `WeekdayItem` struct. +pub(crate) fn parse_weekday(input: &str) -> Result { + weekday(input) + .map(|(_, weekday_item)| weekday_item) + .map_err(|_| ParseError) +} + +/// Finds a value in a list of pairs by its key. +fn find_in_pairs(pairs: &[(&str, T)], key: &str) -> Option { + pairs.iter().find_map(|(k, v)| { + if k.eq_ignore_ascii_case(key) { + Some(v.clone()) + } else { + None + } + }) +} diff --git a/src/parse/primitive.rs b/src/parse/primitive.rs new file mode 100644 index 0000000..53ebbb8 --- /dev/null +++ b/src/parse/primitive.rs @@ -0,0 +1,144 @@ +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! Module to parser relative time strings. +//! +//! Grammar definition: +//! +//! ```ebnf +//! ordinal = "last" | "this" | "next" +//! | "first" | "third" | "fourth" | "fifth" +//! | "sixth" | "seventh" | "eighth" | "ninth" +//! | "tenth" | "eleventh" | "twelfth" ; +//! +//! integer = [ sign ] , digit , { digit } ; +//! +//! sign = { ("+" | "-") , { whitespace } } ; +//! +//! digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" ; +//! ``` + +use nom::{ + bytes::complete::take_while1, + character::complete::{digit1, multispace0, one_of}, + combinator::{map_res, opt}, + multi::fold_many1, + sequence::terminated, + IResult, Parser, +}; + +use super::find_in_pairs; + +const ORDINALS: &[(&str, i64)] = &[ + ("last", -1), + ("this", 0), + ("next", 1), + ("first", 1), + // Unfortunately we can't use "second" as ordinal, the keyword is overloaded + ("third", 3), + ("fourth", 4), + ("fifth", 5), + ("sixth", 6), + ("seventh", 7), + ("eighth", 8), + ("ninth", 9), + ("tenth", 10), + ("eleventh", 11), + ("twelfth", 12), +]; + +pub(super) fn ordinal(input: &str) -> IResult<&str, i64> { + map_res(take_while1(|c: char| c.is_alphabetic()), |s: &str| { + find_in_pairs(ORDINALS, s).ok_or("unknown ordinal") + }) + .parse(input) +} + +pub(super) fn integer(input: &str) -> IResult<&str, i64> { + let (rest, sign) = opt(sign).parse(input)?; + let (rest, num) = map_res(digit1, str::parse::).parse(rest)?; + if sign == Some('-') { + Ok((rest, -num)) + } else { + Ok((rest, num)) + } +} + +/// Parses a sign (either + or -) from the input string. The input string must +/// start with a sign character followed by arbitrary number of interleaving +/// sign characters and whitespace characters. All but the last sign character +/// is ignored, and the last sign character is returned as the result. This +/// quirky behavior is to stay consistent with GNU date. +fn sign(input: &str) -> IResult<&str, char> { + fold_many1( + terminated(one_of("+-"), multispace0), + || '+', + |acc, c| if "+-".contains(c) { c } else { acc }, + ) + .parse(input) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ordinal() { + assert!(ordinal("").is_err()); + assert!(ordinal("invalid").is_err()); + assert!(ordinal(" last").is_err()); + + assert_eq!(ordinal("last"), Ok(("", -1))); + assert_eq!(ordinal("this"), Ok(("", 0))); + assert_eq!(ordinal("next"), Ok(("", 1))); + assert_eq!(ordinal("first"), Ok(("", 1))); + assert_eq!(ordinal("third"), Ok(("", 3))); + assert_eq!(ordinal("fourth"), Ok(("", 4))); + assert_eq!(ordinal("fifth"), Ok(("", 5))); + assert_eq!(ordinal("sixth"), Ok(("", 6))); + assert_eq!(ordinal("seventh"), Ok(("", 7))); + assert_eq!(ordinal("eighth"), Ok(("", 8))); + assert_eq!(ordinal("ninth"), Ok(("", 9))); + assert_eq!(ordinal("tenth"), Ok(("", 10))); + assert_eq!(ordinal("eleventh"), Ok(("", 11))); + assert_eq!(ordinal("twelfth"), Ok(("", 12))); + + // Boundary + assert_eq!(ordinal("last123"), Ok(("123", -1))); + assert_eq!(ordinal("last abc"), Ok((" abc", -1))); + assert!(ordinal("lastabc").is_err()); + + // Case insensitive + assert_eq!(ordinal("THIS"), Ok(("", 0))); + assert_eq!(ordinal("This"), Ok(("", 0))); + } + + #[test] + fn test_integer() { + assert!(integer("").is_err()); + assert!(integer("invalid").is_err()); + assert!(integer(" 123").is_err()); + + assert_eq!(integer("123"), Ok(("", 123))); + assert_eq!(integer("+123"), Ok(("", 123))); + assert_eq!(integer("- 123"), Ok(("", -123))); + + // Boundary + assert_eq!(integer("- 123abc"), Ok(("abc", -123))); + assert_eq!(integer("- +- 123abc"), Ok(("abc", -123))); + } + + #[test] + fn test_sign() { + assert!(sign("").is_err()); + assert!(sign("invalid").is_err()); + assert!(sign(" +").is_err()); + + assert_eq!(sign("+"), Ok(("", '+'))); + assert_eq!(sign("-"), Ok(("", '-'))); + assert_eq!(sign("- + - "), Ok(("", '-'))); + + // Boundary + assert_eq!(sign("- + - abc"), Ok(("abc", '-'))); + } +} diff --git a/src/parse/relative_time.rs b/src/parse/relative_time.rs new file mode 100644 index 0000000..9801290 --- /dev/null +++ b/src/parse/relative_time.rs @@ -0,0 +1,495 @@ +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! Module to parser relative time strings. +//! +//! Grammar definition: +//! +//! ```ebnf +//! relative_times = relative_time , { ("," | "and") , relative_time } ; +//! +//! relative_time = displacement | day_shift ; +//! +//! displacement = (integer | ordinal) , unit , [ "ago" ] ; +//! +//! day_shift = "now" | "today" | "tomorrow" | "yesterday" ; +//! +//! unit = "seconds" | "second" | "secs" | "sec" | "s" +//! | "minutes" | "minute" | "mins" | "min" | "m" +//! | "hours" | "hour" | "h" +//! | "days" | "day" +//! | "weeks" | "week" +//! | "fortnights" | "fortnight" +//! | "months" | "month" +//! | "years" | "year" ; +//! ``` + +use nom::{ + branch::alt, + bytes::complete::{tag, tag_no_case, take_while1}, + character::complete::{multispace0, multispace1}, + combinator::{all_consuming, map_res, opt}, + multi::separated_list0, + sequence::{preceded, terminated}, + IResult, Parser, +}; + +use super::{ + find_in_pairs, + primitive::{integer, ordinal}, +}; + +const TIME_UNITS: &[(&str, TimeUnit)] = &[ + ("seconds", TimeUnit::Second), + ("second", TimeUnit::Second), + ("secs", TimeUnit::Second), + ("sec", TimeUnit::Second), + ("s", TimeUnit::Second), + ("minutes", TimeUnit::Minute), + ("minute", TimeUnit::Minute), + ("mins", TimeUnit::Minute), + ("min", TimeUnit::Minute), + ("m", TimeUnit::Minute), + ("hours", TimeUnit::Hour), + ("hour", TimeUnit::Hour), + ("h", TimeUnit::Hour), + ("days", TimeUnit::Day), + ("day", TimeUnit::Day), + ("weeks", TimeUnit::Week), + ("week", TimeUnit::Week), + ("fortnights", TimeUnit::Fortnight), + ("fortnight", TimeUnit::Fortnight), + ("months", TimeUnit::Month), + ("month", TimeUnit::Month), + ("years", TimeUnit::Year), + ("year", TimeUnit::Year), +]; + +const DAY_SHIFTS: &[(&str, RelativeTime)] = &[ + ("now", RelativeTime::Now), + ("today", RelativeTime::Today), + ("tomorrow", RelativeTime::Tomorrow), + ("yesterday", RelativeTime::Yesterday), +]; + +/// The `TimeUnit` enum represents the different time units that can be used in +/// relative time parsing. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum TimeUnit { + Second, + Minute, + Hour, + Day, + Week, + Fortnight, + Month, + Year, +} + +/// The `RelativeTime` enum represents the different types of relative time. It +/// can be a specific time unit with displacement (like "2 hours") or a day shift +/// (like "today"). +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum RelativeTime { + Now, + Today, + Tomorrow, + Yesterday, + Displacement { value: i64, unit: TimeUnit }, +} + +pub(super) fn relative_times(input: &str) -> IResult<&str, Vec> { + all_consuming(separated_list0( + alt(( + preceded(multispace0, terminated(tag(","), multispace0)), + preceded(multispace1, terminated(tag_no_case("and"), multispace1)), + multispace0, + )), + relative_time, + )) + .parse(input) +} + +fn relative_time(input: &str) -> IResult<&str, RelativeTime> { + alt((day_shift, displacement)).parse(input) +} + +fn displacement(input: &str) -> IResult<&str, RelativeTime> { + let (rest, (value, unit, ago)) = ( + opt(terminated(alt((ordinal, integer)), multispace0)), + unit, + opt(preceded(multispace1, ago)), + ) + .parse(input)?; + + let mut value = value.unwrap_or(1); + if ago.unwrap_or(false) { + value = -value; + } + + Ok((rest, RelativeTime::Displacement { value, unit })) +} + +fn ago(input: &str) -> IResult<&str, bool> { + map_res(take_while1(|c: char| c.is_alphabetic()), |s: &str| { + if s.eq_ignore_ascii_case("ago") { + Ok(true) + } else { + Err("not ago") + } + }) + .parse(input) +} + +fn unit(input: &str) -> IResult<&str, TimeUnit> { + map_res(take_while1(|c: char| c.is_alphabetic()), |s: &str| { + find_in_pairs(TIME_UNITS, s).ok_or("unknown time unit") + }) + .parse(input) +} + +fn day_shift(input: &str) -> IResult<&str, RelativeTime> { + map_res(take_while1(|c: char| c.is_alphabetic()), |s: &str| { + find_in_pairs(DAY_SHIFTS, s).ok_or("unknown day shift") + }) + .parse(input) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_relative_times() { + assert!(relative_times(" ").is_err()); + assert!(relative_times("invalid").is_err()); + + assert_eq!(relative_times(""), Ok(("", vec![]))); + + assert_eq!( + relative_times("second"), + Ok(( + "", + vec![RelativeTime::Displacement { + value: 1, + unit: TimeUnit::Second + }] + )) + ); + assert_eq!( + relative_times("2 minutes"), + Ok(( + "", + vec![RelativeTime::Displacement { + value: 2, + unit: TimeUnit::Minute + }] + )) + ); + assert_eq!( + relative_times("3 hours ago"), + Ok(( + "", + vec![RelativeTime::Displacement { + value: -3, + unit: TimeUnit::Hour + }] + )) + ); + + // Space separator + assert_eq!( + relative_times("today tomorrow"), + Ok(("", vec![RelativeTime::Today, RelativeTime::Tomorrow])) + ); + + // Comma separator + assert_eq!( + relative_times("today, tomorrow"), + Ok(("", vec![RelativeTime::Today, RelativeTime::Tomorrow])) + ); + assert_eq!( + relative_times("today ,tomorrow"), + Ok(("", vec![RelativeTime::Today, RelativeTime::Tomorrow])) + ); + assert_eq!( + relative_times("today , tomorrow"), + Ok(("", vec![RelativeTime::Today, RelativeTime::Tomorrow])) + ); + + // "and" separator + assert_eq!( + relative_times("today and tomorrow"), + Ok(("", vec![RelativeTime::Today, RelativeTime::Tomorrow])) + ); + + // Mixed separator + assert_eq!( + relative_times("yesterday, today and tomorrow"), + Ok(( + "", + vec![ + RelativeTime::Yesterday, + RelativeTime::Today, + RelativeTime::Tomorrow + ] + )) + ); + + // Boundary + assert_eq!( + relative_times("1week2months-3years"), + Ok(( + "", + vec![ + RelativeTime::Displacement { + value: 1, + unit: TimeUnit::Week + }, + RelativeTime::Displacement { + value: 2, + unit: TimeUnit::Month + }, + RelativeTime::Displacement { + value: -3, + unit: TimeUnit::Year + } + ] + )) + ); + assert!(relative_times("1week2months-3years123").is_err()); + assert!(relative_times("1week2months-3yearsabc").is_err()); + } + + #[test] + fn test_relative_time() { + assert!(relative_time("").is_err()); + assert!(relative_time("invalid").is_err()); + + assert_eq!( + relative_time("second"), + Ok(( + "", + RelativeTime::Displacement { + value: 1, + unit: TimeUnit::Second + } + )) + ); + assert_eq!( + relative_time("2 minutes"), + Ok(( + "", + RelativeTime::Displacement { + value: 2, + unit: TimeUnit::Minute + } + )) + ); + assert_eq!( + relative_time("3 hours ago"), + Ok(( + "", + RelativeTime::Displacement { + value: -3, + unit: TimeUnit::Hour + } + )) + ); + assert_eq!( + relative_time("last day"), + Ok(( + "", + RelativeTime::Displacement { + value: -1, + unit: TimeUnit::Day + } + )) + ); + assert_eq!( + relative_time("twelfth week"), + Ok(( + "", + RelativeTime::Displacement { + value: 12, + unit: TimeUnit::Week + } + )) + ); + assert_eq!(relative_time("now"), Ok(("", RelativeTime::Now))); + assert_eq!(relative_time("today"), Ok(("", RelativeTime::Today))); + } + + #[test] + fn test_displacement() { + assert!(displacement("").is_err()); + assert!(displacement("invalid").is_err()); + + assert_eq!( + displacement("second"), + Ok(( + "", + RelativeTime::Displacement { + value: 1, + unit: TimeUnit::Second + } + )) + ); + assert_eq!( + displacement("2 minutes"), + Ok(( + "", + RelativeTime::Displacement { + value: 2, + unit: TimeUnit::Minute + } + )) + ); + assert_eq!( + displacement("3 hours ago"), + Ok(( + "", + RelativeTime::Displacement { + value: -3, + unit: TimeUnit::Hour + } + )) + ); + assert_eq!( + displacement("last day"), + Ok(( + "", + RelativeTime::Displacement { + value: -1, + unit: TimeUnit::Day + } + )) + ); + assert_eq!( + displacement("twelfth week"), + Ok(( + "", + RelativeTime::Displacement { + value: 12, + unit: TimeUnit::Week + } + )) + ); + + // Boundary + assert_eq!( + displacement("3 hours123"), + Ok(( + "123", + RelativeTime::Displacement { + value: 3, + unit: TimeUnit::Hour + } + )) + ); + assert!(displacement("3 hoursabc").is_err()); + assert_eq!( + displacement("3 hours ago123"), + Ok(( + "123", + RelativeTime::Displacement { + value: -3, + unit: TimeUnit::Hour + } + )) + ); + assert_eq!( + displacement("3 hours ago abc"), + Ok(( + " abc", + RelativeTime::Displacement { + value: -3, + unit: TimeUnit::Hour + } + )) + ); + assert_eq!( + displacement("3 hours agoabc"), + Ok(( + " agoabc", + RelativeTime::Displacement { + value: 3, + unit: TimeUnit::Hour + } + )) + ); + } + + #[test] + fn test_ago() { + assert!(ago("").is_err()); + assert!(ago("invalid").is_err()); + + assert_eq!(ago("ago"), Ok(("", true))); + + // Boundary + assert_eq!(ago("ago123"), Ok(("123", true))); + assert_eq!(ago("ago abc"), Ok((" abc", true))); + assert!(ago("agoabc").is_err()); + } + + #[test] + fn test_unit() { + assert!(day_shift("").is_err()); + assert!(unit("invalid").is_err()); + assert!(unit(" second").is_err()); + + assert_eq!(unit("seconds"), Ok(("", TimeUnit::Second))); + assert_eq!(unit("second"), Ok(("", TimeUnit::Second))); + assert_eq!(unit("secs"), Ok(("", TimeUnit::Second))); + assert_eq!(unit("sec"), Ok(("", TimeUnit::Second))); + assert_eq!(unit("s"), Ok(("", TimeUnit::Second))); + assert_eq!(unit("minutes"), Ok(("", TimeUnit::Minute))); + assert_eq!(unit("minute"), Ok(("", TimeUnit::Minute))); + assert_eq!(unit("mins"), Ok(("", TimeUnit::Minute))); + assert_eq!(unit("min"), Ok(("", TimeUnit::Minute))); + assert_eq!(unit("m"), Ok(("", TimeUnit::Minute))); + assert_eq!(unit("hours"), Ok(("", TimeUnit::Hour))); + assert_eq!(unit("hour"), Ok(("", TimeUnit::Hour))); + assert_eq!(unit("days"), Ok(("", TimeUnit::Day))); + assert_eq!(unit("day"), Ok(("", TimeUnit::Day))); + assert_eq!(unit("weeks"), Ok(("", TimeUnit::Week))); + assert_eq!(unit("week"), Ok(("", TimeUnit::Week))); + assert_eq!(unit("fortnights"), Ok(("", TimeUnit::Fortnight))); + assert_eq!(unit("fortnight"), Ok(("", TimeUnit::Fortnight))); + assert_eq!(unit("months"), Ok(("", TimeUnit::Month))); + assert_eq!(unit("month"), Ok(("", TimeUnit::Month))); + assert_eq!(unit("years"), Ok(("", TimeUnit::Year))); + assert_eq!(unit("year"), Ok(("", TimeUnit::Year))); + + // Boundary + assert_eq!(unit("second123"), Ok(("123", TimeUnit::Second))); + assert_eq!(unit("second abc"), Ok((" abc", TimeUnit::Second))); + assert!(unit("secondabc").is_err()); + + // Case insensitive + assert_eq!(unit("SECOND"), Ok(("", TimeUnit::Second))); + assert_eq!(unit("Second"), Ok(("", TimeUnit::Second))); + } + + #[test] + fn test_day_shift() { + assert!(day_shift("").is_err()); + assert!(day_shift("invalid").is_err()); + assert!(day_shift(" now").is_err()); + + assert_eq!(day_shift("now"), Ok(("", RelativeTime::Now))); + assert_eq!(day_shift("today"), Ok(("", RelativeTime::Today))); + assert_eq!(day_shift("tomorrow"), Ok(("", RelativeTime::Tomorrow))); + assert_eq!(day_shift("yesterday"), Ok(("", RelativeTime::Yesterday))); + + // Boundary + assert_eq!(day_shift("now123"), Ok(("123", RelativeTime::Now))); + assert_eq!(day_shift("now abc"), Ok((" abc", RelativeTime::Now))); + assert!(day_shift("nowabc").is_err()); + + // Case insensitive + assert_eq!(day_shift("NOW"), Ok(("", RelativeTime::Now))); + assert_eq!(day_shift("Now"), Ok(("", RelativeTime::Now))); + } +} diff --git a/src/parse/weekday.rs b/src/parse/weekday.rs new file mode 100644 index 0000000..cc3a9be --- /dev/null +++ b/src/parse/weekday.rs @@ -0,0 +1,242 @@ +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! Module to parser weekday strings. +//! +//! Grammar definition: +//! +//! ```ebnf +//! weekday = (integer | ordinal) , day | day , [ "," ] ; +//! +//! day = "sunday" | "sun" +//! | "monday" | "mon" +//! | "tuesday" | "tues" | "tue" +//! | "wednesday" | "wednes" | "wed" +//! | "thursday" | "thurs" | "thur" | "thu" +//! | "friday" | "fri" +//! | "saturday" | "sat" +//! ``` + +use chrono::Weekday; +use nom::{ + branch::alt, + bytes::complete::{tag, take_while1}, + character::complete::multispace0, + combinator::{all_consuming, map_res, opt}, + sequence::{preceded, terminated}, + IResult, Parser, +}; + +use super::{ + find_in_pairs, + primitive::{integer, ordinal}, +}; + +const DAYS: &[(&str, Weekday)] = &[ + ("sunday", Weekday::Sun), + ("sun", Weekday::Sun), + ("monday", Weekday::Mon), + ("mon", Weekday::Mon), + ("tuesday", Weekday::Tue), + ("tues", Weekday::Tue), + ("tue", Weekday::Tue), + ("wednesday", Weekday::Wed), + ("wednes", Weekday::Wed), + ("wed", Weekday::Wed), + ("thursday", Weekday::Thu), + ("thurs", Weekday::Thu), + ("thur", Weekday::Thu), + ("thu", Weekday::Thu), + ("friday", Weekday::Fri), + ("fri", Weekday::Fri), + ("saturday", Weekday::Sat), + ("sat", Weekday::Sat), +]; + +#[derive(Debug, PartialEq)] +pub(crate) struct WeekdayItem { + pub weekday: Weekday, + pub ordinal: Option, +} + +pub(super) fn weekday(input: &str) -> IResult<&str, WeekdayItem> { + all_consuming(alt((ordinal_day, day_comma))).parse(input) +} + +fn ordinal_day(input: &str) -> IResult<&str, WeekdayItem> { + map_res( + (alt((ordinal, integer)), preceded(multispace0, day)), + |(ordinal, weekday): (i64, Weekday)| { + Ok::(WeekdayItem { + weekday, + ordinal: Some(ordinal), + }) + }, + ) + .parse(input) +} + +fn day_comma(input: &str) -> IResult<&str, WeekdayItem> { + map_res( + terminated(day, opt(preceded(multispace0, tag(",")))), + |s: Weekday| { + Ok::(WeekdayItem { + weekday: s, + ordinal: None, + }) + }, + ) + .parse(input) +} + +fn day(input: &str) -> IResult<&str, Weekday> { + map_res(take_while1(|c: char| c.is_alphabetic()), |s: &str| { + find_in_pairs(DAYS, s).ok_or("unknown weekday") + }) + .parse(input) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_weekday() { + assert!(weekday("").is_err()); + assert!(weekday("invalid").is_err()); + assert!(weekday(" sun").is_err()); + + assert_eq!( + weekday("last sunday"), + Ok(( + "", + WeekdayItem { + weekday: Weekday::Sun, + ordinal: Some(-1) + } + )) + ); + assert_eq!( + weekday("sunday ,"), + Ok(( + "", + WeekdayItem { + weekday: Weekday::Sun, + ordinal: None + } + )) + ); + assert!(weekday("next sunday,").is_err()); + } + + #[test] + fn test_ordinal_day() { + assert!(ordinal_day("").is_err()); + assert!(ordinal_day("invalid").is_err()); + assert!(ordinal_day(" sun").is_err()); + + assert_eq!( + ordinal_day("last sunday"), + Ok(( + "", + WeekdayItem { + weekday: Weekday::Sun, + ordinal: Some(-1) + } + )) + ); + assert_eq!( + ordinal_day("2 sun"), + Ok(( + "", + WeekdayItem { + weekday: Weekday::Sun, + ordinal: Some(2) + } + )) + ); + assert_eq!( + ordinal_day("2sun"), + Ok(( + "", + WeekdayItem { + weekday: Weekday::Sun, + ordinal: Some(2) + } + )) + ); + assert!(ordinal_day("nextsun").is_err()); + } + + #[test] + fn test_day_comma() { + assert!(day_comma("").is_err()); + assert!(day_comma("invalid").is_err()); + assert!(day_comma(" sun").is_err()); + + assert_eq!( + day_comma("sunday"), + Ok(( + "", + WeekdayItem { + weekday: Weekday::Sun, + ordinal: None + } + )) + ); + assert_eq!( + day_comma("sun,"), + Ok(( + "", + WeekdayItem { + weekday: Weekday::Sun, + ordinal: None + } + )) + ); + assert_eq!( + day_comma("sun ,"), + Ok(( + "", + WeekdayItem { + weekday: Weekday::Sun, + ordinal: None + } + )) + ); + } + + #[test] + fn test_day() { + assert!(day("").is_err()); + assert!(day("invalid").is_err()); + assert!(day(" sun").is_err()); + + assert_eq!(day("sunday"), Ok(("", Weekday::Sun))); + assert_eq!(day("sun"), Ok(("", Weekday::Sun))); + assert_eq!(day("monday"), Ok(("", Weekday::Mon))); + assert_eq!(day("mon"), Ok(("", Weekday::Mon))); + assert_eq!(day("tuesday"), Ok(("", Weekday::Tue))); + assert_eq!(day("tues"), Ok(("", Weekday::Tue))); + assert_eq!(day("tue"), Ok(("", Weekday::Tue))); + assert_eq!(day("wednesday"), Ok(("", Weekday::Wed))); + assert_eq!(day("wednes"), Ok(("", Weekday::Wed))); + assert_eq!(day("wed"), Ok(("", Weekday::Wed))); + assert_eq!(day("thursday"), Ok(("", Weekday::Thu))); + assert_eq!(day("thur"), Ok(("", Weekday::Thu))); + assert_eq!(day("thu"), Ok(("", Weekday::Thu))); + assert_eq!(day("friday"), Ok(("", Weekday::Fri))); + assert_eq!(day("fri"), Ok(("", Weekday::Fri))); + assert_eq!(day("saturday"), Ok(("", Weekday::Sat))); + assert_eq!(day("sat"), Ok(("", Weekday::Sat))); + + // Boundary + assert_eq!(day("sun123"), Ok(("123", Weekday::Sun))); + assert_eq!(day("sun abc"), Ok((" abc", Weekday::Sun))); + assert!(day("sunabc").is_err()); + + // Case insensitive + assert_eq!(day("MONDAY"), Ok(("", Weekday::Mon))); + assert_eq!(day("Monday"), Ok(("", Weekday::Mon))); + } +} diff --git a/src/parse_relative_time.rs b/src/parse_relative_time.rs index ea4a190..155f8a4 100644 --- a/src/parse_relative_time.rs +++ b/src/parse_relative_time.rs @@ -1,11 +1,12 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -use crate::{parse_weekday::parse_weekday, ParseDateTimeError}; +use crate::{ + parse::{self, RelativeTime, TimeUnit}, + ParseDateTimeError, +}; use chrono::{ - DateTime, Datelike, Days, Duration, LocalResult, Months, NaiveDate, NaiveDateTime, NaiveTime, - TimeZone, Weekday, + DateTime, Datelike, Days, Duration, LocalResult, Months, NaiveDate, NaiveDateTime, TimeZone, }; -use regex::Regex; /// Number of days in each month. /// @@ -54,123 +55,36 @@ pub fn parse_relative_time_at_date( mut datetime: DateTime, s: &str, ) -> Result, ParseDateTimeError> { - let s = s.trim(); - if s.is_empty() { - return Ok(datetime); - } - let time_pattern: Regex = Regex::new( - r"(?ix) - (?:(?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|(?P[a-z]{3,9}))\b - (\s*(?Pand|,)?\s*)? - (\s*(?Pago)?)?", - )?; - - let mut is_ago = s.to_ascii_lowercase().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: String = capture - .name("value") - .ok_or(ParseDateTimeError::InvalidInput)? - .as_str() - .chars() - .filter(|c| !c.is_whitespace()) // Remove potential space between +/- and number - .collect(); - let direction = capture - .name("direction") - .map_or("", |d| d.as_str()) - .to_ascii_lowercase(); - let value = if value_str.is_empty() { - if direction == "this" { - 0 - } else { - 1 + let relative_times = + parse::parse_relative_times(s.trim()).map_err(|_| ParseDateTimeError::InvalidInput)?; + + for relative_time in relative_times { + let new_datetime = match relative_time { + RelativeTime::Now | RelativeTime::Today => { + // Do nothing, just return the current datetime + Some(datetime) } - } else { - value_str - .parse::() - .map_err(|_| ParseDateTimeError::InvalidInput)? + RelativeTime::Yesterday => add_days(datetime, 1, true), + RelativeTime::Tomorrow => add_days(datetime, 1, false), + RelativeTime::Displacement { value, unit } => match unit { + TimeUnit::Second => add_duration(datetime, Duration::seconds(value), false), + TimeUnit::Minute => add_duration(datetime, Duration::minutes(value), false), + TimeUnit::Hour => add_duration(datetime, Duration::hours(value), false), + TimeUnit::Day => add_days(datetime, value, false), + TimeUnit::Week => add_days(datetime, value * 7, false), + TimeUnit::Fortnight => add_days(datetime, value * 14, false), + TimeUnit::Month => add_months(datetime, value, false), + TimeUnit::Year => add_months(datetime, value * 12, false), + }, }; - if direction == "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 new_datetime = match unit.to_ascii_lowercase().as_str() { - "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, 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 { - Ok(datetime) - } -} - -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) + Ok(datetime) } fn add_months( @@ -373,7 +287,7 @@ mod tests { ); assert_eq!( parse_relative_time_at_date(now, "1 month and 2 weeks ago").unwrap(), - add_months(now, 1, true) + add_months(now, 1, false) .unwrap() .checked_sub_days(Days::new(14)) .unwrap() @@ -431,7 +345,7 @@ mod tests { ); assert_eq!( parse_duration("1 week 3 days ago").unwrap(), - Duration::seconds(-864_000) + Duration::seconds(345_600) ); assert_eq!( parse_duration("-2 weeks").unwrap(), @@ -516,9 +430,8 @@ mod tests { ); assert_eq!( parse_duration("2weeks 1hour ago").unwrap(), - Duration::seconds(-1_213_200) + Duration::seconds(1_206_000) ); - assert_eq!(parse_duration("thismonth").unwrap(), Duration::days(0)); assert_eq!( parse_relative_time_at_date(now, "+4months").unwrap(), now.checked_add_months(Months::new(4)).unwrap() @@ -658,7 +571,7 @@ mod tests { ); assert_eq!( parse_relative_time_at_date(now, "1 month and 2 weeks ago").unwrap(), - now.checked_sub_months(Months::new(1)) + add_months(now, 1, false) .unwrap() .checked_sub_days(Days::new(14)) .unwrap() @@ -695,7 +608,7 @@ mod tests { ); assert_eq!( parse_relative_time_at_date(now, "1 week 3 days ago").unwrap(), - now.checked_sub_days(Days::new(7 + 3)).unwrap() + now.checked_add_days(Days::new(7 - 3)).unwrap() ); assert_eq!( parse_relative_time_at_date(now, "-2 weeks").unwrap(), @@ -763,9 +676,9 @@ mod tests { assert_eq!( parse_relative_time_at_date(now, "1 year 2 months 4 weeks 3 days and 2 seconds ago") .unwrap(), - now.checked_sub_months(Months::new(12 + 2)) + now.checked_add_months(Months::new(12 + 2)) .unwrap() - .checked_sub_days(Days::new(4 * 7 + 3)) + .checked_add_days(Days::new(4 * 7 + 3)) .unwrap() .checked_sub_signed(Duration::seconds(2)) .unwrap() @@ -835,205 +748,4 @@ 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) - ); - } - - #[test] - fn test_parse_relative_time_at_date_with_uppercase() { - let tests = vec!["today", "last week", "next month", "1 year ago"]; - let now = Utc::now(); - for t in tests { - assert_eq!( - parse_relative_time_at_date(now, &t.to_uppercase()).unwrap(), - parse_relative_time_at_date(now, t).unwrap(), - ); - } - } } diff --git a/src/parse_weekday.rs b/src/parse_weekday.rs index d0ee4e8..436363c 100644 --- a/src/parse_weekday.rs +++ b/src/parse_weekday.rs @@ -1,100 +1,228 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -use chrono::Weekday; -use nom::branch::alt; -use nom::bytes::complete::tag; -use nom::combinator::value; -use nom::{self, IResult, Parser}; +use chrono::{DateTime, Datelike, Days, NaiveTime, TimeZone}; -// Helper macro to simplify tag matching -macro_rules! tag_match { - ($day:expr, $($pattern:expr),+) => { - value($day, alt(($(tag($pattern)),+))) - }; -} +use crate::{ + parse::{self, WeekdayItem}, + ParseDateTimeError, +}; -pub(crate) fn parse_weekday(s: &str) -> Option { - let s = s.trim().to_lowercase(); - let s = s.as_str(); +pub fn parse_weekday_at_date( + mut datetime: DateTime, + s: &str, +) -> Result, ParseDateTimeError> { + let WeekdayItem { weekday, ordinal } = + parse::parse_weekday(s.trim()).map_err(|_| ParseDateTimeError::InvalidInput)?; - let parse_result: IResult<&str, Weekday> = nom::combinator::all_consuming(alt(( - tag_match!(Weekday::Mon, "monday", "mon"), - tag_match!(Weekday::Tue, "tuesday", "tues", "tue"), - tag_match!(Weekday::Wed, "wednesday", "wednes", "wed"), - tag_match!(Weekday::Thu, "thursday", "thurs", "thur", "thu"), - tag_match!(Weekday::Fri, "friday", "fri"), - tag_match!(Weekday::Sat, "saturday", "sat"), - tag_match!(Weekday::Sun, "sunday", "sun"), - ))) - .parse(s); + datetime = datetime.with_time(NaiveTime::MIN).unwrap(); // infallible - match parse_result { - Ok((_, weekday)) => Some(weekday), - Err(_) => None, + let mut ordinal = ordinal.unwrap_or(0); + if datetime.weekday() != weekday && ordinal > 0 { + ordinal -= 1; } + + let days_delta = (i64::from(weekday.num_days_from_monday()) + - i64::from(datetime.weekday().num_days_from_monday())) + .rem_euclid(7) + + ordinal * 7; + + let datetime = if days_delta < 0 { + datetime.checked_sub_days(Days::new(-days_delta as u64)) + } else { + datetime.checked_add_days(Days::new(days_delta as u64)) + }; + datetime.ok_or(ParseDateTimeError::InvalidInput) } #[cfg(test)] mod tests { - use chrono::Weekday::*; + use super::*; + use chrono::{NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc}; - use crate::parse_weekday::parse_weekday; + #[test] + fn test_parse_weekday_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_weekday_at_date(now, "this wednesday").unwrap(), now); + assert_eq!(parse_weekday_at_date(now, "this wed").unwrap(), now); + // Other days + assert_eq!( + parse_weekday_at_date(now, "this thursday").unwrap(), + now.checked_add_days(Days::new(1)).unwrap() + ); + assert_eq!( + parse_weekday_at_date(now, "this thur").unwrap(), + now.checked_add_days(Days::new(1)).unwrap() + ); + assert_eq!( + parse_weekday_at_date(now, "this thu").unwrap(), + now.checked_add_days(Days::new(1)).unwrap() + ); + assert_eq!( + parse_weekday_at_date(now, "this friday").unwrap(), + now.checked_add_days(Days::new(2)).unwrap() + ); + assert_eq!( + parse_weekday_at_date(now, "this fri").unwrap(), + now.checked_add_days(Days::new(2)).unwrap() + ); + assert_eq!( + parse_weekday_at_date(now, "this saturday").unwrap(), + now.checked_add_days(Days::new(3)).unwrap() + ); + assert_eq!( + parse_weekday_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_weekday_at_date(now, "this sunday").unwrap(), + now.checked_add_days(Days::new(4)).unwrap() + ); + assert_eq!( + parse_weekday_at_date(now, "this sun").unwrap(), + now.checked_add_days(Days::new(4)).unwrap() + ); + assert_eq!( + parse_weekday_at_date(now, "this monday").unwrap(), + now.checked_add_days(Days::new(5)).unwrap() + ); + assert_eq!( + parse_weekday_at_date(now, "this mon").unwrap(), + now.checked_add_days(Days::new(5)).unwrap() + ); + assert_eq!( + parse_weekday_at_date(now, "this tuesday").unwrap(), + now.checked_add_days(Days::new(6)).unwrap() + ); + assert_eq!( + parse_weekday_at_date(now, "this tue").unwrap(), + now.checked_add_days(Days::new(6)).unwrap() + ); + } #[test] - fn test_valid_weekdays() { - let days = [ - ("mon", Mon), - ("monday", Mon), - ("tue", Tue), - ("tues", Tue), - ("tuesday", Tue), - ("wed", Wed), - ("wednes", Wed), - ("wednesday", Wed), - ("thu", Thu), - ("thursday", Thu), - ("fri", Fri), - ("friday", Fri), - ("sat", Sat), - ("saturday", Sat), - ("sun", Sun), - ("sunday", Sun), - ]; + fn test_parse_weekday_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_weekday_at_date(now, "last wed").unwrap(), + now.checked_sub_days(Days::new(7)).unwrap() + ); + // Check "last " + assert_eq!( + parse_weekday_at_date(now, "last thu").unwrap(), + now.checked_sub_days(Days::new(6)).unwrap() + ); + // Check "last " + assert_eq!( + parse_weekday_at_date(now, "last tue").unwrap(), + now.checked_sub_days(Days::new(1)).unwrap() + ); + } - for (name, weekday) in days { - assert_eq!(parse_weekday(name), Some(weekday)); - assert_eq!(parse_weekday(&format!(" {name}")), Some(weekday)); - assert_eq!(parse_weekday(&format!(" {name} ")), Some(weekday)); - assert_eq!(parse_weekday(&format!("{name} ")), Some(weekday)); + #[test] + fn test_parse_weekday_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_weekday_at_date(now, "next wed").unwrap(), + now.checked_add_days(Days::new(7)).unwrap() + ); + // Check "next " + assert_eq!( + parse_weekday_at_date(now, "next thu").unwrap(), + now.checked_add_days(Days::new(1)).unwrap() + ); + // Check "next " + assert_eq!( + parse_weekday_at_date(now, "next tue").unwrap(), + now.checked_add_days(Days::new(6)).unwrap() + ); + } - let (left, right) = name.split_at(1); - let (test_str1, test_str2) = ( - format!("{}{}", left.to_uppercase(), right.to_lowercase()), - format!("{}{}", left.to_lowercase(), right.to_uppercase()), - ); + #[test] + fn test_parse_weekday_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_weekday_at_date(now, "1 wed").unwrap(), + now.checked_add_days(Days::new(7)).unwrap() + ); + assert_eq!( + parse_weekday_at_date(now, "1 thu").unwrap(), + now.checked_add_days(Days::new(1)).unwrap() + ); + assert_eq!( + parse_weekday_at_date(now, "1 tue").unwrap(), + now.checked_add_days(Days::new(6)).unwrap() + ); + assert_eq!( + parse_weekday_at_date(now, "2 wed").unwrap(), + now.checked_add_days(Days::new(14)).unwrap() + ); + assert_eq!( + parse_weekday_at_date(now, "2 thu").unwrap(), + now.checked_add_days(Days::new(8)).unwrap() + ); + assert_eq!( + parse_weekday_at_date(now, "2 tue").unwrap(), + now.checked_add_days(Days::new(13)).unwrap() + ); + } - assert_eq!(parse_weekday(&test_str1), Some(weekday)); - assert_eq!(parse_weekday(&test_str2), Some(weekday)); - } + #[test] + fn test_parse_weekday_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_weekday_at_date(now, "this wed").unwrap(), + now_midnight + ); + assert_eq!( + parse_weekday_at_date(now, "last wed").unwrap(), + now_midnight.checked_sub_days(Days::new(7)).unwrap() + ); + assert_eq!( + parse_weekday_at_date(now, "next wed").unwrap(), + now_midnight.checked_add_days(Days::new(7)).unwrap() + ); } #[test] - fn test_invalid_weekdays() { - let days = [ - "mond", - "tuesda", - "we", - "th", - "fr", - "sa", - "su", - "garbageday", - "tomorrow", - "yesterday", - ]; - for day in days { - assert!(parse_weekday(day).is_none()); - } + fn test_parse_weekday_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_weekday_at_date(now, "this fooday"), + Err(ParseDateTimeError::InvalidInput) + ); } }