pub mod datetime;
pub mod timezone;
use crate::datetime::Parse;
use anyhow::{Error, Result};
use chrono::prelude::*;
pub struct DateTimeUtc(pub DateTime<Utc>);
impl std::str::FromStr for DateTimeUtc {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
parse(s).map(DateTimeUtc)
}
}
const MIDNIGHT: NaiveTime = NaiveTime::MIN;
#[inline]
pub fn parse(input: &str) -> Result<DateTime<Utc>> {
Parse::new(&Local, Utc::now().time()).parse(input)
}
#[inline]
pub fn parse_with_preference(input: &str, dmy_preference: bool) -> Result<DateTime<Utc>> {
Parse::new_with_preference(&Utc, MIDNIGHT, dmy_preference).parse(input)
}
#[inline]
pub fn parse_with_timezone<Tz2: TimeZone>(input: &str, tz: &Tz2) -> Result<DateTime<Utc>> {
Parse::new(tz, Utc::now().time()).parse(input)
}
#[inline]
pub fn parse_with_preference_and_timezone<Tz2: TimeZone>(
input: &str,
dmy_preference: bool,
tz: &Tz2,
) -> Result<DateTime<Utc>> {
Parse::new_with_preference(tz, MIDNIGHT, dmy_preference).parse(input)
}
#[inline]
pub fn parse_with<Tz2: TimeZone>(
input: &str,
tz: &Tz2,
default_time: NaiveTime,
) -> Result<DateTime<Utc>> {
Parse::new(tz, default_time).parse(input)
}
#[cfg(test)]
#[allow(deprecated)]
mod tests {
use super::*;
#[test]
fn readme_accepted_formats_all_parse() {
let readme = include_str!("../README.md");
let after_heading = readme
.split_once("## Accepted date formats")
.expect("README lost its '## Accepted date formats' heading")
.1;
let after_fence_marker = after_heading
.split_once("```")
.expect("the accepted-formats section lost its opening code fence")
.1;
let block_start = after_fence_marker
.split_once('\n')
.expect("the opening code fence is not terminated")
.1;
let block = block_start
.split_once("```")
.expect("the accepted-formats section lost its closing code fence")
.0;
let mut checked = 0_usize;
for line in block.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with("//") {
continue;
}
let input = line
.strip_suffix(',')
.unwrap_or(line)
.strip_prefix('"')
.and_then(|rest| rest.strip_suffix('"'))
.unwrap_or_else(|| {
panic!("unparsable line in the accepted-formats block: {line:?}")
});
assert!(
parse_with_preference(input, false).is_ok()
|| parse_with_preference(input, true).is_ok(),
"README lists {input:?}, but it does not parse"
);
checked += 1;
}
assert!(
checked >= 80,
"expected the full accepted-formats list, only found {checked} entries"
);
}
#[derive(Clone, Copy)]
enum Trunc {
Seconds,
None,
}
#[test]
fn parse_in_local() {
let test_cases = vec![
(
"rfc3339",
"2017-11-25T22:34:50Z",
Utc.ymd(2017, 11, 25).and_hms(22, 34, 50),
Trunc::None,
),
(
"rfc2822",
"Wed, 02 Jun 2021 06:31:39 GMT",
Utc.ymd(2021, 6, 2).and_hms(6, 31, 39),
Trunc::None,
),
(
"ymd_hms",
"2021-04-30 21:14:10",
Local
.ymd(2021, 4, 30)
.and_hms(21, 14, 10)
.with_timezone(&Utc),
Trunc::None,
),
(
"ymd_hms_z",
"2017-11-25 13:31:15 PST",
Utc.ymd(2017, 11, 25).and_hms(21, 31, 15),
Trunc::None,
),
(
"ymd",
"2021-02-21",
Local
.ymd(2021, 2, 21)
.and_time(Local::now().time())
.unwrap()
.with_timezone(&Utc),
Trunc::Seconds,
),
(
"ymd_z",
"2021-02-21 PST",
FixedOffset::west(8 * 3600)
.ymd(2021, 2, 21)
.and_time(
Utc::now()
.with_timezone(&FixedOffset::west(8 * 3600))
.time(),
)
.unwrap()
.with_timezone(&Utc),
Trunc::Seconds,
),
(
"month_ymd",
"2021-Feb-21",
Local
.ymd(2021, 2, 21)
.and_time(Local::now().time())
.unwrap()
.with_timezone(&Utc),
Trunc::Seconds,
),
(
"month_mdy_hms",
"May 8, 2009 5:57:51 PM",
Local
.ymd(2009, 5, 8)
.and_hms(17, 57, 51)
.with_timezone(&Utc),
Trunc::None,
),
(
"month_mdy_hms_z",
"May 02, 2021 15:51 UTC",
Utc.ymd(2021, 5, 2).and_hms(15, 51, 0),
Trunc::None,
),
(
"month_mdy",
"May 25, 2021",
Local
.ymd(2021, 5, 25)
.and_time(Local::now().time())
.unwrap()
.with_timezone(&Utc),
Trunc::Seconds,
),
(
"month_dmy_hms",
"14 May 2019 19:11:40.164",
Local
.ymd(2019, 5, 14)
.and_hms_milli(19, 11, 40, 164)
.with_timezone(&Utc),
Trunc::None,
),
(
"month_dmy",
"1 July 2013",
Local
.ymd(2013, 7, 1)
.and_time(Local::now().time())
.unwrap()
.with_timezone(&Utc),
Trunc::Seconds,
),
(
"slash_mdy_hms",
"03/19/2012 10:11:59",
Local
.ymd(2012, 3, 19)
.and_hms(10, 11, 59)
.with_timezone(&Utc),
Trunc::None,
),
(
"slash_mdy",
"08/21/71",
Local
.ymd(1971, 8, 21)
.and_time(Local::now().time())
.unwrap()
.with_timezone(&Utc),
Trunc::Seconds,
),
(
"slash_ymd_hms",
"2012/03/19 10:11:59",
Local
.ymd(2012, 3, 19)
.and_hms(10, 11, 59)
.with_timezone(&Utc),
Trunc::None,
),
(
"slash_ymd",
"2014/3/31",
Local
.ymd(2014, 3, 31)
.and_time(Local::now().time())
.unwrap()
.with_timezone(&Utc),
Trunc::Seconds,
),
];
for &(test, input, want, trunc) in test_cases.iter() {
match trunc {
Trunc::None => {
assert_eq!(
super::parse(input).unwrap(),
want,
"parse_in_local/{}/{}",
test,
input
)
}
Trunc::Seconds => assert_eq!(
super::parse(input)
.unwrap()
.trunc_subsecs(0)
.with_second(0)
.unwrap(),
want.trunc_subsecs(0).with_second(0).unwrap(),
"parse_in_local/{}/{}",
test,
input
),
};
}
}
#[test]
fn parse_with_timezone_in_utc() {
let test_cases = vec![
(
"rfc3339",
"2017-11-25T22:34:50Z",
Utc.ymd(2017, 11, 25).and_hms(22, 34, 50),
Trunc::None,
),
(
"rfc2822",
"Wed, 02 Jun 2021 06:31:39 GMT",
Utc.ymd(2021, 6, 2).and_hms(6, 31, 39),
Trunc::None,
),
(
"ymd_hms",
"2021-04-30 21:14:10",
Utc.ymd(2021, 4, 30).and_hms(21, 14, 10),
Trunc::None,
),
(
"ymd_hms_z",
"2017-11-25 13:31:15 PST",
Utc.ymd(2017, 11, 25).and_hms(21, 31, 15),
Trunc::None,
),
(
"ymd",
"2021-02-21",
Utc.ymd(2021, 2, 21).and_time(Utc::now().time()).unwrap(),
Trunc::Seconds,
),
(
"ymd_z",
"2021-02-21 PST",
FixedOffset::west(8 * 3600)
.ymd(2021, 2, 21)
.and_time(
Utc::now()
.with_timezone(&FixedOffset::west(8 * 3600))
.time(),
)
.unwrap()
.with_timezone(&Utc),
Trunc::Seconds,
),
(
"month_ymd",
"2021-Feb-21",
Utc.ymd(2021, 2, 21).and_time(Utc::now().time()).unwrap(),
Trunc::Seconds,
),
(
"month_mdy_hms",
"May 8, 2009 5:57:51 PM",
Utc.ymd(2009, 5, 8).and_hms(17, 57, 51),
Trunc::None,
),
(
"month_mdy_hms_z",
"May 02, 2021 15:51 UTC",
Utc.ymd(2021, 5, 2).and_hms(15, 51, 0),
Trunc::None,
),
(
"month_mdy",
"May 25, 2021",
Utc.ymd(2021, 5, 25).and_time(Utc::now().time()).unwrap(),
Trunc::Seconds,
),
(
"month_dmy_hms",
"14 May 2019 19:11:40.164",
Utc.ymd(2019, 5, 14).and_hms_milli(19, 11, 40, 164),
Trunc::None,
),
(
"month_dmy",
"1 July 2013",
Utc.ymd(2013, 7, 1).and_time(Utc::now().time()).unwrap(),
Trunc::Seconds,
),
(
"slash_mdy_hms",
"03/19/2012 10:11:59",
Utc.ymd(2012, 3, 19).and_hms(10, 11, 59),
Trunc::None,
),
(
"slash_mdy",
"08/21/71",
Utc.ymd(1971, 8, 21).and_time(Utc::now().time()).unwrap(),
Trunc::Seconds,
),
(
"slash_ymd_hms",
"2012/03/19 10:11:59",
Utc.ymd(2012, 3, 19).and_hms(10, 11, 59),
Trunc::None,
),
(
"slash_ymd",
"2014/3/31",
Utc.ymd(2014, 3, 31).and_time(Utc::now().time()).unwrap(),
Trunc::Seconds,
),
];
for &(test, input, want, trunc) in test_cases.iter() {
match trunc {
Trunc::None => {
assert_eq!(
super::parse_with_timezone(input, &Utc).unwrap(),
want,
"parse_with_timezone_in_utc/{}/{}",
test,
input
)
}
Trunc::Seconds => assert_eq!(
super::parse_with_timezone(input, &Utc)
.unwrap()
.trunc_subsecs(0)
.with_second(0)
.unwrap(),
want.trunc_subsecs(0).with_second(0).unwrap(),
"parse_with_timezone_in_utc/{}/{}",
test,
input
),
};
}
}
#[test]
fn parse_with_preference_and_timezone_in_utc() {
let current_time = Utc::now().time();
let current_hour = current_time.hour();
let current_minute = current_time.minute();
let test_cases = vec![
(
"rfc3339",
"2017-11-25T22:34:50Z",
Utc.ymd(2017, 11, 25).and_hms(22, 34, 50),
Trunc::None,
),
(
"rfc2822",
"Wed, 02 Jun 2021 06:31:39 GMT",
Utc.ymd(2021, 6, 2).and_hms(6, 31, 39),
Trunc::None,
),
(
"month_mdy_hms",
"May 8, 2009 5:57:51 PM",
Utc.ymd(2009, 5, 8).and_hms(17, 57, 51),
Trunc::None,
),
(
"month_mdy_hms_z",
"May 02, 2021 15:51 UTC",
Utc.ymd(2021, 5, 2).and_hms(15, 51, 0),
Trunc::None,
),
(
"month_mdy",
"May 25, 2021",
Utc.ymd(2021, 5, 25).and_time(Utc::now().time()).unwrap(),
Trunc::Seconds,
),
(
"month_dmy_hms",
"14 May 2019 19:11:40.164",
Utc.ymd(2019, 5, 14).and_hms_milli(19, 11, 40, 164),
Trunc::None,
),
(
"month_dmy",
"1 July 2013",
Utc.ymd(2013, 7, 1).and_time(Utc::now().time()).unwrap(),
Trunc::Seconds,
),
(
"slash_dmy_hms",
"19/03/2012 10:11:59",
Utc.ymd(2012, 3, 19).and_hms(10, 11, 59),
Trunc::None,
),
(
"slash_dmy",
"21/08/71",
Utc.ymd(1971, 8, 21).and_time(Utc::now().time()).unwrap(),
Trunc::Seconds,
),
(
"slash_dmy_hms",
"19/03/2012 10:11:59",
Utc.ymd(2012, 3, 19).and_hms(10, 11, 59),
Trunc::None,
),
(
"slash_dmy",
"31/3/2014",
Utc.ymd(2014, 3, 31).and_time(Utc::now().time()).unwrap(),
Trunc::Seconds,
),
];
for &(test, input, want, trunc) in test_cases.iter() {
match trunc {
Trunc::None => {
assert_eq!(
super::parse_with_preference_and_timezone(input, true, &Utc).unwrap(),
want,
"parse_with_preference_and_timezone_in_utc/{}/{}",
test,
input
)
}
Trunc::Seconds => assert_eq!(
super::parse_with_preference_and_timezone(input, true, &Utc)
.unwrap()
.trunc_subsecs(0)
.with_hour(current_hour)
.unwrap()
.with_minute(current_minute)
.unwrap()
.with_second(0)
.unwrap(),
want.trunc_subsecs(0).with_second(0).unwrap(),
"parse_with_preference_and_timezone_in_utc/{}/{}",
test,
input
),
};
}
}
#[test]
fn parse_unambiguous_dmy() {
assert_eq!(
super::parse("31/3/22")
.unwrap()
.with_timezone(&Local)
.date(),
Local.ymd(2022, 3, 31)
);
assert_eq!(
super::parse_with_preference("3/31/22", true)
.unwrap()
.date(),
Utc.ymd(2022, 3, 31)
);
assert_eq!(
super::parse_with_preference("31/07/2021", true)
.unwrap()
.date(),
Utc.ymd(2021, 7, 31)
);
}
#[test]
fn parse_iso_t_no_tz() {
let got = super::parse_with_preference("2020-01-15T08:00:00", false).unwrap();
assert_eq!(got, Utc.ymd(2020, 1, 15).and_hms(8, 0, 0));
let got = super::parse_with_preference("2020-01-15T08:00", false).unwrap();
assert_eq!(got, Utc.ymd(2020, 1, 15).and_hms(8, 0, 0));
for (input, want) in [
(
"2020-01-15T08:00:00.123",
Utc.ymd(2020, 1, 15).and_hms_milli(8, 0, 0, 123),
),
(
"2020-01-15T08:00:00.123456",
Utc.ymd(2020, 1, 15).and_hms_micro(8, 0, 0, 123456),
),
(
"2020-01-15T08:00:00.123456789",
Utc.ymd(2020, 1, 15).and_hms_nano(8, 0, 0, 123456789),
),
] {
assert_eq!(
super::parse_with_preference(input, false).unwrap(),
want,
"parse_iso_t_no_tz/{input}"
);
}
assert_eq!(
super::parse_with_preference("2020-01-15T08:00:00", false).unwrap(),
super::parse_with_preference("2020-01-15 08:00:00", false).unwrap(),
);
assert!(super::parse_with_preference("2020-01-15T08:00:00Z", false).is_ok());
assert!(super::parse_with_preference("2020-01-15T08:00:00+00:00", false).is_ok());
}
#[test]
fn prefilter_rejects_non_date_strings() {
for input in [
"category_value_123",
"first_name",
"value#42",
"a(b)c1",
"100%",
"naïve_2020", ] {
assert!(
super::parse(input).is_err(),
"prefilter should reject {input}"
);
}
for input in [
"2021-04-30 21:14:10", "2020-07-20+08:00", "03/19/2012 10:11:59.3186369", "May 26, 2021, 12:49 AM PDT", "Wed, 02 Jun 2021 06:31:39 GMT", "1671673426.123456789", "-770172300", ] {
assert!(
super::parse(input).is_ok(),
"prefilter must not reject {input}"
);
}
}
}