Repository: uutils/parse_datetime
Pull request #128: Parse relative weekdays like "next monday"
Author: login: dan-hipschman
Created: 2025-04-21T19:20:44Z | Merged: 2025-04-25T07:24:49Z

Fixes https://github.com/uutils/parse_datetime/issues/127 and https://github.com/uutils/coreutils/issues/7662. Enables GNU test `tests/date/date-next-dow.pl` to pass:
```
$ bash util/run-gnu-test.sh tests/date/date-next-dow.pl
...
PASS: tests/date/date-next-dow.pl
============================================================================
Testsuite summary for GNU coreutils 9.6-dirty
============================================================================
# TOTAL: 1
# PASS:  1
# SKIP:  0
# XFAIL: 0
# FAIL:  0
# XPASS: 0
# ERROR: 0
============================================================================
```
Note, this also allows some formats that GNU date doesn't accept, but I think they're reasonable:
```
$ date +"%a %F"
Mon 2025-04-21

$ set d "1 monday ago" ; date -d $d +"%a %F" ; cargo run -q date -d $d +"%a %F"
date: invalid date ‘1 monday ago’
Mon 2025-04-14

$ set d "-1 monday" ; date -d $d +"%a %F" ; cargo run -q date -d $d +"%a %F"
date: invalid date ‘-1 monday’
Mon 2025-04-14

$ set d "+1 monday" ; date -d $d +"%a %F" ; cargo run -q date -d $d +"%a %F"
date: invalid date ‘+1 monday’
Mon 2025-04-28

# Strangely enough, GNU does recognize this format:
$ set d "1 monday" ; date -d $d +"%a %F" ; cargo run -q date -d $d +"%a %F"
Mon 2025-04-28
Mon 2025-04-28
```


Diff:
diff --git a/Cargo.lock b/Cargo.lock
index 46af8c9..064a8fc 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -144,7 +144,7 @@ checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
 
 [[package]]
 name = "parse_datetime"
