diff --git a/Cargo.lock b/Cargo.lock index 397acb8..ec5e584 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,20 +118,13 @@ version = "2.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "nom" -version = "7.1.3" +version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" dependencies = [ "memchr", - "minimal-lexical", ] [[package]] @@ -151,7 +144,7 @@ checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" [[package]] name = "parse_datetime" -version = "0.6.0" +version = "0.8.0" dependencies = [ "chrono", "nom", diff --git a/Cargo.toml b/Cargo.toml index 7f2c4d9..1c75c90 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.6.0" +version = "0.8.0" edition = "2021" license = "MIT" repository = "https://github.com/uutils/parse_datetime" @@ -10,4 +10,4 @@ readme = "README.md" [dependencies] regex = "1.10.4" chrono = { version="0.4.38", default-features=false, features=["std", "alloc", "clock"] } -nom = "7.1.3" +nom = "8.0.0" diff --git a/README.md b/README.md index e52af2b..8895f43 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,8 @@ A Rust crate for parsing human-readable relative time strings and human-readable Add this to your `Cargo.toml`: -```toml -[dependencies] -parse_datetime = "0.6.0" +``` +cargo add parse_datetime ``` Then, import the crate and use the `parse_datetime_at_date` function: diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index ef5d266..f1ad88b 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -46,11 +46,13 @@ checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1" [[package]] name = "cc" -version = "1.0.79" +version = "1.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" +checksum = "13208fcbb66eaeffe09b99fffbe1af420f00a7b35aa99ad683dfc1aa76145229" dependencies = [ "jobserver", + "libc", + "shlex", ] [[package]] @@ -84,21 +86,9 @@ dependencies = [ "chrono", "libfuzzer-sys", "parse_datetime", - "rand", "regex", ] -[[package]] -name = "getrandom" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c85e1d9ab2eadba7e5040d4e09cbd6d072b76a557ad64e797c2cb9d4da21d7e4" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - [[package]] name = "iana-time-zone" version = "0.1.61" @@ -124,9 +114,9 @@ dependencies = [ [[package]] name = "jobserver" -version = "0.1.26" +version = "0.1.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "936cfd212a0155903bcbc060e316fb6cc7cbf2e1907329391ebadc1fe0ce77c2" +checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" dependencies = [ "libc", ] @@ -148,9 +138,9 @@ checksum = "6a987beff54b60ffa6d51982e1aa1146bc42f19bd26be28b0586f252fccf5317" [[package]] name = "libfuzzer-sys" -version = "0.4.8" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b9569d2f74e257076d8c6bfa73fb505b46b851e51ddaecc825944aa3bed17fa" +checksum = "cf78f52d400cf2d84a3a973a78a592b4adc535739e0a5597a0da6f0c357adc75" dependencies = [ "arbitrary", "cc", @@ -168,20 +158,13 @@ version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5486aed0026218e61b8a01d5fbd5a0a134649abb71a0e53b7bc088529dced86e" -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "nom" -version = "7.1.3" +version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" dependencies = [ "memchr", - "minimal-lexical", ] [[package]] @@ -201,19 +184,13 @@ checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" [[package]] name = "parse_datetime" -version = "0.6.0" +version = "0.7.0" dependencies = [ "chrono", "nom", "regex", ] -[[package]] -name = "ppv-lite86" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" - [[package]] name = "proc-macro2" version = "1.0.59" @@ -232,36 +209,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom", -] - [[package]] name = "regex" version = "1.11.1" @@ -291,6 +238,12 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "syn" version = "2.0.18" @@ -308,12 +261,6 @@ version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15811caf2415fb889178633e7724bad2509101cde276048e013b9def5e51fa0" -[[package]] -name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" - [[package]] name = "wasm-bindgen" version = "0.2.93" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index d8d56b8..dfc4c79 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -7,7 +7,6 @@ edition = "2021" cargo-fuzz = true [dependencies] -rand = "0.8.5" libfuzzer-sys = "0.4.7" regex = "1.10.4" chrono = { version="0.4", default-features=false, features=["std", "alloc", "clock"] } diff --git a/src/lib.rs b/src/lib.rs index 39bc6d1..f6148be 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,8 +21,8 @@ mod parse_time_only_str; mod parse_weekday; use chrono::{ - DateTime, Datelike, Duration, FixedOffset, Local, LocalResult, NaiveDateTime, TimeZone, - Timelike, + DateTime, Datelike, Duration, FixedOffset, Local, LocalResult, MappedLocalTime, NaiveDate, + NaiveDateTime, TimeZone, Timelike, }; use parse_relative_time::parse_relative_time_at_date; @@ -77,10 +77,91 @@ mod format { pub const YYYYMMDDHHMM_UTC_OFFSET: &str = "%Y%m%d%H%MUTC%z"; pub const YYYYMMDDHHMM_ZULU_OFFSET: &str = "%Y%m%d%H%MZ%z"; pub const YYYYMMDDHHMM_HYPHENATED_OFFSET: &str = "%Y-%m-%d %H:%M %z"; + 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"; pub const ZULU_OFFSET: &str = "Z%#z"; pub const NAKED_OFFSET: &str = "%#z"; + + /// Whether the pattern ends in the character `Z`. + pub(crate) fn is_zulu(pattern: &str) -> bool { + pattern.ends_with('Z') + } + + /// Patterns for datetimes with timezones. + /// + /// These are in decreasing order of length. The same pattern may + /// appear multiple times with different lengths if the pattern + /// accepts input strings of different lengths. For example, the + /// specifier `%#z` accepts two-digit time zone offsets (`+00`) + /// and four-digit time zone offsets (`+0000`). + pub(crate) const PATTERNS_TZ: [(&str, usize); 9] = [ + (YYYYMMDDHHMMSS_HYPHENATED_OFFSET, 25), + (YYYYMMDDHHMMSS_T_SEP_HYPHENATED_SPACE_OFFSET, 25), + (YYYYMMDDHHMMSS_T_SEP_HYPHENATED_OFFSET, 24), + (YYYYMMDDHHMMSS_HYPHENATED_OFFSET, 23), + (YYYYMMDDHHMMSS_T_SEP_HYPHENATED_OFFSET, 22), + (YYYYMMDDHHMM_HYPHENATED_OFFSET, 22), + (YYYYMMDDHHMM_UTC_OFFSET, 20), + (YYYYMMDDHHMM_OFFSET, 18), + (YYYYMMDDHHMM_ZULU_OFFSET, 18), + ]; + + /// Patterns for datetimes without timezones. + /// + /// These are in decreasing order of length. + 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), + (YYYYMMDDHHMM_DOT_SS, 15), + (YYYYMMDDHHMM, 12), + ]; + + /// Patterns for dates with neither times nor timezones. + /// + /// These are in decreasing order of length. The same pattern may + /// appear multiple times with different lengths if the pattern + /// accepts input strings of different lengths. For example, the + /// specifier `%m` accepts one-digit month numbers (like `2`) and + /// two-digit month numbers (like `02` or `12`). + pub(crate) const PATTERNS_DATE_NO_TZ: [(&str, usize); 8] = [ + (ISO_8601, 10), + (MMDDYYYY_SLASH, 10), + (ISO_8601, 9), + (MMDDYYYY_SLASH, 9), + (ISO_8601, 8), + (MMDDYY_SLASH, 8), + (MMDDYYYY_SLASH, 8), + (ISO_8601_NO_SEP, 8), + ]; + + /// Patterns for lone timezone offsets. + /// + /// These are in decreasing order of length. The same pattern may + /// appear multiple times with different lengths if the pattern + /// accepts input strings of different lengths. For example, the + /// specifier `%#z` accepts two-digit time zone offsets (`+00`) + /// and four-digit time zone offsets (`+0000`). + pub(crate) const PATTERNS_OFFSET: [(&str, usize); 9] = [ + (UTC_OFFSET, 9), + (UTC_OFFSET, 8), + (ZULU_OFFSET, 7), + (UTC_OFFSET, 6), + (ZULU_OFFSET, 6), + (NAKED_OFFSET, 6), + (NAKED_OFFSET, 5), + (ZULU_OFFSET, 4), + (NAKED_OFFSET, 3), + ]; } /// Parses a time string and returns a `DateTime` representing the @@ -153,31 +234,68 @@ pub fn parse_datetime_at_date + Clone>( // TODO: Replace with a proper customiseable parsing solution using `nom`, `grmtools`, or // similar - // Formats with offsets don't require NaiveDateTime workaround - for fmt in [ - format::YYYYMMDDHHMM_OFFSET, - format::YYYYMMDDHHMM_HYPHENATED_OFFSET, - format::YYYYMMDDHHMM_UTC_OFFSET, - format::YYYYMMDDHHMM_ZULU_OFFSET, - ] { - if let Ok(parsed) = DateTime::parse_from_str(s.as_ref(), fmt) { - return Ok(parsed); + // 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) = if let Some((ref_date, n)) = parse_reference_date(date, s.as_ref()) { + (ref_date, n) + } else { + 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(date: DateTime, s: S) -> Option<(DateTime, usize)> +where + S: AsRef, +{ + // 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. + let pattern = Regex::new(r"([\+-])(\d)$").unwrap(); + let tmp_s = pattern.replace(s.as_ref(), "${1}0${2}"); + 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) { + if tmp_s == s.as_ref() { + return Some((parsed, n)); + } else { + return Some((parsed, n - 1)); + } + } } } // Parse formats with no offset, assume local time - for fmt in [ - format::YYYYMMDDHHMMS_T_SEP, - format::YYYYMMDDHHMM, - format::YYYYMMDDHHMMS, - format::YYYYMMDDHHMMSS, - format::YYYY_MM_DD_HH_MM, - format::YYYYMMDDHHMM_DOT_SS, - format::POSIX_LOCALE, - ] { - if let Ok(parsed) = NaiveDateTime::parse_from_str(s.as_ref(), fmt) { - if let Ok(dt) = naive_dt_to_fixed_offset(date, parsed) { - return Ok(dt); + for (fmt, n) in format::PATTERNS_NO_TZ { + if s.as_ref().len() >= n { + if let Ok(parsed) = NaiveDateTime::parse_from_str(&s.as_ref()[0..n], fmt) { + // Special case: `chrono` can only parse a datetime like + // `2000-01-01 01:23:45Z` as a naive datetime, so we + // manually force it to be in UTC. + if format::is_zulu(fmt) { + match FixedOffset::east_opt(0) + .unwrap() + .from_local_datetime(&parsed) + { + MappedLocalTime::Single(datetime) => return Some((datetime, n)), + _ => return None, + } + } else if let Ok(dt) = naive_dt_to_fixed_offset(date, parsed) { + return Some((dt, n)); + } } } } @@ -200,28 +318,24 @@ pub fn parse_datetime_at_date + Clone>( let dt = DateTime::::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())); } } - let ts = s.as_ref().to_owned() + " 0000"; // Parse date only formats - assume midnight local timezone - for fmt in [ - format::ISO_8601, - format::ISO_8601_NO_SEP, - format::MMDDYYYY_SLASH, - format::MMDDYY_SLASH, - ] { - let f = fmt.to_owned() + " %H%M"; - if let Ok(parsed) = NaiveDateTime::parse_from_str(&ts, &f) { - if let Ok(dt) = naive_dt_to_fixed_offset(date, parsed) { - return Ok(dt); + for (fmt, n) in format::PATTERNS_DATE_NO_TZ { + if s.as_ref().len() >= n { + 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 Some((dt, n)); + } } } } @@ -230,41 +344,26 @@ pub fn parse_datetime_at_date + Clone>( // offsets, so instead we replicate parse_date behaviour by getting // the current date with local, and create a date time string at midnight, // before trying offset suffixes - // - // 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. - let pattern = Regex::new(r"([\+-])(\d)$").unwrap(); - let ts = format!( - "{}0000{}", - date.format("%Y%m%d"), - pattern.replace(s.as_ref(), "${1}0${2}") - ); - for fmt in [ - format::UTC_OFFSET, - format::ZULU_OFFSET, - format::NAKED_OFFSET, - ] { - let f = format::YYYYMMDDHHMM.to_owned() + fmt; - if let Ok(parsed) = DateTime::parse_from_str(&ts, &f) { - return Ok(parsed); + let ts = format!("{}0000{}", date.format("%Y%m%d"), tmp_s.as_ref()); + for (fmt, n) in format::PATTERNS_OFFSET { + if ts.len() == n + 12 { + let f = format::YYYYMMDDHHMM.to_owned() + fmt; + 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)); + } + } } } - // Parse relative time. - if let Ok(datetime) = parse_relative_time_at_date(date, s.as_ref()) { - return Ok(DateTime::::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 by assuming the offset @@ -322,6 +421,14 @@ mod tests { assert_eq!(actual.unwrap().timestamp(), TEST_TIME); } + #[test] + fn test_t_sep_single_digit_offset_no_space() { + env::set_var("TZ", "UTC"); + let dt = "2021-02-14T22:37:47-8"; + let actual = parse_datetime(dt); + assert_eq!(actual.unwrap().timestamp(), TEST_TIME); + } + #[test] fn invalid_formats() { let invalid_dts = vec!["NotADate", "202104", "202104-12T22:37:47"]; @@ -354,6 +461,7 @@ mod tests { #[test] fn single_digit_month_day() { + std::env::set_var("TZ", "UTC"); let x = Local.with_ymd_and_hms(1987, 5, 7, 0, 0, 0).unwrap(); let expected = DateTime::fixed_offset(&x); @@ -370,7 +478,7 @@ mod tests { #[cfg(test)] mod offsets { - use chrono::Local; + use chrono::{Local, NaiveDate}; use crate::parse_datetime; use crate::ParseDateTimeError; @@ -413,6 +521,17 @@ mod tests { Err(ParseDateTimeError::InvalidInput) ); } + + #[test] + fn test_datetime_with_offset() { + let actual = parse_datetime("1997-01-19 08:17:48 +0").unwrap(); + let expected = NaiveDate::from_ymd_opt(1997, 1, 19) + .unwrap() + .and_hms_opt(8, 17, 48) + .unwrap() + .and_utc(); + assert_eq!(actual, expected); + } } #[cfg(test)] @@ -499,12 +618,12 @@ mod tests { for offset in offsets { // positive offset let time = Utc.timestamp_opt(offset, 0).unwrap(); - let dt = parse_datetime(format!("@{}", offset)); + let dt = parse_datetime(format!("@{offset}")); assert_eq!(dt.unwrap(), time); // negative offset let time = Utc.timestamp_opt(-offset, 0).unwrap(); - let dt = parse_datetime(format!("@-{}", offset)); + let dt = parse_datetime(format!("@-{offset}")); assert_eq!(dt.unwrap(), time); } } @@ -522,7 +641,7 @@ mod tests { let parsed_time = parse_datetime_at_date(test_date, "9:04:30 PM +0530") .unwrap() .timestamp(); - assert_eq!(parsed_time, 1709480070) + assert_eq!(parsed_time, 1709480070); } } /// Used to test example code presented in the README. @@ -554,4 +673,213 @@ mod tests { assert_eq!(result, Err(ParseDateTimeError::InvalidInput)); } } + + #[test] + fn test_datetime_ending_in_z() { + use crate::parse_datetime; + use chrono::{TimeZone, Utc}; + + let actual = parse_datetime("2023-06-03 12:00:01Z").unwrap(); + let expected = Utc.with_ymd_and_hms(2023, 6, 3, 12, 0, 1).unwrap(); + assert_eq!(actual, expected); + } + + #[test] + fn test_parse_invalid_datetime() { + assert!(crate::parse_datetime("bogus +1 day").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() { + std::env::set_var("TZ", "UTC0"); + + // 1997-01-01 00:00:00 +0000 + let expected = chrono::NaiveDate::from_ymd_opt(1997, 1, 1) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc() + .fixed_offset(); + + for s in [ + "1997-01-01 00:00:00 +0000", + "1997-01-01 00:00:00 +00", + "199701010000 +0000", + "199701010000UTC+0000", + "199701010000Z+0000", + "1997-01-01 00:00 +0000", + "1997-01-01 00:00:00 +0000", + "1997-01-01T00:00:00+0000", + "1997-01-01T00:00:00+00", + "1997-01-01T00:00:00Z", + "@852076800", + ] { + let actual = crate::parse_datetime(s).unwrap(); + assert_eq!(actual, expected); + } + } + + #[test] + fn test_parse_datetime_notz_nodelta() { + std::env::set_var("TZ", "UTC0"); + let expected = chrono::NaiveDate::from_ymd_opt(1997, 1, 1) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc() + .fixed_offset(); + + for s in [ + "1997-01-01 00:00:00.000000000", + "Wed Jan 1 00:00:00 1997", + "1997-01-01T00:00:00", + "1997-01-01 00:00:00", + "1997-01-01 00:00", + "199701010000.00", + "199701010000", + ] { + let actual = crate::parse_datetime(s).unwrap(); + assert_eq!(actual, expected); + } + } + + #[test] + fn test_parse_date_notz_nodelta() { + std::env::set_var("TZ", "UTC0"); + let expected = chrono::NaiveDate::from_ymd_opt(1997, 1, 1) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc() + .fixed_offset(); + + for s in ["1997-01-01", "19970101", "01/01/1997", "01/01/97"] { + let actual = crate::parse_datetime(s).unwrap(); + assert_eq!(actual, expected); + } + } + + #[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}; + std::env::set_var("TZ", "UTC"); + + let offset = FixedOffset::east_opt(5 * 60 * 60 + 1800).unwrap(); + let expected = Local::now() + .date_naive() + .and_hms_opt(21, 4, 30) + .unwrap() + .and_local_timezone(offset) + .unwrap(); + let actual = crate::parse_datetime("9:04:30 PM +0530").unwrap(); + assert_eq!(actual, expected); + } + + #[test] + fn test_weekday_only() { + use chrono::{Datelike, Days, Local, MappedLocalTime, NaiveTime, Weekday}; + std::env::set_var("TZ", "UTC0"); + let now = Local::now(); + let midnight = NaiveTime::from_hms_opt(0, 0, 0).unwrap(); + let today = now.weekday(); + let midnight_today = if let MappedLocalTime::Single(t) = now.with_time(midnight) { + t + } else { + panic!() + }; + + for (s, day) in [ + ("sunday", Weekday::Sun), + ("monday", Weekday::Mon), + ("tuesday", Weekday::Tue), + ("wednesday", Weekday::Wed), + ("thursday", Weekday::Thu), + ("friday", Weekday::Fri), + ("saturday", Weekday::Sat), + ] { + let actual = crate::parse_datetime(s).unwrap(); + let delta = Days::new(u64::from(day.days_since(today))); + let expected = midnight_today.checked_add_days(delta).unwrap(); + assert_eq!(actual, expected); + } + } } diff --git a/src/parse_relative_time.rs b/src/parse_relative_time.rs index de9b293..689af5b 100644 --- a/src/parse_relative_time.rs +++ b/src/parse_relative_time.rs @@ -20,6 +20,8 @@ const DAYS_PER_MONTH: [u32; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 3 /// * `date` - A `Date` instance representing the base date for the calculation /// * `s` - A string slice representing the relative time. /// +/// If `s` is empty, the `date` is returned as-is. +/// /// # Supported formats /// /// The function supports the following formats for relative time: @@ -51,6 +53,10 @@ pub fn parse_relative_time_at_date( mut datetime: DateTime, s: &str, ) -> Result, ParseDateTimeError> { + let s = s.trim(); + if s.is_empty() { + return Ok(datetime); + } let time_pattern: Regex = Regex::new( r"(?x) (?:(?P[-+]?\d*)\s*)? @@ -278,6 +284,12 @@ mod tests { Ok(parsed - now) } + #[test] + fn test_empty_string() { + let now = Utc::now(); + assert_eq!(parse_relative_time_at_date(now, "").unwrap(), now); + } + #[test] fn test_years() { let now = Utc::now(); @@ -285,10 +297,7 @@ mod tests { parse_relative_time_at_date(now, "1 year").unwrap(), now.checked_add_months(Months::new(12)).unwrap() ); - assert_eq!( - parse_relative_time_at_date(now, "this year").unwrap(), - now.checked_add_months(Months::new(0)).unwrap() - ); + assert_eq!(parse_relative_time_at_date(now, "this year").unwrap(), now); assert_eq!( parse_relative_time_at_date(now, "-2 years").unwrap(), now.checked_sub_months(Months::new(24)).unwrap() @@ -319,25 +328,24 @@ mod tests { #[test] fn test_months() { + use crate::parse_relative_time::add_months; + let now = Utc::now(); assert_eq!( parse_relative_time_at_date(now, "1 month").unwrap(), - now.checked_add_months(Months::new(1)).unwrap() - ); - assert_eq!( - parse_relative_time_at_date(now, "this month").unwrap(), - now.checked_add_months(Months::new(0)).unwrap() + add_months(now, 1, false).unwrap(), ); + assert_eq!(parse_relative_time_at_date(now, "this month").unwrap(), now); assert_eq!( parse_relative_time_at_date(now, "1 month and 2 weeks").unwrap(), - now.checked_add_months(Months::new(1)) + add_months(now, 1, false) .unwrap() .checked_add_days(Days::new(14)) .unwrap() ); assert_eq!( parse_relative_time_at_date(now, "1 month and 2 weeks ago").unwrap(), - now.checked_sub_months(Months::new(1)) + add_months(now, 1, true) .unwrap() .checked_sub_days(Days::new(14)) .unwrap() @@ -348,7 +356,7 @@ mod tests { ); assert_eq!( parse_relative_time_at_date(now, "month").unwrap(), - now.checked_add_months(Months::new(1)).unwrap() + add_months(now, 1, false).unwrap(), ); } @@ -562,6 +570,8 @@ mod tests { #[test] fn test_duration_parsing() { + use crate::parse_relative_time::add_months; + let now = Utc::now(); assert_eq!( parse_relative_time_at_date(now, "1 year").unwrap(), @@ -582,25 +592,25 @@ mod tests { assert_eq!( parse_relative_time_at_date(now, "1 month").unwrap(), - now.checked_add_months(Months::new(1)).unwrap() + add_months(now, 1, false).unwrap(), ); assert_eq!( parse_relative_time_at_date(now, "1 month and 2 weeks").unwrap(), - now.checked_add_months(Months::new(1)) + add_months(now, 1, false) .unwrap() .checked_add_days(Days::new(14)) .unwrap() ); assert_eq!( parse_relative_time_at_date(now, "1 month, 2 weeks").unwrap(), - now.checked_add_months(Months::new(1)) + add_months(now, 1, false) .unwrap() .checked_add_days(Days::new(14)) .unwrap() ); assert_eq!( parse_relative_time_at_date(now, "1 months 2 weeks").unwrap(), - now.checked_add_months(Months::new(1)) + add_months(now, 1, false) .unwrap() .checked_add_days(Days::new(14)) .unwrap() @@ -618,7 +628,7 @@ mod tests { ); assert_eq!( parse_relative_time_at_date(now, "month").unwrap(), - now.checked_add_months(Months::new(1)).unwrap() + add_months(now, 1, false).unwrap(), ); assert_eq!( diff --git a/src/parse_time_only_str.rs b/src/parse_time_only_str.rs index ef0f814..119c2e6 100644 --- a/src/parse_time_only_str.rs +++ b/src/parse_time_only_str.rs @@ -140,7 +140,7 @@ mod tests { let parsed_time = parse_time_only(get_test_date(), "21:04") .unwrap() .timestamp(); - assert_eq!(parsed_time, 1709499840) + assert_eq!(parsed_time, 1709499840); } #[test] @@ -187,7 +187,7 @@ mod tests { let parsed_time = parse_time_only(get_test_date(), "21:04:30") .unwrap() .timestamp(); - assert_eq!(parsed_time, 1709499870) + assert_eq!(parsed_time, 1709499870); } #[test] @@ -196,7 +196,7 @@ mod tests { let parsed_time = parse_time_only(get_test_date(), "21:04:30 +0530") .unwrap() .timestamp(); - assert_eq!(parsed_time, 1709480070) + assert_eq!(parsed_time, 1709480070); } #[test] @@ -205,6 +205,6 @@ mod tests { let parsed_time = parse_time_only(get_test_date(), "9:04:00 PM") .unwrap() .timestamp(); - assert_eq!(parsed_time, 1709499840) + assert_eq!(parsed_time, 1709499840); } } diff --git a/src/parse_timestamp.rs b/src/parse_timestamp.rs index 732558d..6e5b20d 100644 --- a/src/parse_timestamp.rs +++ b/src/parse_timestamp.rs @@ -10,8 +10,7 @@ use nom::character::complete::{char, digit1}; use nom::combinator::all_consuming; use nom::multi::fold_many0; use nom::sequence::preceded; -use nom::sequence::tuple; -use nom::{self, IResult}; +use nom::{self, IResult, Parser}; #[derive(Debug, PartialEq)] pub enum ParseTimestampError { @@ -55,7 +54,7 @@ pub(crate) fn parse_timestamp(s: &str) -> Result { let res: IResult<&str, (char, &str)> = all_consuming(preceded( char('@'), - tuple(( + ( // Note: to stay compatible with gnu date this code allows // multiple + and - and only considers the last one fold_many0( @@ -67,8 +66,9 @@ pub(crate) fn parse_timestamp(s: &str) -> Result { |_, c| c, ), digit1, - )), - ))(s); + ), + )) + .parse(s); let (_, (sign, number_str)) = res?; diff --git a/src/parse_weekday.rs b/src/parse_weekday.rs index d61aca7..d0ee4e8 100644 --- a/src/parse_weekday.rs +++ b/src/parse_weekday.rs @@ -4,7 +4,7 @@ use chrono::Weekday; use nom::branch::alt; use nom::bytes::complete::tag; use nom::combinator::value; -use nom::{self, IResult}; +use nom::{self, IResult, Parser}; // Helper macro to simplify tag matching macro_rules! tag_match { @@ -25,7 +25,8 @@ pub(crate) fn parse_weekday(s: &str) -> Option { tag_match!(Weekday::Fri, "friday", "fri"), tag_match!(Weekday::Sat, "saturday", "sat"), tag_match!(Weekday::Sun, "sunday", "sun"), - )))(s); + ))) + .parse(s); match parse_result { Ok((_, weekday)) => Some(weekday), @@ -63,9 +64,9 @@ mod tests { for (name, weekday) in days { assert_eq!(parse_weekday(name), Some(weekday)); - assert_eq!(parse_weekday(&format!(" {}", name)), Some(weekday)); - assert_eq!(parse_weekday(&format!(" {} ", name)), Some(weekday)); - assert_eq!(parse_weekday(&format!("{} ", name)), Some(weekday)); + assert_eq!(parse_weekday(&format!(" {name}")), Some(weekday)); + assert_eq!(parse_weekday(&format!(" {name} ")), Some(weekday)); + assert_eq!(parse_weekday(&format!("{name} ")), Some(weekday)); let (left, right) = name.split_at(1); let (test_str1, test_str2) = (