Skip to content

Commit b9630d4

Browse files
authored
Merge pull request #128 from dan-hipschman/parse-relative-weekdays
Parse relative weekdays like "next monday"
2 parents 9b22a18 + 65fb914 commit b9630d4

File tree

3 files changed

+239
-28
lines changed

3 files changed

+239
-28
lines changed

Cargo.lock

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[package]
22
name = "parse_datetime"
33
description = "parsing human-readable time strings and converting them to a DateTime"
4-
version = "0.8.0"
4+
version = "0.9.0"
55
edition = "2021"
66
license = "MIT"
77
repository = "https://github.com/uutils/parse_datetime"

src/parse_relative_time.rs

+237-26
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
// For the full copyright and license information, please view the LICENSE
22
// file that was distributed with this source code.
3-
use crate::ParseDateTimeError;
3+
use crate::{parse_weekday::parse_weekday, ParseDateTimeError};
44
use chrono::{
5-
DateTime, Datelike, Days, Duration, LocalResult, Months, NaiveDate, NaiveDateTime, TimeZone,
5+
DateTime, Datelike, Days, Duration, LocalResult, Months, NaiveDate, NaiveDateTime, NaiveTime,
6+
TimeZone, Weekday,
67
};
78
use regex::Regex;
89

@@ -61,7 +62,7 @@ pub fn parse_relative_time_at_date<T: TimeZone>(
6162
r"(?x)
6263
(?:(?P<value>[-+]?\s*\d*)\s*)?
6364
(\s*(?P<direction>next|this|last)?\s*)?
64-
(?P<unit>years?|months?|fortnights?|weeks?|days?|hours?|h|minutes?|mins?|m|seconds?|secs?|s|yesterday|tomorrow|now|today)
65+
(?P<unit>years?|months?|fortnights?|weeks?|days?|hours?|h|minutes?|mins?|m|seconds?|secs?|s|yesterday|tomorrow|now|today|(?P<weekday>[a-z]{3,9}))\b
6566
(\s*(?P<separator>and|,)?\s*)?
6667
(\s*(?P<ago>ago)?)?",
6768
)?;
@@ -80,16 +81,19 @@ pub fn parse_relative_time_at_date<T: TimeZone>(
8081
.chars()
8182
.filter(|c| !c.is_whitespace()) // Remove potential space between +/- and number
8283
.collect();
84+
let direction = capture.name("direction").map_or("", |d| d.as_str());
8385
let value = if value_str.is_empty() {
84-
1
86+
if direction == "this" {
87+
0
88+
} else {
89+
1
90+
}
8591
} else {
8692
value_str
8793
.parse::<i64>()
8894
.map_err(|_| ParseDateTimeError::InvalidInput)?
8995
};
9096

91-
let direction = capture.name("direction").map_or("", |d| d.as_str());
92-
9397
if direction == "last" {
9498
is_ago = true;
9599
}
@@ -103,27 +107,26 @@ pub fn parse_relative_time_at_date<T: TimeZone>(
103107
is_ago = true;
104108
}
105109

106-
let new_datetime = if direction == "this" {
107-
add_days(datetime, 0, is_ago)
108-
} else {
109-
match unit {
110-
"years" | "year" => add_months(datetime, value * 12, is_ago),
111-
"months" | "month" => add_months(datetime, value, is_ago),
112-
"fortnights" | "fortnight" => add_days(datetime, value * 14, is_ago),
113-
"weeks" | "week" => add_days(datetime, value * 7, is_ago),
114-
"days" | "day" => add_days(datetime, value, is_ago),
115-
"hours" | "hour" | "h" => add_duration(datetime, Duration::hours(value), is_ago),
116-
"minutes" | "minute" | "mins" | "min" | "m" => {
117-
add_duration(datetime, Duration::minutes(value), is_ago)
118-
}
119-
"seconds" | "second" | "secs" | "sec" | "s" => {
120-
add_duration(datetime, Duration::seconds(value), is_ago)
121-
}
122-
"yesterday" => add_days(datetime, 1, true),
123-
"tomorrow" => add_days(datetime, 1, false),
124-
"now" | "today" => Some(datetime),
125-
_ => None,
110+
let new_datetime = match unit {
111+
"years" | "year" => add_months(datetime, value * 12, is_ago),
112+
"months" | "month" => add_months(datetime, value, is_ago),
113+
"fortnights" | "fortnight" => add_days(datetime, value * 14, is_ago),
114+
"weeks" | "week" => add_days(datetime, value * 7, is_ago),
115+
"days" | "day" => add_days(datetime, value, is_ago),
116+
"hours" | "hour" | "h" => add_duration(datetime, Duration::hours(value), is_ago),
117+
"minutes" | "minute" | "mins" | "min" | "m" => {
118+
add_duration(datetime, Duration::minutes(value), is_ago)
119+
}
120+
"seconds" | "second" | "secs" | "sec" | "s" => {
121+
add_duration(datetime, Duration::seconds(value), is_ago)
126122
}
123+
"yesterday" => add_days(datetime, 1, true),
124+
"tomorrow" => add_days(datetime, 1, false),
125+
"now" | "today" => Some(datetime),
126+
_ => capture
127+
.name("weekday")
128+
.and_then(|weekday| parse_weekday(weekday.as_str()))
129+
.and_then(|weekday| adjust_for_weekday(datetime, weekday, value, is_ago)),
127130
};
128131
datetime = match new_datetime {
129132
Some(dt) => dt,
@@ -148,6 +151,25 @@ pub fn parse_relative_time_at_date<T: TimeZone>(
148151
}
149152
}
150153

154+
fn adjust_for_weekday<T: TimeZone>(
155+
mut datetime: DateTime<T>,
156+
weekday: Weekday,
157+
mut amount: i64,
158+
is_ago: bool,
159+
) -> Option<DateTime<T>> {
160+
let mut same_day = true;
161+
// last/this/next <weekday> truncates the time to midnight
162+
datetime = datetime.with_time(NaiveTime::MIN).unwrap();
163+
while datetime.weekday() != weekday {
164+
datetime = add_days(datetime, 1, is_ago)?;
165+
same_day = false;
166+
}
167+
if !same_day && 0 < amount {
168+
amount -= 1;
169+
}
170+
add_days(datetime, amount * 7, is_ago)
171+
}
172+
151173
fn add_months<T: TimeZone>(
152174
datetime: DateTime<T>,
153175
months: i64,
@@ -810,4 +832,193 @@ mod tests {
810832
let result = parse_relative_time_at_date(now, "invalid 1r");
811833
assert_eq!(result, Err(ParseDateTimeError::InvalidInput));
812834
}
835+
836+
#[test]
837+
fn test_parse_relative_time_at_date_this_weekday() {
838+
// Jan 1 2025 is a Wed
839+
let now = Utc.from_utc_datetime(&NaiveDateTime::new(
840+
NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
841+
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
842+
));
843+
// Check "this <same weekday>"
844+
assert_eq!(
845+
parse_relative_time_at_date(now, "this wednesday").unwrap(),
846+
now
847+
);
848+
assert_eq!(parse_relative_time_at_date(now, "this wed").unwrap(), now);
849+
// Other days
850+
assert_eq!(
851+
parse_relative_time_at_date(now, "this thursday").unwrap(),
852+
now.checked_add_days(Days::new(1)).unwrap()
853+
);
854+
assert_eq!(
855+
parse_relative_time_at_date(now, "this thur").unwrap(),
856+
now.checked_add_days(Days::new(1)).unwrap()
857+
);
858+
assert_eq!(
859+
parse_relative_time_at_date(now, "this thu").unwrap(),
860+
now.checked_add_days(Days::new(1)).unwrap()
861+
);
862+
assert_eq!(
863+
parse_relative_time_at_date(now, "this friday").unwrap(),
864+
now.checked_add_days(Days::new(2)).unwrap()
865+
);
866+
assert_eq!(
867+
parse_relative_time_at_date(now, "this fri").unwrap(),
868+
now.checked_add_days(Days::new(2)).unwrap()
869+
);
870+
assert_eq!(
871+
parse_relative_time_at_date(now, "this saturday").unwrap(),
872+
now.checked_add_days(Days::new(3)).unwrap()
873+
);
874+
assert_eq!(
875+
parse_relative_time_at_date(now, "this sat").unwrap(),
876+
now.checked_add_days(Days::new(3)).unwrap()
877+
);
878+
// "this" with a day of the week that comes before today should return the next instance of
879+
// that day
880+
assert_eq!(
881+
parse_relative_time_at_date(now, "this sunday").unwrap(),
882+
now.checked_add_days(Days::new(4)).unwrap()
883+
);
884+
assert_eq!(
885+
parse_relative_time_at_date(now, "this sun").unwrap(),
886+
now.checked_add_days(Days::new(4)).unwrap()
887+
);
888+
assert_eq!(
889+
parse_relative_time_at_date(now, "this monday").unwrap(),
890+
now.checked_add_days(Days::new(5)).unwrap()
891+
);
892+
assert_eq!(
893+
parse_relative_time_at_date(now, "this mon").unwrap(),
894+
now.checked_add_days(Days::new(5)).unwrap()
895+
);
896+
assert_eq!(
897+
parse_relative_time_at_date(now, "this tuesday").unwrap(),
898+
now.checked_add_days(Days::new(6)).unwrap()
899+
);
900+
assert_eq!(
901+
parse_relative_time_at_date(now, "this tue").unwrap(),
902+
now.checked_add_days(Days::new(6)).unwrap()
903+
);
904+
}
905+
906+
#[test]
907+
fn test_parse_relative_time_at_date_last_weekday() {
908+
// Jan 1 2025 is a Wed
909+
let now = Utc.from_utc_datetime(&NaiveDateTime::new(
910+
NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
911+
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
912+
));
913+
// Check "last <same weekday>"
914+
assert_eq!(
915+
parse_relative_time_at_date(now, "last wed").unwrap(),
916+
now.checked_sub_days(Days::new(7)).unwrap()
917+
);
918+
// Check "last <day after today>"
919+
assert_eq!(
920+
parse_relative_time_at_date(now, "last thu").unwrap(),
921+
now.checked_sub_days(Days::new(6)).unwrap()
922+
);
923+
// Check "last <day before today>"
924+
assert_eq!(
925+
parse_relative_time_at_date(now, "last tue").unwrap(),
926+
now.checked_sub_days(Days::new(1)).unwrap()
927+
);
928+
}
929+
930+
#[test]
931+
fn test_parse_relative_time_at_date_next_weekday() {
932+
// Jan 1 2025 is a Wed
933+
let now = Utc.from_utc_datetime(&NaiveDateTime::new(
934+
NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
935+
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
936+
));
937+
// Check "next <same weekday>"
938+
assert_eq!(
939+
parse_relative_time_at_date(now, "next wed").unwrap(),
940+
now.checked_add_days(Days::new(7)).unwrap()
941+
);
942+
// Check "next <day after today>"
943+
assert_eq!(
944+
parse_relative_time_at_date(now, "next thu").unwrap(),
945+
now.checked_add_days(Days::new(1)).unwrap()
946+
);
947+
// Check "next <day before today>"
948+
assert_eq!(
949+
parse_relative_time_at_date(now, "next tue").unwrap(),
950+
now.checked_add_days(Days::new(6)).unwrap()
951+
);
952+
}
953+
954+
#[test]
955+
fn test_parse_relative_time_at_date_number_weekday() {
956+
// Jan 1 2025 is a Wed
957+
let now = Utc.from_utc_datetime(&NaiveDateTime::new(
958+
NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
959+
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
960+
));
961+
assert_eq!(
962+
parse_relative_time_at_date(now, "1 wed").unwrap(),
963+
now.checked_add_days(Days::new(7)).unwrap()
964+
);
965+
assert_eq!(
966+
parse_relative_time_at_date(now, "1 thu").unwrap(),
967+
now.checked_add_days(Days::new(1)).unwrap()
968+
);
969+
assert_eq!(
970+
parse_relative_time_at_date(now, "1 tue").unwrap(),
971+
now.checked_add_days(Days::new(6)).unwrap()
972+
);
973+
assert_eq!(
974+
parse_relative_time_at_date(now, "2 wed").unwrap(),
975+
now.checked_add_days(Days::new(14)).unwrap()
976+
);
977+
assert_eq!(
978+
parse_relative_time_at_date(now, "2 thu").unwrap(),
979+
now.checked_add_days(Days::new(8)).unwrap()
980+
);
981+
assert_eq!(
982+
parse_relative_time_at_date(now, "2 tue").unwrap(),
983+
now.checked_add_days(Days::new(13)).unwrap()
984+
);
985+
}
986+
987+
#[test]
988+
fn test_parse_relative_time_at_date_weekday_truncates_time() {
989+
// Jan 1 2025 is a Wed
990+
let now = Utc.from_utc_datetime(&NaiveDateTime::new(
991+
NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
992+
NaiveTime::from_hms_opt(12, 0, 0).unwrap(),
993+
));
994+
let now_midnight = Utc.from_utc_datetime(&NaiveDateTime::new(
995+
NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
996+
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
997+
));
998+
assert_eq!(
999+
parse_relative_time_at_date(now, "this wed").unwrap(),
1000+
now_midnight
1001+
);
1002+
assert_eq!(
1003+
parse_relative_time_at_date(now, "last wed").unwrap(),
1004+
now_midnight.checked_sub_days(Days::new(7)).unwrap()
1005+
);
1006+
assert_eq!(
1007+
parse_relative_time_at_date(now, "next wed").unwrap(),
1008+
now_midnight.checked_add_days(Days::new(7)).unwrap()
1009+
);
1010+
}
1011+
1012+
#[test]
1013+
fn test_parse_relative_time_at_date_invalid_weekday() {
1014+
// Jan 1 2025 is a Wed
1015+
let now = Utc.from_utc_datetime(&NaiveDateTime::new(
1016+
NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1017+
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
1018+
));
1019+
assert_eq!(
1020+
parse_relative_time_at_date(now, "this fooday"),
1021+
Err(ParseDateTimeError::InvalidInput)
1022+
);
1023+
}
8131024
}

0 commit comments

Comments
 (0)