You've already forked parse_datetime
mirror of
https://github.com/uutils/parse_datetime.git
synced 2026-06-10 16:13:15 -07:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ba69fb8772 | |||
| 912bc4e706 | |||
| c239ed0b36 | |||
| 3074fe3baa | |||
| 29e368662c | |||
| 1e757df39b | |||
| 7a32b1ee3d | |||
| c898cd84de |
Generated
+1
-2
@@ -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
@@ -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
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user