-version = "0.8.0"
+version = "0.9.0"
 dependencies = [
  "chrono",
  "nom",
diff --git a/Cargo.toml b/Cargo.toml
index 1c75c90..751b38f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,7 +1,7 @@
 [package]
 name = "parse_datetime"
 description = "parsing human-readable time strings and converting them to a DateTime"
-version = "0.8.0"
+version = "0.9.0"
 edition = "2021"
 license = "MIT"
 repository = "https://github.com/uutils/parse_datetime"
diff --git a/src/parse_relative_time.rs b/src/parse_relative_time.rs
index 782949b..1c193bf 100644
--- a/src/parse_relative_time.rs
+++ b/src/parse_relative_time.rs
@@ -1,8 +1,9 @@
 // For the full copyright and license information, please view the LICENSE
 // file that was distributed with this source code.
-use crate::ParseDateTimeError;
+use crate::{parse_weekday::parse_weekday, ParseDateTimeError};
 use chrono::{
-    DateTime, Datelike, Days, Duration, LocalResult, Months, NaiveDate, NaiveDateTime, TimeZone,
+    DateTime, Datelike, Days, Duration, LocalResult, Months, NaiveDate, NaiveDateTime, NaiveTime,
+    TimeZone, Weekday,
 };
 use regex::Regex;
 
@@ -61,7 +62,7 @@ pub fn parse_relative_time_at_date<T: TimeZone>(
         r"(?x)
         (?:(?P<value>[-+]?\s*\d*)\s*)?
         (\s*(?P<direction>next|this|last)?\s*)?
-        (?P<unit>years?|months?|fortnights?|weeks?|days?|hours?|h|minutes?|mins?|m|seconds?|secs?|s|yesterday|tomorrow|now|today)
+        (?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
         (\s*(?P<separator>and|,)?\s*)?
         (\s*(?P<ago>ago)?)?",
     )?;
@@ -80,16 +81,19 @@ pub fn parse_relative_time_at_date<T: TimeZone>(
             .chars()
             .filter(|c| !c.is_whitespace()) // Remove potential space between +/- and number
             .collect();
+        let direction = capture.name("direction").map_or("", |d| d.as_str());
         let value = if value_str.is_empty() {
-            1
+            if direction == "this" {
+                0
+            } else {
+                1
+            }
         } else {
             value_str
                 .parse::<i64>()
                 .map_err(|_| ParseDateTimeError::InvalidInput)?
         };
 
-        let direction = capture.name("direction").map_or("", |d| d.as_str());
-
         if direction == "last" {
             is_ago = true;
         }
@@ -103,27 +107,26 @@ pub fn parse_relative_time_at_date<T: TimeZone>(
             is_ago = true;
         }
 
-        let new_datetime = if direction == "this" {
-            add_days(datetime, 0, is_ago)
-        } else {
-            match unit {
-                "years" | "year" => add_months(datetime, value * 12, is_ago),
-                "months" | "month" => add_months(datetime, value, is_ago),
-                "fortnights" | "fortnight" => add_days(datetime, value * 14, is_ago),
-                "weeks" | "week" => add_days(datetime, value * 7, is_ago),
-                "days" | "day" => add_days(datetime, value, is_ago),
-                "hours" | "hour" | "h" => add_duration(datetime, Duration::hours(value), is_ago),
-                "minutes" | "minute" | "mins" | "min" | "m" => {
-                    add_duration(datetime, Duration::minutes(value), is_ago)
-                }
-                "seconds" | "second" | "secs" | "sec" | "s" => {
-                    add_duration(datetime, Duration::seconds(value), is_ago)
-                }
-                "yesterday" => add_days(datetime, 1, true),
-                "tomorrow" => add_days(datetime, 1, false),
-                "now" | "today" => Some(datetime),
-                _ => None,
+        let new_datetime = match unit {
+            "years" | "year" => add_months(datetime, value * 12, is_ago),
+            "months" | "month" => add_months(datetime, value, is_ago),
+            "fortnights" | "fortnight" => add_days(datetime, value * 14, is_ago),
+            "weeks" | "week" => add_days(datetime, value * 7, is_ago),
+            "days" | "day" => add_days(datetime, value, is_ago),
+            "hours" | "hour" | "h" => add_duration(datetime, Duration::hours(value), is_ago),
+            "minutes" | "minute" | "mins" | "min" | "m" => {
+                add_duration(datetime, Duration::minutes(value), is_ago)
+            }
+            "seconds" | "second" | "secs" | "sec" | "s" => {
+                add_duration(datetime, Duration::seconds(value), is_ago)
             }
+            "yesterday" => add_days(datetime, 1, true),
+            "tomorrow" => add_days(datetime, 1, false),
+            "now" | "today" => Some(datetime),
+            _ => capture
+                .name("weekday")
+                .and_then(|weekday| parse_weekday(weekday.as_str()))
+                .and_then(|weekday| adjust_for_weekday(datetime, weekday, value, is_ago)),
         };
         datetime = match new_datetime {
             Some(dt) => dt,
@@ -148,6 +151,25 @@ pub fn parse_relative_time_at_date<T: TimeZone>(
     }
 }
 
+fn adjust_for_weekday<T: TimeZone>(
+    mut datetime: DateTime<T>,
+    weekday: Weekday,
+    mut amount: i64,
+    is_ago: bool,
+) -> Option<DateTime<T>> {
+    let mut same_day = true;
+    // last/this/next <weekday> truncates the time to midnight
+    datetime = datetime.with_time(NaiveTime::MIN).unwrap();
+    while datetime.weekday() != weekday {
+        datetime = add_days(datetime, 1, is_ago)?;
+        same_day = false;
+    }
+    if !same_day && 0 < amount {
+        amount -= 1;
+    }
+    add_days(datetime, amount * 7, is_ago)
+}
+
 fn add_months<T: TimeZone>(
     datetime: DateTime<T>,
     months: i64,
@@ -810,4 +832,193 @@ mod tests {
         let result = parse_relative_time_at_date(now, "invalid 1r");
         assert_eq!(result, Err(ParseDateTimeError::InvalidInput));
     }
+
+    #[test]
+    fn test_parse_relative_time_at_date_this_weekday() {
+        // Jan 1 2025 is a Wed
+        let now = Utc.from_utc_datetime(&NaiveDateTime::new(
+            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
+            NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
+        ));
+        // Check "this <same weekday>"
+        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 <same weekday>"
+        assert_eq!(
+            parse_relative_time_at_date(now, "last wed").unwrap(),
+            now.checked_sub_days(Days::new(7)).unwrap()
+        );
+        // Check "last <day after today>"
+        assert_eq!(
+            parse_relative_time_at_date(now, "last thu").unwrap(),
+            now.checked_sub_days(Days::new(6)).unwrap()
+        );
+        // Check "last <day before today>"
+        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 <same weekday>"
+        assert_eq!(
+            parse_relative_time_at_date(now, "next wed").unwrap(),
+            now.checked_add_days(Days::new(7)).unwrap()
+        );
+        // Check "next <day after today>"
+        assert_eq!(
+            parse_relative_time_at_date(now, "next thu").unwrap(),
+            now.checked_add_days(Days::new(1)).unwrap()
+        );
+        // Check "next <day before today>"
+        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)
+        );
+    }
 }


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR adds support for parsing relative weekdays (e.g. "next monday") in human-readable time strings, fixing related issues and enabling GNU test cases to pass. Key changes include extending the regex pattern to capture weekday strings, introducing the adjust_for_weekday helper function for calculating the correct date, and adding comprehensive tests for "this", "last", "next", and numbered weekday specifications.

### Reviewed Changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

| File                       | Description                                                     |
| -------------------------- | --------------------------------------------------------------- |
| src/parse_relative_time.rs | Updated regex and parsing logic to capture weekday inputs and added tests for relative weekday manipulations |
| Cargo.toml                 | Bumped version from 0.8.0 to 0.9.0 to reflect the new feature    |




state: COMMENTED
author_association: NONE
submitted_at: 2025-04-21T20:27:42Z
user: login: sylvestre
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T07:53:42Z
user: login: dan-hipschman
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T16:48:13Z

Review comments:
diff_hunk: @@ -1,7 +1,7 @@
 [package]
 name = "parse_datetime"
 description = "parsing human-readable time strings and converting them to a DateTime"
-version = "0.8.0"
+version = "0.9.0"
path: Cargo.toml
user: login: sylvestre
body: i wish that was done with a different commit message and a different PR
i missed it, sorry

created_at: 2025-04-25T07:53:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,7 +1,7 @@
 [package]
 name = "parse_datetime"
 description = "parsing human-readable time strings and converting them to a DateTime"
-version = "0.8.0"
+version = "0.9.0"
path: Cargo.toml
user: login: dan-hipschman
body: Sorry I wasn't sure whether to bump it or let someone else
created_at: 2025-04-25T16:48:13Z
author_association: CONTRIBUTOR

PR comments:
user: login: sylvestre
created_at: 2025-04-22T07:55:26Z
body: it is now conflicting, could you please rebase it? thanks

author_association: CONTRIBUTOR
user: login: dan-hipschman
created_at: 2025-04-22T17:14:49Z
body: > it is now conflicting, could you please rebase it? thanks

Yes, no problem. Rebased.

BTW I noticed a case I missed before, so fixed it and added more tests. The case is when using weekdays, GNU date truncates the time to midnight. This output is with parse_datetime from this PR:
```
# Preserves time
$ set d "today" ; date -Rd "$d" ; cargo run -q date -Rd "$d"
Tue, 22 Apr 2025 10:05:03 -0700
Tue, 22 Apr 2025 10:05:03 -0700

$ set d "now + 1 day" ; date -Rd "$d" ; cargo run -q date -Rd "$d"
Wed, 23 Apr 2025 10:05:13 -0700
Wed, 23 Apr 2025 10:05:13 -0700

# Truncates time
$ set d "wed" ; date -Rd "$d" ; cargo run -q date -Rd "$d"
Wed, 23 Apr 2025 00:00:00 -0700
Wed, 23 Apr 2025 00:00:00 -0700

$ set d "last wed" ; date -Rd "$d" ; cargo run -q date -Rd "$d"
Wed, 16 Apr 2025 00:00:00 -0700
Wed, 16 Apr 2025 00:00:00 -0700
```

Confirmed unit tests for parse_datetime and coreutils pass, and GNU test `tests/date/date-next-dow.pl` still passes.
author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-04-25T07:24:57Z
body: ## [Codecov](https://app.codecov.io/gh/uutils/parse_datetime/pull/128?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uutils) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 0.00%. Comparing base [(`9b22a18`)](https://app.codecov.io/gh/uutils/parse_datetime/commit/9b22a18bce7d44f530890cba258d6d9e2ebefcc0?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uutils) to head [(`65fb914`)](https://app.codecov.io/gh/uutils/parse_datetime/commit/65fb9140d2142c24a420878f8ee5218d916e9606?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uutils).
> Report is 2 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@     Coverage Diff     @@
##   main   #128   +/-   ##
===========================
===========================

```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/uutils/parse_datetime/pull/128?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uutils).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uutils).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: cakebaker
created_at: 2025-04-25T07:24:58Z
body: Thanks!
author_association: COLLABORATOR