From 93c02dc0b780ac31318bf7bea33d7752523dc704 Mon Sep 17 00:00:00 2001 From: Patrick Klitzke Date: Fri, 15 Sep 2023 18:40:27 +0900 Subject: [PATCH] Fixes Negative numbers for @ not being recognized (#43) * Fixes Negative numbers for @ not being recognized This commit resolves #40. Adds new file and functions parse_timestamp. Adds tests for handling negative numbers. --- Cargo.lock | 17 ++++++ Cargo.toml | 1 + src/lib.rs | 16 ++++-- src/parse_relative_time.rs | 2 + src/parse_timestamp.rs | 111 +++++++++++++++++++++++++++++++++++++ 5 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 src/parse_timestamp.rs diff --git a/Cargo.lock b/Cargo.lock index 6af6ced..cd3b98a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,6 +118,22 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num-traits" version = "0.2.15" @@ -138,6 +154,7 @@ name = "parse_datetime" version = "0.5.0" dependencies = [ "chrono", + "nom", "regex", ] diff --git a/Cargo.toml b/Cargo.toml index b28ba6f..5255ddb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,3 +10,4 @@ readme = "README.md" [dependencies] regex = "1.9" chrono = { version="0.4", default-features=false, features=["std", "alloc", "clock"] } +nom = "7.1.3" diff --git a/src/lib.rs b/src/lib.rs index 90e191b..bb58926 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,10 +14,12 @@ use std::fmt::{self, Display}; // Expose parse_datetime mod parse_relative_time; +mod parse_timestamp; use chrono::{DateTime, FixedOffset, Local, LocalResult, NaiveDateTime, TimeZone}; use parse_relative_time::parse_relative_time; +use parse_timestamp::parse_timestamp; #[derive(Debug, PartialEq)] pub enum ParseDateTimeError { @@ -169,9 +171,9 @@ pub fn parse_datetime_at_date + Clone>( } // Parse epoch seconds - if s.as_ref().bytes().next() == Some(b'@') { - if let Ok(parsed) = NaiveDateTime::parse_from_str(&s.as_ref()[1..], "%s") { - if let Ok(dt) = naive_dt_to_fixed_offset(date, parsed) { + if let Ok(timestamp) = parse_timestamp(s.as_ref()) { + if let Some(timestamp_date) = NaiveDateTime::from_timestamp_opt(timestamp, 0) { + if let Ok(dt) = naive_dt_to_fixed_offset(date, timestamp_date) { return Ok(dt); } } @@ -359,15 +361,21 @@ mod tests { use chrono::{TimeZone, Utc}; #[test] - fn test_positive_offsets() { + 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); } } } diff --git a/src/parse_relative_time.rs b/src/parse_relative_time.rs index afa47b5..7bc0840 100644 --- a/src/parse_relative_time.rs +++ b/src/parse_relative_time.rs @@ -1,3 +1,5 @@ +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. use crate::ParseDateTimeError; use chrono::{Duration, Local, NaiveDate, Utc}; use regex::Regex; diff --git a/src/parse_timestamp.rs b/src/parse_timestamp.rs new file mode 100644 index 0000000..732558d --- /dev/null +++ b/src/parse_timestamp.rs @@ -0,0 +1,111 @@ +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. +use core::fmt; +use std::error::Error; +use std::fmt::Display; +use std::num::ParseIntError; + +use nom::branch::alt; +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}; + +#[derive(Debug, PartialEq)] +pub enum ParseTimestampError { + InvalidNumber(ParseIntError), + InvalidInput, +} + +impl Display for ParseTimestampError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidInput => { + write!(f, "Invalid input string: cannot be parsed as a timestamp") + } + Self::InvalidNumber(err) => { + write!(f, "Invalid timestamp number: {err}") + } + } + } +} + +impl Error for ParseTimestampError {} + +// TODO is this necessary +impl From for ParseTimestampError { + fn from(err: ParseIntError) -> Self { + Self::InvalidNumber(err) + } +} + +type NomError<'a> = nom::Err>; + +impl<'a> From> for ParseTimestampError { + fn from(_err: NomError<'a>) -> Self { + Self::InvalidInput + } +} + +pub(crate) fn parse_timestamp(s: &str) -> Result { + let s = s.trim().to_lowercase(); + let s = s.as_str(); + + 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( + // parse either + or - + alt((char('+'), char('-'))), + // start with a + + || '+', + // whatever we get (+ or -), update the accumulator to that value + |_, c| c, + ), + digit1, + )), + ))(s); + + let (_, (sign, number_str)) = res?; + + let mut number = number_str.parse::()?; + + if sign == '-' { + number *= -1; + } + + Ok(number) +} + +#[cfg(test)] +mod tests { + + use crate::parse_timestamp::parse_timestamp; + + #[test] + fn test_valid_timestamp() { + assert_eq!(parse_timestamp("@1234"), Ok(1234)); + assert_eq!(parse_timestamp("@99999"), Ok(99999)); + assert_eq!(parse_timestamp("@-4"), Ok(-4)); + assert_eq!(parse_timestamp("@-99999"), Ok(-99999)); + assert_eq!(parse_timestamp("@+4"), Ok(4)); + assert_eq!(parse_timestamp("@0"), Ok(0)); + + // gnu date accepts numbers signs and uses the last sign + assert_eq!(parse_timestamp("@---+12"), Ok(12)); + assert_eq!(parse_timestamp("@+++-12"), Ok(-12)); + assert_eq!(parse_timestamp("@+----+12"), Ok(12)); + assert_eq!(parse_timestamp("@++++-123"), Ok(-123)); + } + + #[test] + fn test_invalid_timestamp() { + assert!(parse_timestamp("@").is_err()); + assert!(parse_timestamp("@+--+").is_err()); + assert!(parse_timestamp("@+1ab2").is_err()); + } +}