Skip to content

Commit

Permalink
Merge pull request #110 from jfinkels/parse-ref-date-and-delta
Browse files Browse the repository at this point in the history
Parse reference datetime and timedelta from the same string
  • Loading branch information
cakebaker authored Feb 16, 2025
2 parents ca6a39c + d4353d0 commit e7a7a0b
Showing 1 changed file with 128 additions and 30 deletions.
158 changes: 128 additions & 30 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ mod format {
pub const YYYYMMDDHHMMSS_HYPHENATED_OFFSET: &str = "%Y-%m-%d %H:%M:%S %#z";
pub const YYYYMMDDHHMMSS_HYPHENATED_ZULU: &str = "%Y-%m-%d %H:%M:%SZ";
pub const YYYYMMDDHHMMSS_T_SEP_HYPHENATED_OFFSET: &str = "%Y-%m-%dT%H:%M:%S%#z";
pub const YYYYMMDDHHMMSS_T_SEP_HYPHENATED_ZULU: &str = "%Y-%m-%dT%H:%M:%SZ";
pub const YYYYMMDDHHMMSS_T_SEP_HYPHENATED_SPACE_OFFSET: &str = "%Y-%m-%dT%H:%M:%S %#z";
pub const YYYYMMDDHHMMS_T_SEP: &str = "%Y-%m-%dT%H:%M:%S";
pub const UTC_OFFSET: &str = "UTC%#z";
Expand All @@ -88,7 +89,7 @@ mod format {

/// Whether the pattern ends in the character `Z`.
pub(crate) fn is_zulu(pattern: &str) -> bool {
pattern == YYYYMMDDHHMMSS_HYPHENATED_ZULU
pattern.ends_with('Z')
}

/// Patterns for datetimes with timezones.
Expand All @@ -113,10 +114,11 @@ mod format {
/// Patterns for datetimes without timezones.
///
/// These are in decreasing order of length.
pub(crate) const PATTERNS_NO_TZ: [(&str, usize); 8] = [
pub(crate) const PATTERNS_NO_TZ: [(&str, usize); 9] = [
(YYYYMMDDHHMMSS, 29),
(POSIX_LOCALE, 24),
(YYYYMMDDHHMMSS_HYPHENATED_ZULU, 20),
(YYYYMMDDHHMMSS_T_SEP_HYPHENATED_ZULU, 20),
(YYYYMMDDHHMMS_T_SEP, 19),
(YYYYMMDDHHMMS, 19),
(YYYY_MM_DD_HH_MM, 16),
Expand Down Expand Up @@ -232,8 +234,34 @@ pub fn parse_datetime_at_date<S: AsRef<str> + Clone>(
// TODO: Replace with a proper customiseable parsing solution using `nom`, `grmtools`, or
// similar

// Formats with offsets don't require NaiveDateTime workaround
//
// Try to parse a reference date first. Try parsing from longest
// pattern to shortest pattern. If a reference date can be parsed,
// then try to parse a time delta from the remaining slice. If no
// reference date could be parsed, then try to parse the entire
// string as a time delta. If no time delta could be parsed,
// return an error.
let (ref_date, n) = match parse_reference_date(date, s.as_ref()) {
Some((ref_date, n)) => (ref_date, n),
None => {
let tz = TimeZone::from_offset(date.offset());
match date.naive_local().and_local_timezone(tz) {
MappedLocalTime::Single(ref_date) => (ref_date, 0),
_ => return Err(ParseDateTimeError::InvalidInput),
}
}
};
parse_relative_time_at_date(ref_date, &s.as_ref()[n..])
}

/// Parse an absolute datetime from a prefix of s, if possible.
///
/// Try to parse the longest possible absolute datetime at the beginning
/// of string `s`. Return the parsed datetime and the index in `s` at
/// which the datetime ended.
fn parse_reference_date<S>(date: DateTime<Local>, s: S) -> Option<(DateTime<FixedOffset>, usize)>
where
S: AsRef<str>,
{
// HACK: if the string ends with a single digit preceded by a + or -
// sign, then insert a 0 between the sign and the digit to make it
// possible for `chrono` to parse it.
Expand All @@ -242,7 +270,11 @@ pub fn parse_datetime_at_date<S: AsRef<str> + Clone>(
for (fmt, n) in format::PATTERNS_TZ {
if tmp_s.len() >= n {
if let Ok(parsed) = DateTime::parse_from_str(&tmp_s[0..n], fmt) {
return Ok(parsed);
if tmp_s == s.as_ref() {
return Some((parsed, n));
} else {
return Some((parsed, n - 1));
}
}
}
}
Expand All @@ -259,11 +291,11 @@ pub fn parse_datetime_at_date<S: AsRef<str> + Clone>(
.unwrap()
.from_local_datetime(&parsed)
{
MappedLocalTime::Single(datetime) => return Ok(datetime),
_ => return Err(ParseDateTimeError::InvalidInput),
MappedLocalTime::Single(datetime) => return Some((datetime, n)),
_ => return None,
}
} else if let Ok(dt) = naive_dt_to_fixed_offset(date, parsed) {
return Ok(dt);
return Some((dt, n));
}
}
}
Expand All @@ -287,13 +319,13 @@ pub fn parse_datetime_at_date<S: AsRef<str> + Clone>(

let dt = DateTime::<FixedOffset>::from(beginning_of_day);

return Ok(dt);
return Some((dt, s.as_ref().len()));
}

// Parse epoch seconds
if let Ok(timestamp) = parse_timestamp(s.as_ref()) {
if let Some(timestamp_date) = DateTime::from_timestamp(timestamp, 0) {
return Ok(timestamp_date.into());
return Some((timestamp_date.into(), s.as_ref().len()));
}
}

Expand All @@ -303,7 +335,7 @@ pub fn parse_datetime_at_date<S: AsRef<str> + Clone>(
if let Ok(parsed) = NaiveDate::parse_from_str(&s.as_ref()[0..n], fmt) {
let datetime = parsed.and_hms_opt(0, 0, 0).unwrap();
if let Ok(dt) = naive_dt_to_fixed_offset(date, datetime) {
return Ok(dt);
return Some((dt, n));
}
}
}
Expand All @@ -318,25 +350,21 @@ pub fn parse_datetime_at_date<S: AsRef<str> + Clone>(
if ts.len() == n + 12 {
let f = format::YYYYMMDDHHMM.to_owned() + fmt;
if let Ok(parsed) = DateTime::parse_from_str(&ts, &f) {
return Ok(parsed);
if tmp_s == s.as_ref() {
return Some((parsed, n));
} else {
return Some((parsed, n - 1));
}
}
}
}

// Parse relative time.
if let Ok(datetime) = parse_relative_time_at_date(date, s.as_ref()) {
return Ok(DateTime::<FixedOffset>::from(datetime));
}

// parse time only dates
if let Some(date_time) = parse_time_only_str::parse_time_only(date, s.as_ref()) {
return Ok(date_time);
return Some((date_time, s.as_ref().len()));
}

// Default parse and failure
s.as_ref()
.parse()
.map_err(|_| (ParseDateTimeError::InvalidInput))
None
}

// Convert NaiveDateTime to DateTime<FixedOffset> by assuming the offset
Expand Down Expand Up @@ -662,14 +690,10 @@ mod tests {
assert!(crate::parse_datetime("bogus +1 day").is_err());
}

// TODO Re-enable this when we parse the absolute datetime and the
// time delta separately, see
// <https://github.com/uutils/parse_datetime/issues/104>.
//
// #[test]
// fn test_parse_invalid_delta() {
// assert!(crate::parse_datetime("1997-01-01 bogus").is_err());
// }
#[test]
fn test_parse_invalid_delta() {
assert!(crate::parse_datetime("1997-01-01 bogus").is_err());
}

#[test]
fn test_parse_datetime_tz_nodelta() {
Expand Down Expand Up @@ -741,6 +765,80 @@ mod tests {
}
}

#[test]
fn test_parse_datetime_tz_delta() {
std::env::set_var("TZ", "UTC0");

// 1998-01-01
let expected = chrono::NaiveDate::from_ymd_opt(1998, 1, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc()
.fixed_offset();

for s in [
"1997-01-01 00:00:00 +0000 +1 year",
"1997-01-01 00:00:00 +00 +1 year",
"199701010000 +0000 +1 year",
"199701010000UTC+0000 +1 year",
"199701010000Z+0000 +1 year",
"1997-01-01T00:00:00Z +1 year",
"1997-01-01 00:00 +0000 +1 year",
"1997-01-01 00:00:00 +0000 +1 year",
"1997-01-01T00:00:00+0000 +1 year",
"1997-01-01T00:00:00+00 +1 year",
] {
let actual = crate::parse_datetime(s).unwrap();
assert_eq!(actual, expected);
}
}

#[test]
fn test_parse_datetime_notz_delta() {
std::env::set_var("TZ", "UTC0");
let expected = chrono::NaiveDate::from_ymd_opt(1998, 1, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc()
.fixed_offset();

for s in [
"1997-01-01 00:00:00.000000000 +1 year",
"Wed Jan 1 00:00:00 1997 +1 year",
"1997-01-01T00:00:00 +1 year",
"1997-01-01 00:00:00 +1 year",
"1997-01-01 00:00 +1 year",
"199701010000.00 +1 year",
"199701010000 +1 year",
] {
let actual = crate::parse_datetime(s).unwrap();
assert_eq!(actual, expected);
}
}

#[test]
fn test_parse_date_notz_delta() {
std::env::set_var("TZ", "UTC0");
let expected = chrono::NaiveDate::from_ymd_opt(1998, 1, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc()
.fixed_offset();

for s in [
"1997-01-01 +1 year",
"19970101 +1 year",
"01/01/1997 +1 year",
"01/01/97 +1 year",
] {
let actual = crate::parse_datetime(s).unwrap();
assert_eq!(actual, expected);
}
}

#[test]
fn test_time_only() {
use chrono::{FixedOffset, Local};
Expand Down

0 comments on commit e7a7a0b

Please sign in to comment.