diff --git a/Cargo.lock b/Cargo.lock index 085c66f..1dc5436 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" + [[package]] name = "autocfg" version = "1.1.0" @@ -111,9 +117,9 @@ checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" [[package]] name = "num-traits" -version = "0.2.15" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] @@ -126,9 +132,11 @@ checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" [[package]] name = "parse_datetime" -version = "0.4.0" +version = "0.7.0" dependencies = [ + "anyhow", "chrono", + "num-traits", "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index c9c14e1..d5e57ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "parse_datetime" -description = " parsing human-readable relative time strings and converting them to a Duration" -version = "0.4.0" +description = "parsing human-readable relative time strings and converting them to a Duration" +version = "0.7.0" edition = "2021" license = "MIT" repository = "https://github.com/uutils/parse_datetime" @@ -9,4 +9,8 @@ readme = "README.md" [dependencies] chrono = { version="0.4", default-features=false, features=["std", "alloc", "clock"] } +num-traits = "0.2.19" winnow = "0.5.34" + +[dev-dependencies] +anyhow = "1.0.86" diff --git a/src/items/combined.rs b/src/items/combined.rs index 241fe32..e2cfe0e 100644 --- a/src/items/combined.rs +++ b/src/items/combined.rs @@ -14,8 +14,11 @@ //! > ISO 8601 fractional minutes and hours are not supported. Typically, hosts //! > support nanosecond timestamp resolution; excess precision is silently discarded. +use winnow::ascii::dec_uint; +use winnow::token::take; use winnow::{combinator::alt, seq, PResult, Parser}; +use crate::items::combined; use crate::items::space; use super::{ @@ -24,13 +27,17 @@ use super::{ time::{self, Time}, }; -#[derive(PartialEq, Debug, Clone)] +#[derive(PartialEq, Debug, Clone, Default)] pub struct DateTime { - date: Date, - time: Time, + pub(crate) date: Date, + pub(crate) time: Time, } pub fn parse(input: &mut &str) -> PResult { + alt((parse_basic, parse_8digits)).parse_next(input) +} + +fn parse_basic(input: &mut &str) -> PResult { seq!(DateTime { date: date::iso, // Note: the `T` is lowercased by the main parse function @@ -40,6 +47,31 @@ pub fn parse(input: &mut &str) -> PResult { .parse_next(input) } +fn parse_8digits(input: &mut &str) -> PResult { + s(( + take(2usize).and_then(dec_uint), + take(2usize).and_then(dec_uint), + take(2usize).and_then(dec_uint), + take(2usize).and_then(dec_uint), + )) + .map( + |(hour, minute, day, month): (u32, u32, u32, u32)| combined::DateTime { + date: date::Date { + day, + month, + year: None, + }, + time: time::Time { + hour, + minute, + second: 0.0, + offset: None, + }, + }, + ) + .parse_next(input) +} + #[cfg(test)] mod tests { use super::{parse, DateTime}; diff --git a/src/items/date.rs b/src/items/date.rs index f6f3096..d4ab0bd 100644 --- a/src/items/date.rs +++ b/src/items/date.rs @@ -37,7 +37,7 @@ use winnow::{ use super::s; use crate::ParseDateTimeError; -#[derive(PartialEq, Eq, Clone, Debug)] +#[derive(PartialEq, Eq, Clone, Debug, Default)] pub struct Date { pub day: u32, pub month: u32, @@ -96,7 +96,7 @@ fn literal2(input: &mut &str) -> PResult { .parse_next(input) } -fn year(input: &mut &str) -> PResult { +pub fn year(input: &mut &str) -> PResult { s(alt(( take(4usize).try_map(|x: &str| x.parse()), take(3usize).try_map(|x: &str| x.parse()), @@ -116,7 +116,8 @@ fn year(input: &mut &str) -> PResult { fn month(input: &mut &str) -> PResult { s(dec_uint) .try_map(|x| { - (x >= 1 && x <= 12) + (1..=12) + .contains(&x) .then_some(x) .ok_or(ParseDateTimeError::InvalidInput) }) @@ -126,7 +127,8 @@ fn month(input: &mut &str) -> PResult { fn day(input: &mut &str) -> PResult { s(dec_uint) .try_map(|x| { - (x >= 1 && x <= 31) + (1..=31) + .contains(&x) .then_some(x) .ok_or(ParseDateTimeError::InvalidInput) }) diff --git a/src/items/mod.rs b/src/items/mod.rs index 347cdf0..b038ae3 100644 --- a/src/items/mod.rs +++ b/src/items/mod.rs @@ -33,12 +33,29 @@ mod relative; mod time; mod time_zone; mod weekday; -mod number {} +mod epoch { + use winnow::{ascii::dec_int, combinator::preceded, PResult, Parser}; + + use super::s; + pub fn parse(input: &mut &str) -> PResult { + s(preceded("@", dec_int)).parse_next(input) + } +} +mod timezone { + use super::time; + use winnow::PResult; + + pub(crate) fn parse(input: &mut &str) -> PResult { + time::timezone(input) + } +} + +use chrono::{DateTime, Datelike, FixedOffset, TimeZone, Timelike}; use winnow::{ ascii::multispace0, combinator::{alt, delimited, not, peek, preceded, repeat, separated, terminated}, - error::ParserError, + error::{ParseError, ParserError}, stream::AsChar, token::{none_of, take_while}, PResult, Parser, @@ -46,12 +63,14 @@ use winnow::{ #[derive(PartialEq, Debug)] pub enum Item { + Timestamp(i32), + Year(u32), DateTime(combined::DateTime), Date(date::Date), Time(time::Time), Weekday(weekday::Weekday), Relative(relative::Relative), - TimeZone(()), + TimeZone(time::Offset), } /// Allow spaces and comments before a parser @@ -115,32 +134,182 @@ where .parse_next(input) } -/// Parse an item +// Parse an item pub fn parse_one(input: &mut &str) -> PResult { - alt(( + let result = alt(( combined::parse.map(Item::DateTime), date::parse.map(Item::Date), time::parse.map(Item::Time), relative::parse.map(Item::Relative), weekday::parse.map(Item::Weekday), - // time_zone::parse.map(Item::TimeZone), + epoch::parse.map(Item::Timestamp), + timezone::parse.map(Item::TimeZone), + date::year.map(Item::Year), )) - .parse_next(input) + .parse_next(input)?; + + Ok(result) +} + +pub fn parse<'a>( + input: &'a mut &str, +) -> Result, ParseError<&'a str, winnow::error::ContextError>> { + terminated(repeat(0.., parse_one), space).parse(input) +} + +/// Restores year, month, day, etc after applying the timezone +fn with_timezone_restore(offset: time::Offset, at: DateTime) -> DateTime { + let offset: FixedOffset = chrono::FixedOffset::from(offset); + let copy = at; + at.with_timezone(&offset) + .with_day(copy.day()) + .unwrap() + .with_month(copy.month()) + .unwrap() + .with_year(copy.year()) + .unwrap() + .with_hour(copy.hour()) + .unwrap() + .with_minute(copy.minute()) + .unwrap() + .with_second(copy.second()) + .unwrap() +} + +pub(crate) fn at_date(date: Vec, mut d: DateTime) -> DateTime { + d = d.with_hour(0).unwrap_or(d); + d = d.with_minute(0).unwrap_or(d); + d = d.with_second(0).unwrap_or(d); + d = d.with_nanosecond(0).unwrap_or(d); + + for item in date { + match item { + Item::Timestamp(ts) => { + d = chrono::Utc + .timestamp_opt(ts.into(), 0) + .unwrap() + .with_timezone(&d.timezone()) + } + Item::Date(date::Date { day, month, year }) => { + d = d.with_day(day).unwrap_or(d); + d = d.with_month(month).unwrap_or(d); + d = year + // converts i32 to u32 safely + .and_then(|year: u32| >::try_into(year).ok()) + .and_then(|year| d.with_year(year)) + .unwrap_or(d); + } + Item::DateTime(combined::DateTime { + date: date::Date { day, month, year }, + time: + time::Time { + hour, + minute, + second, + offset, + }, + .. + }) => { + if let Some(offset) = offset { + let offset: FixedOffset = chrono::FixedOffset::from(offset); + d = d.with_timezone(&offset); + } + d = d.with_day(day).unwrap_or(d); + d = d.with_month(month).unwrap_or(d); + d = year + // converts i32 to u32 safely + .and_then(|year: u32| >::try_into(year).ok()) + .and_then(|year| d.with_year(year)) + .unwrap_or(d); + d = d.with_hour(hour).unwrap_or(d); + d = d.with_minute(minute).unwrap_or(d); + d = d.with_second(second as u32).unwrap_or(d); + } + Item::Year(year) => d = d.with_year(year as i32).unwrap_or(d), + Item::Time(time::Time { + hour, + minute, + second, + offset, + }) => { + if let Some(offset) = offset { + d = with_timezone_restore(offset, d) + } + d = d.with_hour(hour).unwrap_or(d); + d = d.with_minute(minute).unwrap_or(d); + d = d.with_second(second as u32).unwrap_or(d); + } + Item::Weekday(weekday::Weekday { + offset: _, // TODO: use the offset + day, + }) => { + let mut beginning_of_day = d + .with_hour(0) + .unwrap() + .with_minute(0) + .unwrap() + .with_second(0) + .unwrap() + .with_nanosecond(0) + .unwrap(); + let day = day.into(); + + while beginning_of_day.weekday() != day { + beginning_of_day += chrono::Duration::days(1); + } + + d = beginning_of_day + } + Item::Relative(relative::Relative::Years(x)) => d = d.with_year(x).unwrap_or(d), + Item::Relative(relative::Relative::Months(x)) => { + d = d.with_month(x as u32).unwrap_or(d) + } + Item::Relative(relative::Relative::Days(x)) => d += chrono::Duration::days(x.into()), + Item::Relative(relative::Relative::Hours(x)) => d += chrono::Duration::hours(x.into()), + Item::Relative(relative::Relative::Minutes(x)) => { + d += chrono::Duration::minutes(x.into()); + } + // Seconds are special because they can be given as a float + Item::Relative(relative::Relative::Seconds(x)) => { + d += chrono::Duration::seconds(x as i64); + } + Item::TimeZone(offset) => { + d = with_timezone_restore(offset, d); + } + } + } + + d } -pub fn parse(input: &mut &str) -> Option> { - terminated(repeat(0.., parse_one), space).parse(input).ok() +pub(crate) fn at_local(date: Vec) -> DateTime { + at_date(date, chrono::Local::now().into()) } #[cfg(test)] mod tests { - use super::{date::Date, parse, time::Time, Item}; + use super::{at_date, date::Date, parse, time::Time, Item}; + use chrono::{DateTime, FixedOffset}; + + fn at_utc(date: Vec) -> DateTime { + at_date(date, chrono::Utc::now().fixed_offset()) + } + + fn test_eq_fmt(fmt: &str, input: &str) -> String { + let input = input.to_ascii_lowercase(); + parse(&mut input.as_str()) + .map(at_utc) + .map_err(|e| eprintln!("TEST FAILED AT:\n{}", anyhow::format_err!("{e}"))) + .expect("parsing failed during tests") + .format(fmt) + .to_string() + } #[test] fn date_and_time() { assert_eq!( parse(&mut " 10:10 2022-12-12 "), - Some(vec![ + Ok(vec![ Item::Time(Time { hour: 10, minute: 10, @@ -153,6 +322,43 @@ mod tests { year: Some(2022) }) ]) - ) + ); + + // format, expected output, input + assert_eq!("2024-01-02", test_eq_fmt("%Y-%m-%d", "2024-01-02")); + + // https://github.com/uutils/coreutils/issues/6662 + assert_eq!("2005-01-02", test_eq_fmt("%Y-%m-%d", "2005-01-01 +1 day")); + + // https://github.com/uutils/coreutils/issues/6644 + assert_eq!("Jul 16", test_eq_fmt("%b %d", "Jul 16")); + assert_eq!("0718061449", test_eq_fmt("%m%d%H%M%S", "Jul 18 06:14:49")); + assert_eq!( + "07182024061449", + test_eq_fmt("%m%d%Y%H%M%S", "Jul 18, 2024 06:14:49") + ); + assert_eq!( + "07182024061449", + test_eq_fmt("%m%d%Y%H%M%S", "Jul 18 06:14:49 2024") + ); + + // https://github.com/uutils/coreutils/issues/5177 + assert_eq!( + "2023-07-27T13:53:54+00:00", + test_eq_fmt("%+", "@1690466034") + ); + + // https://github.com/uutils/coreutils/issues/6398 + assert_eq!("1111 1111 00", test_eq_fmt("%m%d %H%M %S", "11111111")); + + assert_eq!( + "2024-07-17 06:14:49 +00:00", + test_eq_fmt("%Y-%m-%d %H:%M:%S %Z", "Jul 17 06:14:49 2024 GMT"), + ); + + assert_eq!( + "2024-07-17 06:14:49 -03:00", + test_eq_fmt("%Y-%m-%d %H:%M:%S %Z", "Jul 17 06:14:49 2024 BRT"), + ); } } diff --git a/src/items/number.rs b/src/items/number.rs deleted file mode 100644 index e795263..0000000 --- a/src/items/number.rs +++ /dev/null @@ -1,45 +0,0 @@ -// For the full copyright and license information, please view the LICENSE -// file that was distributed with this source code. - -//! Numbers without other symbols -//! -//! The GNU docs state: -//! -//! > If the decimal number is of the form yyyymmdd and no other calendar date -//! > item (see Calendar date items) appears before it in the date string, then -//! > yyyy is read as the year, mm as the month number and dd as the day of the -//! > month, for the specified calendar date. -//! > -//! > If the decimal number is of the form hhmm and no other time of day item -//! > appears before it in the date string, then hh is read as the hour of the -//! > day and mm as the minute of the hour, for the specified time of day. mm -//! > can also be omitted. - -use winnow::{combinator::cond, PResult}; - -enum Number { - Date, - Time, - Year, -} - -pub fn parse(seen_date: bool, seen_time: bool, input: &mut &str) -> PResult { - alt(( - cond(!seen_date, date_number), - cond(!seen_time, time_number), - cond(seen_date && seen_time, year_number), - )) - .parse_next(input) -} - -fn date_number(input: &mut &str) -> PResult { - todo!() -} - -fn time_number(input: &mut &str) -> PResult { - todo!() -} - -fn year_number(input: &mut &str) -> PResult { - todo!() -} diff --git a/src/items/relative.rs b/src/items/relative.rs index 7e7cb81..b825c58 100644 --- a/src/items/relative.rs +++ b/src/items/relative.rs @@ -99,7 +99,7 @@ fn ago(input: &mut &str) -> PResult { fn integer_unit(input: &mut &str) -> PResult { s(alpha1) .verify_map(|s: &str| { - Some(match s.strip_suffix('s').unwrap_or(&s) { + Some(match s.strip_suffix('s').unwrap_or(s) { "year" => Relative::Years(1), "month" => Relative::Months(1), "fortnight" => Relative::Days(14), diff --git a/src/items/time.rs b/src/items/time.rs index 7d58896..2b104b7 100644 --- a/src/items/time.rs +++ b/src/items/time.rs @@ -37,9 +37,13 @@ //! > //! > Either ‘am’/‘pm’ or a time zone correction may be specified, but not both. +use std::fmt::Display; + +use chrono::FixedOffset; use winnow::{ ascii::{dec_uint, float}, combinator::{alt, opt, preceded}, + error::{ContextError, ErrMode}, seq, stream::AsChar, token::take_while, @@ -48,7 +52,14 @@ use winnow::{ use super::s; -#[derive(PartialEq, Clone, Debug)] +#[derive(PartialEq, Debug, Clone, Default)] +pub struct Offset { + pub(crate) negative: bool, + pub(crate) hours: u32, + pub(crate) minutes: u32, +} + +#[derive(PartialEq, Clone, Debug, Default)] pub struct Time { pub hour: u32, pub minute: u32, @@ -56,11 +67,46 @@ pub struct Time { pub offset: Option, } -#[derive(PartialEq, Debug, Clone)] -pub struct Offset { - negative: bool, - hours: u32, - minutes: u32, +impl Offset { + fn merge(self, offset: Offset) -> Offset { + let Offset { negative, .. } = offset; + let combine = |a: u32, b: u32, negative: bool| if negative { a - b } else { a + b }; + Offset { + negative, + hours: combine(self.hours, offset.hours, negative), + minutes: combine(self.minutes, offset.minutes, negative), + } + } +} + +impl From for chrono::FixedOffset { + fn from( + Offset { + negative, + hours, + minutes, + }: Offset, + ) -> Self { + let secs = hours * 3600 + minutes * 60; + + if negative { + FixedOffset::west_opt(secs.try_into().unwrap()).unwrap() + } else { + FixedOffset::east_opt(secs.try_into().unwrap()).unwrap() + } + } +} + +impl Display for Offset { + fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + write!( + fmt, + "{}{:02}:{:02}", + if self.negative { "-" } else { "+" }, + self.hours, + self.minutes + ) + } } #[derive(Clone)] @@ -150,8 +196,12 @@ fn second(input: &mut &str) -> PResult { s(float).verify(|x| *x < 60.0).parse_next(input) } +pub(crate) fn timezone(input: &mut &str) -> PResult { + alt((timezone_num, timezone_name_offset)).parse_next(input) +} + /// Parse a timezone starting with `+` or `-` -fn timezone(input: &mut &str) -> PResult { +fn timezone_num(input: &mut &str) -> PResult { seq!(plus_or_minus, alt((timezone_colon, timezone_colonless))) .map(|(negative, (hours, minutes))| Offset { negative, @@ -166,7 +216,7 @@ fn timezone_colon(input: &mut &str) -> PResult<(u32, u32)> { seq!( s(take_while(1..=2, AsChar::is_dec_digit)).try_map(|x: &str| { // parse will fail on empty input - if x == "" { + if x.is_empty() { Ok(0) } else { x.parse() @@ -194,6 +244,260 @@ fn timezone_colonless(input: &mut &str) -> PResult<(u32, u32)> { .parse_next(input) } +/// Parse a timezone by name +fn timezone_name_offset(input: &mut &str) -> PResult { + /// I'm assuming there are no timezone abbreviations with more + /// than 6 charactres + const MAX_TZ_SIZE: usize = 6; + let nextword = s(take_while(1..=MAX_TZ_SIZE, AsChar::is_alpha)).parse_next(input)?; + let tz = tzname_to_offset(nextword)?; + if let Ok(other_tz) = timezone_num.parse_next(input) { + let newtz = tz.merge(other_tz); + + return Ok(newtz); + }; + + Ok(tz) +} + +/// Timezone list extracted from: +/// https://www.timeanddate.com/time/zones/ +fn tzname_to_offset(input: &str) -> PResult { + let mut offset_str = match input { + "z" => Ok("+0"), + "yekt" => Ok("+5"), + "yekst" => Ok("+6"), + "yapt" => Ok("+10"), + "yakt" => Ok("+9"), + "yakst" => Ok("+10"), + "y" => Ok("-12"), + "x" => Ok("-11"), + "wt" => Ok("+0"), + "wst" => Ok("+13"), + "wita" => Ok("+8"), + "wit" => Ok("+9"), + "wib" => Ok("+7"), + "wgt" => Ok("-2"), + "wgst" => Ok("-1"), + "wft" => Ok("+12"), + "wet" => Ok("+0"), + "west" => Ok("+1"), + "wat" => Ok("+1"), + "wast" => Ok("+2"), + "warst" => Ok("-3"), + "wakt" => Ok("+12"), + "w" => Ok("-10"), + "vut" => Ok("+11"), + "vost" => Ok("+6"), + "vlat" => Ok("+10"), + "vlast" => Ok("+11"), + "vet" => Ok("-4"), + "v" => Ok("-9"), + "uzt" => Ok("+5"), + "uyt" => Ok("-3"), + "uyst" => Ok("-2"), + "utc" => Ok("+0"), + "ulat" => Ok("+8"), + "ulast" => Ok("+9"), + "u" => Ok("-8"), + "tvt" => Ok("+12"), + "trt" => Ok("+3"), + "tot" => Ok("+13"), + "tost" => Ok("+14"), + "tmt" => Ok("+5"), + "tlt" => Ok("+9"), + "tkt" => Ok("+13"), + "tjt" => Ok("+5"), + "tft" => Ok("+5"), + "taht" => Ok("-10"), + "t" => Ok("-7"), + "syot" => Ok("+3"), + "sst" => Ok("-11"), + "srt" => Ok("-3"), + "sret" => Ok("+11"), + "sgt" => Ok("+8"), + "sct" => Ok("+4"), + "sbt" => Ok("+11"), + "sast" => Ok("+2"), + "samt" => Ok("+4"), + "sakt" => Ok("+11"), + "s" => Ok("-6"), + "rott" => Ok("-3"), + "ret" => Ok("+4"), + "r" => Ok("-5"), + "qyzt" => Ok("+6"), + "q" => Ok("-4"), + "pyt" => Ok("-4"), + "pyst" => Ok("-3"), + "pwt" => Ok("+9"), + "pt" => Ok("-7"), + "pst" => Ok("-8"), + "pont" => Ok("+11"), + "pmst" => Ok("-3"), + "pmdt" => Ok("-2"), + "pkt" => Ok("+5"), + "pht" => Ok("+8"), + "phot" => Ok("+13"), + "pgt" => Ok("+10"), + "pett" => Ok("+12"), + "petst" => Ok("+12"), + "pet" => Ok("-5"), + "pdt" => Ok("-7"), + "p" => Ok("-3"), + "orat" => Ok("+5"), + "omst" => Ok("+6"), + "omsst" => Ok("+7"), + "o" => Ok("-2"), + "nzst" => Ok("+12"), + "nzdt" => Ok("+13"), + "nut" => Ok("-11"), + "nst" => Ok("-3:30"), + "nrt" => Ok("+12"), + "npt" => Ok("+5:45"), + "novt" => Ok("+7"), + "novst" => Ok("+7"), + "nft" => Ok("+11"), + "nfdt" => Ok("+12"), + "ndt" => Ok("-2:30"), + "nct" => Ok("+11"), + "n" => Ok("-1"), + "myt" => Ok("+8"), + "mvt" => Ok("+5"), + "mut" => Ok("+4"), + "mt" => Ok("-6"), + "mst" => Ok("-7"), + "msk" => Ok("+3"), + "msd" => Ok("+4"), + "mmt" => Ok("+6:30"), + "mht" => Ok("+12"), + "mdt" => Ok("-6"), + "mawt" => Ok("+5"), + "mart" => Ok("-9:30"), + "magt" => Ok("+11"), + "magst" => Ok("+12"), + "m" => Ok("+12"), + "lint" => Ok("+14"), + "lhst" => Ok("+10:30"), + "lhdt" => Ok("+11"), + "l" => Ok("+11"), + "kuyt" => Ok("+4"), + "kst" => Ok("+9"), + "krat" => Ok("+7"), + "krast" => Ok("+8"), + "kost" => Ok("+11"), + "kgt" => Ok("+6"), + "k" => Ok("+10"), + "jst" => Ok("+9"), + "ist" => Ok("+5:30"), + "irst" => Ok("+3:30"), + "irkt" => Ok("+8"), + "irkst" => Ok("+9"), + "irdt" => Ok("+4:30"), + "iot" => Ok("+6"), + "idt" => Ok("+3"), + "ict" => Ok("+7"), + "i" => Ok("+9"), + "hst" => Ok("-10"), + "hovt" => Ok("+7"), + "hovst" => Ok("+8"), + "hkt" => Ok("+8"), + "hdt" => Ok("-9"), + "h" => Ok("+8"), + "gyt" => Ok("-4"), + "gst" => Ok("+4"), + "gmt" => Ok("+0"), + "gilt" => Ok("+12"), + "gft" => Ok("-3"), + "get" => Ok("+4"), + "gamt" => Ok("-9"), + "galt" => Ok("-6"), + "g" => Ok("+7"), + "fnt" => Ok("-2"), + "fkt" => Ok("-4"), + "fkst" => Ok("-3"), + "fjt" => Ok("+12"), + "fjst" => Ok("+13"), + "fet" => Ok("+3"), + "f" => Ok("+6"), + "et" => Ok("-4"), + "est" => Ok("-5"), + "egt" => Ok("-1"), + "egst" => Ok("+0"), + "eet" => Ok("+2"), + "eest" => Ok("+3"), + "edt" => Ok("-4"), + "ect" => Ok("-5"), + "eat" => Ok("+3"), + "east" => Ok("-6"), + "easst" => Ok("-5"), + "e" => Ok("+5"), + "ddut" => Ok("+10"), + "davt" => Ok("+7"), + "d" => Ok("+4"), + "chst" => Ok("+10"), + "cxt" => Ok("+7"), + "cvt" => Ok("-1"), + "ct" => Ok("-5"), + "cst" => Ok("-6"), + "cot" => Ok("-5"), + "clt" => Ok("-4"), + "clst" => Ok("-3"), + "ckt" => Ok("-10"), + "cist" => Ok("-5"), + "cidst" => Ok("-4"), + "chut" => Ok("+10"), + "chot" => Ok("+8"), + "chost" => Ok("+9"), + "chast" => Ok("+12:45"), + "chadt" => Ok("+13:45"), + "cet" => Ok("+1"), + "cest" => Ok("+2"), + "cdt" => Ok("-5"), + "cct" => Ok("+6:30"), + "cat" => Ok("+2"), + "cast" => Ok("+8"), + "c" => Ok("+3"), + "btt" => Ok("+6"), + "bst" => Ok("+6"), + "brt" => Ok("-3"), + "brst" => Ok("-2"), + "bot" => Ok("-4"), + "bnt" => Ok("+8"), + "b" => Ok("+2"), + "aoe" => Ok("-12"), + "azt" => Ok("+4"), + "azst" => Ok("+5"), + "azot" => Ok("-1"), + "azost" => Ok("+0"), + "awst" => Ok("+8"), + "awdt" => Ok("+9"), + "at" => Ok("-4:00"), + "ast" => Ok("-3"), + "art" => Ok("-3"), + "aqtt" => Ok("+5"), + "anat" => Ok("+12"), + "anast" => Ok("+12"), + "amt" => Ok("-4"), + "amst" => Ok("-3"), + "almt" => Ok("+6"), + "akst" => Ok("-9"), + "akdt" => Ok("-8"), + "aft" => Ok("+4:30"), + "aet" => Ok("+11"), + "aest" => Ok("+10"), + "aedt" => Ok("+11"), + "adt" => Ok("+4"), + "acwst" => Ok("+8:45"), + "act" => Ok("-5"), + "acst" => Ok("+9:30"), + "acdt" => Ok("+10:30"), + "a" => Ok("+1"), + _ => Err(ErrMode::Backtrack(ContextError::new())), + }?; + + timezone_num(&mut offset_str) +} + /// Parse the plus or minus character and return whether it was negative fn plus_or_minus(input: &mut &str) -> PResult { s(alt(("+".value(false), "-".value(true)))).parse_next(input) @@ -435,4 +739,36 @@ mod tests { ); } } + + #[test] + fn test_timezone_colonless() { + use super::timezone_colonless; + + fn aux(inp: &mut &str) -> String { + format!("{:?}", timezone_colonless(inp).expect("timezone_colonless")) + } + + assert_eq!("(0, 0)", aux(&mut "0000")); + assert_eq!("(12, 34)", aux(&mut "1234")); + } + + #[test] + fn test_timezone() { + use super::timezone; + let make_timezone = |input: &mut &str| { + timezone(input) + .map_err(|e| eprintln!("TEST FAILED AT:\n{}", anyhow::format_err!("{e}"))) + .map(|offset| format!("{}", offset)) + .expect("expect tests to succeed") + }; + + assert_eq!("+00:00", make_timezone(&mut "+00:00")); + assert_eq!("+00:00", make_timezone(&mut "+0000")); + assert_eq!("-00:00", make_timezone(&mut "-0000")); + assert_eq!("+00:00", make_timezone(&mut "gmt")); + assert_eq!("+01:00", make_timezone(&mut "a")); + assert_eq!("+00:00", make_timezone(&mut "utc")); + assert_eq!("-02:00", make_timezone(&mut "brst")); + assert_eq!("-03:00", make_timezone(&mut "brt")); + } } diff --git a/src/items/weekday.rs b/src/items/weekday.rs index c3ae24c..167c4c0 100644 --- a/src/items/weekday.rs +++ b/src/items/weekday.rs @@ -26,7 +26,7 @@ use winnow::{ascii::alpha1, combinator::opt, seq, PResult, Parser}; use super::{ordinal::ordinal, s}; #[derive(PartialEq, Eq, Debug)] -enum Day { +pub(crate) enum Day { Monday, Tuesday, Wednesday, @@ -38,10 +38,23 @@ enum Day { #[derive(PartialEq, Eq, Debug)] pub struct Weekday { - offset: i32, - day: Day, + pub(crate) offset: i32, + pub(crate) day: Day, } +impl From for chrono::Weekday { + fn from(value: Day) -> Self { + match value { + Day::Monday => chrono::Weekday::Mon, + Day::Tuesday => chrono::Weekday::Tue, + Day::Wednesday => chrono::Weekday::Wed, + Day::Thursday => chrono::Weekday::Thu, + Day::Friday => chrono::Weekday::Fri, + Day::Saturday => chrono::Weekday::Sat, + Day::Sunday => chrono::Weekday::Sun, + } + } +} pub fn parse(input: &mut &str) -> PResult { seq!(Weekday { offset: opt(ordinal).map(|o| o.unwrap_or_default()), diff --git a/src/lib.rs b/src/lib.rs index ea5ef55..c102f5c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,8 +6,7 @@ use std::error::Error; use std::fmt::{self, Display}; -use items::Item; -use winnow::Parser; +use chrono::{DateTime, FixedOffset, Local}; mod items; @@ -31,10 +30,318 @@ impl Display for ParseDateTimeError { impl Error for ParseDateTimeError {} -pub fn parse_datetime(input: &str) -> Result, ParseDateTimeError> { - let input = input.to_ascii_lowercase(); - match items::parse(&mut input.as_ref()) { - Some(x) => Ok(x), - None => Err(ParseDateTimeError::InvalidInput), +pub fn parse_datetime + Clone>( + input: S, +) -> Result, ParseDateTimeError> { + let input = input.as_ref().to_ascii_lowercase(); + match items::parse(&mut input.as_str()) { + Ok(x) => Ok(items::at_local(x)), + Err(_) => Err(ParseDateTimeError::InvalidInput), + } +} +pub fn parse_datetime_at_date + Clone>( + date: DateTime, + input: S, +) -> Result, ParseDateTimeError> { + let input = input.as_ref().to_ascii_lowercase(); + match items::parse(&mut input.as_str()) { + Ok(x) => Ok(items::at_date(x, date.into())), + Err(_) => Err(ParseDateTimeError::InvalidInput), + } +} + +#[cfg(test)] +mod tests { + static TEST_TIME: i64 = 1613371067; + + #[cfg(test)] + mod iso_8601 { + use std::env; + + use chrono::{TimeZone, Utc}; + + use crate::ParseDateTimeError; + use crate::{parse_datetime, tests::TEST_TIME}; + + #[test] + fn test_t_sep() { + env::set_var("TZ", "UTC"); + let dt = "2021-02-15T06:37:47"; + let actual = parse_datetime(dt); + assert_eq!(actual.unwrap().timestamp(), TEST_TIME); + } + + #[test] + fn test_space_sep() { + env::set_var("TZ", "UTC"); + let dt = "2021-02-15 06:37:47"; + let actual = parse_datetime(dt); + assert_eq!(actual.unwrap().timestamp(), TEST_TIME); + } + + #[test] + fn test_space_sep_offset() { + env::set_var("TZ", "UTC"); + // "2021-02-14T22:37:47+00:00" + let dt = "2021-02-14 22:37:47 -0800"; + let actual = parse_datetime(dt).unwrap(); + let t = Utc.timestamp_opt(TEST_TIME, 0).unwrap().fixed_offset(); + assert_eq!(actual, t); + } + + #[test] + fn test_t_sep_offset() { + env::set_var("TZ", "UTC"); + let dt = "2021-02-14T22:37:47 -0800"; + let actual = parse_datetime(dt); + assert_eq!(actual.unwrap().timestamp(), TEST_TIME); + } + + #[test] + fn invalid_formats() { + let invalid_dts = vec![ + "NotADate", + // @TODO + //"202104", + //"202104-12T22:37:47" + ]; + for dt in invalid_dts { + assert_eq!(parse_datetime(dt), Err(ParseDateTimeError::InvalidInput)); + } + } + + #[test] + fn test_epoch_seconds() { + env::set_var("TZ", "UTC"); + let dt = "@1613371067"; + let actual = parse_datetime(dt); + assert_eq!(actual.unwrap().timestamp(), TEST_TIME); + } + + #[test] + fn test_epoch_seconds_non_utc() { + env::set_var("TZ", "EST"); + let dt = "@1613371067"; + let actual = parse_datetime(dt); + assert_eq!(actual.unwrap().timestamp(), TEST_TIME); + } + } + + #[cfg(test)] + mod formats { + use crate::parse_datetime; + use chrono::{DateTime, Local, TimeZone}; + + #[test] + fn single_digit_month_day() { + let x = Local.with_ymd_and_hms(1987, 5, 7, 0, 0, 0).unwrap(); + let expected = DateTime::fixed_offset(&x); + + assert_eq!(Ok(expected), parse_datetime("1987-05-07")); + assert_eq!(Ok(expected), parse_datetime("1987-5-07")); + assert_eq!(Ok(expected), parse_datetime("1987-05-7")); + assert_eq!(Ok(expected), parse_datetime("1987-5-7")); + } + } + + #[cfg(test)] + mod offsets { + use chrono::Local; + + use crate::parse_datetime; + use crate::ParseDateTimeError; + + #[test] + fn test_positive_offsets() { + let offsets: Vec<&str> = vec![ + "+07:00", + "UTC+07:00", + "UTC+0700", + "UTC+07", + "Z+07:00", + "Z+0700", + "Z+07", + ]; + + let expected = format!("{}{}", Local::now().format("%Y%m%d"), "0000+0700"); + for offset in offsets { + let actual = + parse_datetime(offset).unwrap_or_else(|_| panic!("parse_datetime {offset}")); + assert_eq!(expected, format!("{}", actual.format("%Y%m%d%H%M%z"))); + } + } + + #[test] + fn test_partial_offset() { + let offsets = vec!["UTC+00:15", "UTC+0015", "Z+00:15", "Z+0015"]; + let expected = format!("{}{}", Local::now().format("%Y%m%d"), "0000+0015"); + for offset in offsets { + let actual = parse_datetime(offset).unwrap(); + assert_eq!(expected, format!("{}", actual.format("%Y%m%d%H%M%z"))); + } + } + + #[test] + fn invalid_offset_format() { + let invalid_offsets = vec![ + // "+0700", // this is not invalid + // "UTC+2", // this is not invalid + // "Z-1", // this is not invalid, means UTC-1 + "UTC+01005", // this is + ]; + for offset in invalid_offsets { + assert_eq!( + parse_datetime(offset), + Err(ParseDateTimeError::InvalidInput) + ); + } + } + } + + #[cfg(test)] + mod relative_time { + use crate::parse_datetime; + #[test] + fn test_positive_offsets() { + let relative_times = vec![ + "today", + "yesterday", + "1 minute", + "3 hours", + "1 year 3 months", + ]; + + for relative_time in relative_times { + assert!(parse_datetime(relative_time).is_ok()); + } + } + } + + #[cfg(test)] + mod weekday { + use chrono::{DateTime, Local, TimeZone}; + + use crate::parse_datetime_at_date; + + fn get_formatted_date(date: DateTime, weekday: &str) -> String { + let result = parse_datetime_at_date(date, weekday).unwrap(); + + return result.format("%F %T %f").to_string(); + } + + #[test] + fn test_weekday() { + // add some constant hours and minutes and seconds to check its reset + let date = Local.with_ymd_and_hms(2023, 2, 28, 10, 12, 3).unwrap(); + + // 2023-2-28 is tuesday + assert_eq!( + get_formatted_date(date, "tuesday"), + "2023-02-28 00:00:00 000000000" + ); + + // 2023-3-01 is wednesday + assert_eq!( + get_formatted_date(date, "wed"), + "2023-03-01 00:00:00 000000000" + ); + + assert_eq!( + get_formatted_date(date, "thu"), + "2023-03-02 00:00:00 000000000" + ); + + assert_eq!( + get_formatted_date(date, "fri"), + "2023-03-03 00:00:00 000000000" + ); + + assert_eq!( + get_formatted_date(date, "sat"), + "2023-03-04 00:00:00 000000000" + ); + + assert_eq!( + get_formatted_date(date, "sun"), + "2023-03-05 00:00:00 000000000" + ); + } + } + + #[cfg(test)] + mod timestamp { + use crate::parse_datetime; + use chrono::{TimeZone, Utc}; + + #[test] + fn test_positive_and_negative_offsets() { + let offsets: Vec = vec![ + 0, 1, 2, 10, 100, 150, 2000, 1234400000, 1334400000, 1692582913, 2092582910, + ]; + + for offset in offsets { + // positive offset + let time = Utc.timestamp_opt(offset, 0).unwrap(); + 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)); + assert_eq!(dt.unwrap(), time); + } + } + } + + #[cfg(test)] + mod timeonly { + use crate::parse_datetime_at_date; + use chrono::{Local, TimeZone}; + use std::env; + + #[test] + fn test_time_only() { + env::set_var("TZ", "UTC"); + let test_date = Local.with_ymd_and_hms(2024, 3, 3, 0, 0, 0).unwrap(); + let parsed_time = parse_datetime_at_date(test_date, "9:04:30 PM +0530").unwrap(); + // convert the timezone to an offset + let offset = 5 * 3600 + 30 * 60; + let tz = chrono::FixedOffset::east_opt(offset).unwrap(); + let t = chrono::Utc + .timestamp_opt(1709480070, 0) + .unwrap() + .with_timezone(&tz) + .fixed_offset(); + assert_eq!(parsed_time, t) + } + } + /// Used to test example code presented in the README. + mod readme_test { + use crate::parse_datetime; + use chrono::{Local, TimeZone}; + + #[test] + fn test_readme_code() { + let dt = parse_datetime("2021-02-14 06:37:47"); + assert_eq!( + dt.unwrap(), + Local.with_ymd_and_hms(2021, 2, 14, 6, 37, 47).unwrap() + ); + } + } + + mod invalid_test { + use crate::parse_datetime; + use crate::ParseDateTimeError; + + #[test] + fn test_invalid_input() { + let result = parse_datetime("foobar"); + println!("{result:?}"); + assert_eq!(result, Err(ParseDateTimeError::InvalidInput)); + + let result = parse_datetime("invalid 1"); + assert_eq!(result, Err(ParseDateTimeError::InvalidInput)); + } } }