14 Commits

Author SHA1 Message Date
Daniel Hofstetter b9630d4bd3 Merge pull request #128 from dan-hipschman/parse-relative-weekdays
Parse relative weekdays like "next monday"
2025-04-25 09:24:49 +02:00
Dan Hipschman 65fb9140d2 Parse relative weekdays like "next monday" 2025-04-22 10:00:13 -07:00
Sylvestre Ledru 9b22a18bce Merge pull request #130 from cakebaker/fix_some_clippy_pedantic_issues
clippy: fix some warnings from clippy::pedantic
2025-04-22 10:42:20 +02:00
Daniel Hofstetter 28b788a300 clippy: fix warnings from redundant_else lint 2025-04-22 10:34:39 +02:00
Daniel Hofstetter f6b6b3e4ea clippy: fix warning from manual_let_else lint 2025-04-22 10:31:02 +02:00
Daniel Hofstetter 1a93bb4e3e clippy: fix warning from needless_continue lint 2025-04-22 10:27:13 +02:00
Daniel Hofstetter 9fc966308e clippy: remove unnecessary semicolon 2025-04-22 10:12:20 +02:00
Sylvestre Ledru 7119769488 Merge pull request #121 from cakebaker/rename_const
Rename `TWELVEHOUR` to `TWELVE_HOUR`
2025-04-22 09:56:39 +02:00
Daniel Hofstetter 46a82b70b3 Rename TWELVEHOUR to TWELVE_HOUR 2025-04-22 09:52:19 +02:00
Dan Hipschman e7ed14e8a4 Allow spaces between +/- and number (#129)
* Allow spaces between +/- and number

* Remove space to make fmt job pass

---------

Co-authored-by: Daniel Hofstetter <daniel.hofstetter@42dh.com>
2025-04-22 09:44:09 +02:00
Daniel Hofstetter 9184428407 Merge pull request #126 from uutils/renovate/chrono-0.x-lockfile
Update Rust crate chrono to v0.4.40
2025-02-26 13:44:20 +01:00
renovate[bot] 25825df8b7 Update Rust crate chrono to v0.4.40 2025-02-26 11:56:48 +00:00
Sylvestre Ledru 0dbce58014 Merge pull request #120 from cakebaker/readme_fix_sentence
README.md: fix incorrect sentence
2025-02-17 20:31:53 +01:00
Daniel Hofstetter 0c0a054f70 README.md: fix incorrect sentence 2025-02-17 14:51:34 +01:00
7 changed files with 287 additions and 52 deletions
Generated
+10 -4
View File
@@ -52,14 +52,14 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "chrono"
version = "0.4.39"
version = "0.4.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825"
checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c"
dependencies = [
"android-tzdata",
"iana-time-zone",
"num-traits",
"windows-targets",
"windows-link",
]
[[package]]
@@ -144,7 +144,7 @@ checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
[[package]]
name = "parse_datetime"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"chrono",
"nom",
@@ -279,6 +279,12 @@ dependencies = [
"windows-targets",
]
[[package]]
name = "windows-link"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3"
[[package]]
name = "windows-targets"
version = "0.52.5"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -16,7 +16,7 @@ A Rust crate for parsing human-readable relative time strings and human-readable
## Usage
Add this to your `Cargo.toml`:
Add `parse_datetime` to your `Cargo.toml` with:
```
cargo add parse_datetime
+10 -4
View File
@@ -63,14 +63,14 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "chrono"
version = "0.4.39"
version = "0.4.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825"
checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c"
dependencies = [
"android-tzdata",
"iana-time-zone",
"num-traits",
"windows-targets",
"windows-link",
]
[[package]]
@@ -184,7 +184,7 @@ checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3"
[[package]]
name = "parse_datetime"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"chrono",
"nom",
@@ -325,6 +325,12 @@ dependencies = [
"windows-targets",
]
[[package]]
name = "windows-link"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3"
[[package]]
name = "windows-targets"
version = "0.52.6"
+2 -4
View File
@@ -271,9 +271,8 @@ where
if let Ok(parsed) = DateTime::parse_from_str(&tmp_s[0..n], fmt) {
if tmp_s == s.as_ref() {
return Some((parsed, n));
} else {
return Some((parsed, n - 1));
}
return Some((parsed, n - 1));
}
}
}
@@ -351,9 +350,8 @@ where
if let Ok(parsed) = DateTime::parse_from_str(&ts, &f) {
if tmp_s == s.as_ref() {
return Some((parsed, n));
} else {
return Some((parsed, n - 1));
}
return Some((parsed, n - 1));
}
}
}
+256 -29
View File
@@ -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;
@@ -59,9 +60,9 @@ pub fn parse_relative_time_at_date<T: TimeZone>(
}
let time_pattern: Regex = Regex::new(
r"(?x)
(?:(?P<value>[-+]?\d*)\s*)?
(?:(?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)?)?",
)?;
@@ -73,20 +74,26 @@ pub fn parse_relative_time_at_date<T: TimeZone>(
for capture in time_pattern.captures_iter(s) {
captures_processed += 1;
let value_str = capture
let value_str: String = capture
.name("value")
.ok_or(ParseDateTimeError::InvalidInput)?
.as_str();
.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());
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;
}
@@ -100,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,
@@ -145,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,
@@ -510,6 +535,19 @@ mod tests {
);
}
#[test]
fn test_spaces() {
let now = Utc::now();
assert_eq!(
parse_relative_time_at_date(now, "+ 1 hour").unwrap(),
now.checked_add_signed(Duration::hours(1)).unwrap()
);
assert_eq!(
parse_relative_time_at_date(now, "- 1 hour").unwrap(),
now.checked_sub_signed(Duration::hours(1)).unwrap()
);
}
#[test]
fn test_invalid_input() {
let result = parse_duration("foobar");
@@ -794,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)
);
}
}
+7 -9
View File
@@ -4,7 +4,7 @@ use regex::Regex;
mod time_only_formats {
pub const HH_MM: &str = "%R";
pub const HH_MM_SS: &str = "%T";
pub const TWELVEHOUR: &str = "%r";
pub const TWELVE_HOUR: &str = "%r";
}
/// Convert a military time zone string to a time zone offset.
@@ -55,16 +55,14 @@ fn parse_time_with_offset_multi(
for fmt in [
time_only_formats::HH_MM,
time_only_formats::HH_MM_SS,
time_only_formats::TWELVEHOUR,
time_only_formats::TWELVE_HOUR,
] {
let parsed = match NaiveTime::parse_from_str(s, fmt) {
Ok(t) => t,
Err(_) => continue,
let Ok(parsed) = NaiveTime::parse_from_str(s, fmt) else {
continue;
};
let parsed_dt = date.date_naive().and_time(parsed);
match offset.from_local_datetime(&parsed_dt).single() {
Some(dt) => return Some(dt),
None => continue,
if let Some(dt) = offset.from_local_datetime(&parsed_dt).single() {
return Some(dt);
}
}
None
@@ -84,7 +82,7 @@ pub(crate) fn parse_time_only(date: DateTime<Local>, s: &str) -> Option<DateTime
offset_in_sec += minutes.as_str().parse::<i32>().unwrap() * 60;
}
_ => (),
};
}
offset_in_sec *= if &captures["sign"] == "-" { -1 } else { 1 };
FixedOffset::east_opt(offset_in_sec)
}