Skip to content

Commit e503de7

Browse files
authored
Merge pull request #12 from Benjscho/main
add datetime parser
2 parents 70d3e53 + 7ee33d1 commit e503de7

File tree

3 files changed

+275
-2
lines changed

3 files changed

+275
-2
lines changed

README.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44
[![License](http://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/uutils/humantime_to_duration/blob/main/LICENSE)
55
[![CodeCov](https://codecov.io/gh/uutils/humantime_to_duration/branch/main/graph/badge.svg)](https://codecov.io/gh/uutils/humantime_to_duration)
66

7-
A Rust crate for parsing human-readable relative time strings and converting them to a `Duration`.
7+
A Rust crate for parsing human-readable relative time strings and converting them to a `Duration`, or parsing human-readable datetime strings and converting them to a `DateTime`.
88

99
## Features
1010

11-
- Parses a variety of human-readable time formats.
11+
- Parses a variety of human-readable and standard time formats.
1212
- Supports positive and negative durations.
1313
- Allows for chaining time units (e.g., "1 hour 2 minutes" or "2 days and 2 hours").
1414
- Calculate durations relative to a specified date.
@@ -39,6 +39,15 @@ assert_eq!(
3939
);
4040
```
4141

42+
For DateTime parsing, import the `parse_datetime` module:
43+
```
44+
use humantime_to_duration::parse_datetime::from_str;
45+
use chrono::{Local, TimeZone};
46+
47+
let dt = from_str("2021-02-14 06:37:47");
48+
assert_eq!(dt.unwrap(), Local.with_ymd_and_hms(2021, 2, 14, 6, 37, 47).unwrap());
49+
```
50+
4251
### Supported Formats
4352

4453
The `from_str` and `from_str_at_date` functions support the following formats for relative time:
@@ -56,6 +65,8 @@ The `from_str` and `from_str_at_date` functions support the following formats fo
5665

5766
## Return Values
5867

68+
### Duration
69+
5970
The `from_str` and `from_str_at_date` functions return:
6071

6172
- `Ok(Duration)` - If the input string can be parsed as a relative time
@@ -64,6 +75,13 @@ The `from_str` and `from_str_at_date` functions return:
6475
This function will return `Err(ParseDurationError::InvalidInput)` if the input string
6576
cannot be parsed as a relative time.
6677

78+
### parse_datetime
79+
80+
The `from_str` function returns:
81+
82+
- `Ok(DateTime<FixedOffset>)` - If the input string can be prsed as a datetime
83+
- `Err(ParseDurationError::InvalidInput)` - If the input string cannot be parsed
84+
6785
## Fuzzer
6886

6987
To run the fuzzer:

src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
// For the full copyright and license information, please view the LICENSE
22
// file that was distributed with this source code.
33

4+
// Expose parse_datetime
5+
pub mod parse_datetime;
6+
47
use chrono::{Duration, Local, NaiveDate, Utc};
58
use regex::{Error as RegexError, Regex};
69
use std::error::Error;

src/parse_datetime.rs

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
// For the full copyright and license information, please view the LICENSE
2+
// file that was distributed with this source code.
3+
4+
use chrono::{DateTime, FixedOffset, Local, LocalResult, NaiveDateTime, TimeZone};
5+
6+
use crate::ParseDurationError;
7+
8+
/// Formats that parse input can take.
9+
/// Taken from `touch` coreutils
10+
mod format {
11+
pub(crate) const ISO_8601: &str = "%Y-%m-%d";
12+
pub(crate) const ISO_8601_NO_SEP: &str = "%Y%m%d";
13+
pub(crate) const POSIX_LOCALE: &str = "%a %b %e %H:%M:%S %Y";
14+
pub(crate) const YYYYMMDDHHMM_DOT_SS: &str = "%Y%m%d%H%M.%S";
15+
pub(crate) const YYYYMMDDHHMMSS: &str = "%Y-%m-%d %H:%M:%S.%f";
16+
pub(crate) const YYYYMMDDHHMMS: &str = "%Y-%m-%d %H:%M:%S";
17+
pub(crate) const YYYY_MM_DD_HH_MM: &str = "%Y-%m-%d %H:%M";
18+
pub(crate) const YYYYMMDDHHMM: &str = "%Y%m%d%H%M";
19+
pub(crate) const YYYYMMDDHHMM_OFFSET: &str = "%Y%m%d%H%M %z";
20+
pub(crate) const YYYYMMDDHHMM_UTC_OFFSET: &str = "%Y%m%d%H%MUTC%z";
21+
pub(crate) const YYYYMMDDHHMM_ZULU_OFFSET: &str = "%Y%m%d%H%MZ%z";
22+
pub(crate) const YYYYMMDDHHMM_HYPHENATED_OFFSET: &str = "%Y-%m-%d %H:%M %z";
23+
pub(crate) const YYYYMMDDHHMMS_T_SEP: &str = "%Y-%m-%dT%H:%M:%S";
24+
pub(crate) const UTC_OFFSET: &str = "UTC%#z";
25+
pub(crate) const ZULU_OFFSET: &str = "Z%#z";
26+
}
27+
28+
/// Loosely parses a time string and returns a `DateTime` representing the
29+
/// absolute time of the string.
30+
///
31+
/// # Arguments
32+
///
33+
/// * `s` - A string slice representing the time.
34+
///
35+
/// # Examples
36+
///
37+
/// ```
38+
/// use chrono::{DateTime, Utc, TimeZone};
39+
/// let time = humantime_to_duration::parse_datetime::from_str("2023-06-03 12:00:01Z");
40+
/// assert_eq!(time.unwrap(), Utc.with_ymd_and_hms(2023, 06, 03, 12, 00, 01).unwrap());
41+
/// ```
42+
///
43+
/// # Supported formats
44+
///
45+
/// The function supports the following formats for time:
46+
///
47+
/// * ISO formats
48+
/// * timezone offsets, e.g., "UTC-0100"
49+
///
50+
/// # Returns
51+
///
52+
/// * `Ok(DateTime<FixedOffset>)` - If the input string can be parsed as a time
53+
/// * `Err(ParseDurationError)` - If the input string cannot be parsed as a relative time
54+
///
55+
/// # Errors
56+
///
57+
/// This function will return `Err(ParseDurationError::InvalidInput)` if the input string
58+
/// cannot be parsed as a relative time.
59+
///
60+
pub fn from_str<S: AsRef<str> + Clone>(s: S) -> Result<DateTime<FixedOffset>, ParseDurationError> {
61+
// TODO: Replace with a proper customiseable parsing solution using `nom`, `grmtools`, or
62+
// similar
63+
64+
// Formats with offsets don't require NaiveDateTime workaround
65+
for fmt in [
66+
format::YYYYMMDDHHMM_OFFSET,
67+
format::YYYYMMDDHHMM_HYPHENATED_OFFSET,
68+
format::YYYYMMDDHHMM_UTC_OFFSET,
69+
format::YYYYMMDDHHMM_ZULU_OFFSET,
70+
] {
71+
if let Ok(parsed) = DateTime::parse_from_str(s.as_ref(), fmt) {
72+
return Ok(parsed);
73+
}
74+
}
75+
76+
// Parse formats with no offset, assume local time
77+
for fmt in [
78+
format::YYYYMMDDHHMMS_T_SEP,
79+
format::YYYYMMDDHHMM,
80+
format::YYYYMMDDHHMMS,
81+
format::YYYYMMDDHHMMSS,
82+
format::YYYY_MM_DD_HH_MM,
83+
format::YYYYMMDDHHMM_DOT_SS,
84+
format::POSIX_LOCALE,
85+
] {
86+
if let Ok(parsed) = NaiveDateTime::parse_from_str(s.as_ref(), fmt) {
87+
if let Ok(dt) = naive_dt_to_fixed_offset(parsed) {
88+
return Ok(dt);
89+
}
90+
}
91+
}
92+
93+
// Parse epoch seconds
94+
if s.as_ref().bytes().next() == Some(b'@') {
95+
if let Ok(parsed) = NaiveDateTime::parse_from_str(&s.as_ref()[1..], "%s") {
96+
if let Ok(dt) = naive_dt_to_fixed_offset(parsed) {
97+
return Ok(dt);
98+
}
99+
}
100+
}
101+
102+
let ts = s.as_ref().to_owned() + "0000";
103+
// Parse date only formats - assume midnight local timezone
104+
for fmt in [format::ISO_8601, format::ISO_8601_NO_SEP] {
105+
let f = fmt.to_owned() + "%H%M";
106+
if let Ok(parsed) = NaiveDateTime::parse_from_str(&ts, &f) {
107+
if let Ok(dt) = naive_dt_to_fixed_offset(parsed) {
108+
return Ok(dt);
109+
}
110+
}
111+
}
112+
113+
// Parse offsets. chrono doesn't provide any functionality to parse
114+
// offsets, so instead we replicate parse_date behaviour by getting
115+
// the current date with local, and create a date time string at midnight,
116+
// before trying offset suffixes
117+
let local = Local::now();
118+
let ts = format!("{}", local.format("%Y%m%d")) + "0000" + s.as_ref();
119+
for fmt in [format::UTC_OFFSET, format::ZULU_OFFSET] {
120+
let f = format::YYYYMMDDHHMM.to_owned() + fmt;
121+
if let Ok(parsed) = DateTime::parse_from_str(&ts, &f) {
122+
return Ok(parsed);
123+
}
124+
}
125+
126+
// Default parse and failure
127+
s.as_ref()
128+
.parse()
129+
.map_err(|_| (ParseDurationError::InvalidInput))
130+
}
131+
132+
// Convert NaiveDateTime to DateTime<FixedOffset> by assuming the offset
133+
// is local time
134+
fn naive_dt_to_fixed_offset(dt: NaiveDateTime) -> Result<DateTime<FixedOffset>, ()> {
135+
let now = Local::now();
136+
match now.offset().from_local_datetime(&dt) {
137+
LocalResult::Single(dt) => Ok(dt),
138+
_ => Err(()),
139+
}
140+
}
141+
142+
#[cfg(test)]
143+
mod tests {
144+
static TEST_TIME: i64 = 1613371067;
145+
146+
#[cfg(test)]
147+
mod iso_8601 {
148+
use std::env;
149+
150+
use crate::{
151+
parse_datetime::from_str, parse_datetime::tests::TEST_TIME, ParseDurationError,
152+
};
153+
154+
#[test]
155+
fn test_t_sep() {
156+
env::set_var("TZ", "UTC");
157+
let dt = "2021-02-15T06:37:47";
158+
let actual = from_str(dt);
159+
assert_eq!(actual.unwrap().timestamp(), TEST_TIME);
160+
}
161+
162+
#[test]
163+
fn test_space_sep() {
164+
env::set_var("TZ", "UTC");
165+
let dt = "2021-02-15 06:37:47";
166+
let actual = from_str(dt);
167+
assert_eq!(actual.unwrap().timestamp(), TEST_TIME);
168+
}
169+
170+
#[test]
171+
fn test_space_sep_offset() {
172+
env::set_var("TZ", "UTC");
173+
let dt = "2021-02-14 22:37:47 -0800";
174+
let actual = from_str(dt);
175+
assert_eq!(actual.unwrap().timestamp(), TEST_TIME);
176+
}
177+
178+
#[test]
179+
fn test_t_sep_offset() {
180+
env::set_var("TZ", "UTC");
181+
let dt = "2021-02-14T22:37:47 -0800";
182+
let actual = from_str(dt);
183+
assert_eq!(actual.unwrap().timestamp(), TEST_TIME);
184+
}
185+
186+
#[test]
187+
fn invalid_formats() {
188+
let invalid_dts = vec!["NotADate", "202104", "202104-12T22:37:47"];
189+
for dt in invalid_dts {
190+
assert_eq!(from_str(dt), Err(ParseDurationError::InvalidInput));
191+
}
192+
}
193+
}
194+
195+
#[cfg(test)]
196+
mod offsets {
197+
use chrono::Local;
198+
199+
use crate::{parse_datetime::from_str, ParseDurationError};
200+
201+
#[test]
202+
fn test_positive_offsets() {
203+
let offsets = vec![
204+
"UTC+07:00",
205+
"UTC+0700",
206+
"UTC+07",
207+
"Z+07:00",
208+
"Z+0700",
209+
"Z+07",
210+
];
211+
212+
let expected = format!("{}{}", Local::now().format("%Y%m%d"), "0000+0700");
213+
for offset in offsets {
214+
let actual = from_str(offset).unwrap();
215+
assert_eq!(expected, format!("{}", actual.format("%Y%m%d%H%M%z")));
216+
}
217+
}
218+
219+
#[test]
220+
fn test_partial_offset() {
221+
let offsets = vec!["UTC+00:15", "UTC+0015", "Z+00:15", "Z+0015"];
222+
let expected = format!("{}{}", Local::now().format("%Y%m%d"), "0000+0015");
223+
for offset in offsets {
224+
let actual = from_str(offset).unwrap();
225+
assert_eq!(expected, format!("{}", actual.format("%Y%m%d%H%M%z")));
226+
}
227+
}
228+
229+
#[test]
230+
fn invalid_offset_format() {
231+
let invalid_offsets = vec!["+0700", "UTC+2", "Z-1", "UTC+01005"];
232+
for offset in invalid_offsets {
233+
assert_eq!(from_str(offset), Err(ParseDurationError::InvalidInput));
234+
}
235+
}
236+
}
237+
238+
/// Used to test example code presented in the README.
239+
mod readme_test {
240+
use crate::parse_datetime::from_str;
241+
use chrono::{Local, TimeZone};
242+
243+
#[test]
244+
fn test_readme_code() {
245+
let dt = from_str("2021-02-14 06:37:47");
246+
assert_eq!(
247+
dt.unwrap(),
248+
Local.with_ymd_and_hms(2021, 2, 14, 6, 37, 47).unwrap()
249+
);
250+
}
251+
}
252+
}

0 commit comments

Comments
 (0)