8 Commits

Author SHA1 Message Date
Daniel Hofstetter ba69fb8772 Merge pull request #239 from cakebaker/bump_version_to_0_13_1
chore: bump version to 0.13.1
2025-10-04 17:27:00 +02:00
Daniel Hofstetter 912bc4e706 chore: bump version to 0.13.1 2025-10-04 17:19:18 +02:00
Daniel Hofstetter c239ed0b36 Merge pull request #238 from stillbeingnick/bugfix/issue/coreutils-date-invalid
Changing ErrMode::Cut to ErrMode::Backtrack to allow alt parser flow (Fix for uutils/coreutils issue 8754)
2025-10-04 17:00:00 +02:00
Nicholas Still 3074fe3baa Change ErrMode::Cut to ErrMode::Backtrack to allow alt parser flow 2025-10-04 16:55:43 +02:00
Daniel Hofstetter 29e368662c Merge pull request #237 from yuankunzhang/add-comments-for-build-function
docs: add comments for the build function
2025-09-28 15:21:47 +02:00
yuankunzhang 1e757df39b docs: add comments for the build function 2025-09-27 21:34:09 +08:00
Daniel Hofstetter 7a32b1ee3d Merge pull request #236 from yuankunzhang/remove-regex-dependency
chore: remove regex crate as dependency
2025-09-27 14:29:14 +02:00
yuankunzhang c898cd84de chore: remove regex crate as dependency 2025-09-27 20:10:25 +08:00
5 changed files with 108 additions and 42 deletions
Generated
+1 -2
View File
@@ -158,11 +158,10 @@ dependencies = [
[[package]]
name = "parse_datetime"
version = "0.13.0"
version = "0.13.1"
dependencies = [
"jiff",
"num-traits",
"regex",
"rstest",
"winnow",
]
+1 -2
View File
@@ -1,7 +1,7 @@
[package]
name = "parse_datetime"
description = "parsing human-readable time strings and converting them to a DateTime"
version = "0.13.0"
version = "0.13.1"
edition = "2021"
license = "MIT"
repository = "https://github.com/uutils/parse_datetime"
@@ -9,7 +9,6 @@ readme = "README.md"
rust-version = "1.71.1"
[dependencies]
regex = "1.10.4"
winnow = "0.7.10"
num-traits = "0.2.19"
jiff = { version = "0.2.15", default-features = false, features = ["tz-system", "tzdb-bundle-platform", "tzdb-zoneinfo"] }
+58 -27
View File
@@ -160,31 +160,59 @@ impl DateTimeBuilder {
self.set_time(time)
}
/// Build a `Zoned` object from the pieces accumulated in this builder.
///
/// Resolution order (mirrors GNU `date` semantics):
///
/// 1. Base instant.
/// - a. If `self.base` is provided, start with it.
/// - b. Else if a `timezone` rule is present, start with "now" in that
/// timezone.
/// - c. Else start with current system local time.
///
/// 2. Absolute timestamp override.
/// - a. If `self.timestamp` is set, it fully determines the result.
///
/// 3. Time of day truncation.
/// - a. If any of date, time, weekday, offset, timezone is set, zero the
/// time of day to 00:00:00 before applying fields.
///
/// 4. Fieldwise resolution (applied to the base instant).
/// - a. Apply date. If year is absent in the parsed date, inherit the year
/// from the base instant.
/// - b. Apply time. If time carries an explicit numeric offset, apply the
/// offset before setting time.
/// - c. Apply weekday (e.g., "next Friday" or "last Monday").
/// - d. Apply relative adjustments (e.g., "+3 days", "-2 months").
/// - e. Apply final fixed offset if present.
pub(super) fn build(self) -> Result<Zoned, error::Error> {
let base = self.base.unwrap_or(if let Some(tz) = &self.timezone {
jiff::Timestamp::now().to_zoned(tz.clone())
} else {
Zoned::now()
});
// If a timestamp is set, we use it to build the `Zoned` object.
if let Some(ts) = self.timestamp {
return Ok(jiff::Timestamp::try_from(ts)?.to_zoned(base.offset().to_time_zone()));
}
// If any of the following items are set, we truncate the time portion
// of the base date to zero; otherwise, we use the base date as is.
let mut dt = if self.date.is_none()
&& self.time.is_none()
&& self.weekday.is_none()
&& self.offset.is_none()
&& self.timezone.is_none()
{
base
} else {
base.with().time(civil::time(0, 0, 0, 0)).build()?
// 1. Choose the base instant.
let base = match (self.base, &self.timezone) {
(Some(b), _) => b,
(None, Some(tz)) => jiff::Timestamp::now().to_zoned(tz.clone()),
(None, None) => Zoned::now(),
};
// 2. Absolute timestamp override everything else.
if let Some(ts) = self.timestamp {
let ts = jiff::Timestamp::try_from(ts)?;
return Ok(ts.to_zoned(base.offset().to_time_zone()));
}
// 3. Determine whether to truncate the time of day.
let need_midnight = self.date.is_some()
|| self.time.is_some()
|| self.weekday.is_some()
|| self.offset.is_some()
|| self.timezone.is_some();
let mut dt = if need_midnight {
base.with().time(civil::time(0, 0, 0, 0)).build()?
} else {
base
};
// 4a. Apply date.
if let Some(date) = self.date {
let d: civil::Date = if date.year.is_some() {
date.try_into()?
@@ -194,6 +222,7 @@ impl DateTimeBuilder {
dt = dt.with().date(d).build()?;
}
// 4b. Apply time.
if let Some(time) = self.time.clone() {
if let Some(offset) = &time.offset {
dt = dt.datetime().to_zoned(offset.try_into()?)?;
@@ -203,13 +232,13 @@ impl DateTimeBuilder {
dt = dt.with().time(t).build()?;
}
if let Some(weekday::Weekday { offset, day }) = self.weekday {
// 4c. Apply weekday.
if let Some(weekday::Weekday { mut offset, day }) = self.weekday {
if self.time.is_none() {
dt = dt.with().time(civil::time(0, 0, 0, 0)).build()?;
}
let mut offset = offset;
let day = day.into();
let target = day.into();
// If the current day is not the target day, we need to adjust
// the x value to ensure we find the correct day.
@@ -217,7 +246,7 @@ impl DateTimeBuilder {
// Consider this:
// Assuming today is Monday, next Friday is actually THIS Friday;
// but next Monday is indeed NEXT Monday.
if dt.date().weekday() != day && offset > 0 {
if dt.date().weekday() != target && offset > 0 {
offset -= 1;
}
@@ -237,7 +266,7 @@ impl DateTimeBuilder {
//
// Example 4: next Thursday (x = 1, day = Thursday)
// delta = (3 - 3) % 7 + (1) * 7 = 7
let delta = (day.since(civil::Weekday::Monday) as i32
let delta = (target.since(civil::Weekday::Monday) as i32
- dt.date().weekday().since(civil::Weekday::Monday) as i32)
.rem_euclid(7)
+ offset.checked_mul(7).ok_or("multiplication overflow")?;
@@ -245,6 +274,7 @@ impl DateTimeBuilder {
dt = dt.checked_add(Span::new().try_days(delta)?)?;
}
// 4d. Apply relative adjustments.
for rel in self.relative {
dt = dt.checked_add::<Span>(if let relative::Relative::Months(x) = rel {
// *NOTE* This is done in this way to conform to GNU behavior.
@@ -255,6 +285,7 @@ impl DateTimeBuilder {
})?;
}
// 4e. Apply final fixed offset.
if let Some(offset) = self.offset {
let (offset, hour_adjustment) = offset.normalize();
dt = dt.checked_add(Span::new().hours(hour_adjustment))?;
+14 -11
View File
@@ -138,9 +138,10 @@ pub(super) fn iso1(input: &mut &str) -> ModalResult<Date> {
let (year, _, month, _, day) =
(year_str, s('-'), s(dec_uint), s('-'), s(dec_uint)).parse_next(input)?;
// Map err to Backtrack instead of Cut to avoid early termination of parsing
(year, month, day)
.try_into()
.map_err(|e| ErrMode::Cut(ctx_err(e)))
.map_err(|e| ErrMode::Backtrack(ctx_err(e)))
}
/// Parse `[year][month][day]`
@@ -156,7 +157,7 @@ pub(super) fn iso2(input: &mut &str) -> ModalResult<Date> {
(year, month, day)
.try_into()
.map_err(|e| ErrMode::Cut(ctx_err(e)))
.map_err(|e| ErrMode::Backtrack(ctx_err(e)))
}
/// Parse `[year]/[month]/[day]` or `[month]/[day]/[year]` or `[month]/[day]`.
@@ -178,19 +179,21 @@ fn us(input: &mut &str) -> ModalResult<Date> {
let day = day_from_str(s2)?;
(s1, n, day)
.try_into()
.map_err(|e| ErrMode::Cut(ctx_err(e)))
.map_err(|e| ErrMode::Backtrack(ctx_err(e)))
}
Some(s2) => {
// [month]/[day]/[year]
let month = month_from_str(s1)?;
(s2, month, n)
.try_into()
.map_err(|e| ErrMode::Cut(ctx_err(e)))
.map_err(|e| ErrMode::Backtrack(ctx_err(e)))
}
None => {
// [month]/[day]
let month = month_from_str(s1)?;
(month, n).try_into().map_err(|e| ErrMode::Cut(ctx_err(e)))
(month, n)
.try_into()
.map_err(|e| ErrMode::Backtrack(ctx_err(e)))
}
}
}
@@ -213,10 +216,10 @@ fn literal1(input: &mut &str) -> ModalResult<Date> {
match year {
Some(year) => (year, month, day)
.try_into()
.map_err(|e| ErrMode::Cut(ctx_err(e))),
.map_err(|e| ErrMode::Backtrack(ctx_err(e))),
None => (month, day)
.try_into()
.map_err(|e| ErrMode::Cut(ctx_err(e))),
.map_err(|e| ErrMode::Backtrack(ctx_err(e))),
}
}
@@ -242,10 +245,10 @@ fn literal2(input: &mut &str) -> ModalResult<Date> {
match year {
Some(year) => (year, month, day)
.try_into()
.map_err(|e| ErrMode::Cut(ctx_err(e))),
.map_err(|e| ErrMode::Backtrack(ctx_err(e))),
None => (month, day)
.try_into()
.map_err(|e| ErrMode::Cut(ctx_err(e))),
.map_err(|e| ErrMode::Backtrack(ctx_err(e))),
}
}
@@ -274,12 +277,12 @@ fn literal_month(input: &mut &str) -> ModalResult<u8> {
fn month_from_str(s: &str) -> ModalResult<u8> {
s.parse::<u8>()
.map_err(|_| ErrMode::Cut(ctx_err("month must be a valid u8 number")))
.map_err(|_| ErrMode::Backtrack(ctx_err("month must be a valid u8 number")))
}
fn day_from_str(s: &str) -> ModalResult<u8> {
s.parse::<u8>()
.map_err(|_| ErrMode::Cut(ctx_err("day must be a valid u8 number")))
.map_err(|_| ErrMode::Backtrack(ctx_err("day must be a valid u8 number")))
}
#[cfg(test)]
+34
View File
@@ -128,3 +128,37 @@ fn test_time_invalid(#[case] input: &str) {
"Input string '{input}' did not produce an error when parsing"
);
}
#[rstest]
#[case::decimal_1_whole("1.123456789 seconds ago")]
#[case::decimal_2_whole("12.123456789 seconds ago")]
#[case::decimal_3_whole("123.123456789 seconds ago")]
#[case::decimal_4_whole("1234.123456789 seconds ago")]
#[case::decimal_5_whole("12345.123456789 seconds ago")]
#[case::decimal_6_whole("123456.123456789 seconds ago")]
#[case::decimal_7_whole("1234567.123456789 seconds ago")]
#[case::decimal_8_whole("12345678.123456789 seconds ago")]
#[case::decimal_9_whole("123456789.123456789 seconds ago")]
#[case::decimal_10_whole("1234567891.123456789 seconds ago")]
#[case::decimal_11_whole("12345678912.123456789 seconds ago")]
#[case::decimal_12_whole("123456789123.123456789 seconds ago")]
fn test_time_seconds_ago(#[case] input: &str) {
let result = parse_datetime::parse_datetime(input);
assert!(
result.is_ok(),
"Input string '{input}', produced {result:?}, instead of Ok(Zoned)"
);
}
#[rstest]
#[case::decimal_13_whole("1234567891234.123456789 seconds ago")]
#[case::decimal_14_whole("12345678912345.123456789 seconds ago")]
#[case::decimal_15_whole("123456789123456.123456789 seconds ago")]
fn test_time_seconds_ago_invalid(#[case] input: &str) {
let result = parse_datetime::parse_datetime(input);
assert_eq!(
result,
Err(parse_datetime::ParseDateTimeError::InvalidInput),
"Input string '{input}' did not produce an error when parsing"
);
}