├── .gitignore ├── fuzz ├── .gitignore ├── fuzz_targets │ └── parse_datetime.rs ├── Cargo.toml └── Cargo.lock ├── renovate.json ├── Cargo.toml ├── .pre-commit-config.yaml ├── src ├── items │ ├── error.rs │ ├── ordinal.rs │ ├── pure.rs │ ├── combined.rs │ ├── year.rs │ ├── primitive.rs │ ├── weekday.rs │ ├── epoch.rs │ ├── relative.rs │ ├── builder.rs │ ├── time.rs │ ├── timezone.rs │ ├── offset.rs │ ├── mod.rs │ └── date.rs └── lib.rs ├── LICENSE ├── tests ├── common │ └── mod.rs ├── date.rs └── time.rs ├── README.md ├── .github └── workflows │ └── ci.yml └── Cargo.lock /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | -------------------------------------------------------------------------------- /fuzz/.gitignore: -------------------------------------------------------------------------------- 1 | corpus 2 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/parse_datetime.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | 3 | use libfuzzer_sys::fuzz_target; 4 | 5 | fuzz_target!(|data: &[u8]| { 6 | let s = std::str::from_utf8(data).unwrap_or(""); 7 | let _ = parse_datetime::parse_datetime(s); 8 | }); 9 | -------------------------------------------------------------------------------- /fuzz/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "fuzz_parse_datetime" 3 | version = "0.2.0" 4 | edition = "2021" 5 | 6 | [package.metadata] 7 | cargo-fuzz = true 8 | 9 | [dependencies] 10 | libfuzzer-sys = "0.4.7" 11 | 12 | [dependencies.parse_datetime] 13 | path = "../" 14 | 15 | [[bin]] 16 | name = "fuzz_parse_datetime" 17 | path = "fuzz_targets/parse_datetime.rs" 18 | test = false 19 | doc = false 20 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "parse_datetime" 3 | description = "parsing human-readable time strings and converting them to a DateTime" 4 | version = "0.13.3" 5 | edition = "2021" 6 | license = "MIT" 7 | repository = "https://github.com/uutils/parse_datetime" 8 | readme = "README.md" 9 | rust-version = "1.71.1" 10 | 11 | [dependencies] 12 | winnow = "0.7.10" 13 | num-traits = "0.2.19" 14 | jiff = { version = "0.2.15", default-features = false, features = ["tz-system", "tzdb-bundle-platform", "tzdb-zoneinfo"] } 15 | 16 | [dev-dependencies] 17 | rstest = "0.26" 18 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: rust-linting 5 | name: Rust linting 6 | description: Run cargo fmt on files included in the commit. 7 | entry: cargo +nightly fmt -- 8 | pass_filenames: true 9 | types: [file, rust] 10 | language: system 11 | - id: rust-clippy 12 | name: Rust clippy 13 | description: Run cargo clippy on files included in the commit. 14 | entry: cargo +nightly clippy --workspace --all-targets --all-features -- 15 | pass_filenames: false 16 | types: [file, rust] 17 | language: system 18 | -------------------------------------------------------------------------------- /src/items/error.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | use winnow::error::{ContextError, ErrMode}; 4 | 5 | #[derive(Debug)] 6 | pub(crate) enum Error { 7 | ParseError(String), 8 | } 9 | 10 | impl std::error::Error for Error {} 11 | 12 | impl fmt::Display for Error { 13 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 14 | match self { 15 | Error::ParseError(reason) => { 16 | write!(f, "{reason}") 17 | } 18 | } 19 | } 20 | } 21 | 22 | impl From<&'static str> for Error { 23 | fn from(reason: &'static str) -> Self { 24 | Error::ParseError(reason.to_owned()) 25 | } 26 | } 27 | 28 | impl From> for Error { 29 | fn from(err: ErrMode) -> Self { 30 | Error::ParseError(err.to_string()) 31 | } 32 | } 33 | 34 | impl From for Error { 35 | fn from(err: jiff::Error) -> Self { 36 | Error::ParseError(err.to_string()) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Sylvestre Ledru and others 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/common/mod.rs: -------------------------------------------------------------------------------- 1 | // For the full copyright and license information, please view the LICENSE 2 | // file that was distributed with this source code. 3 | 4 | use std::env; 5 | 6 | use jiff::Zoned; 7 | use parse_datetime::{parse_datetime, parse_datetime_at_date}; 8 | 9 | pub fn check_absolute(input: &str, expected: &str) { 10 | env::set_var("TZ", "UTC0"); 11 | 12 | let parsed = match parse_datetime(input) { 13 | Ok(v) => v, 14 | Err(e) => panic!("Failed to parse date from value '{input}': {e}"), 15 | }; 16 | 17 | assert_eq!( 18 | parsed.strftime("%Y-%m-%d %H:%M:%S%:z").to_string(), 19 | expected, 20 | "Input value: {input}" 21 | ); 22 | } 23 | 24 | pub fn check_relative(now: Zoned, input: &str, expected: &str) { 25 | env::set_var("TZ", "UTC0"); 26 | 27 | let parsed = match parse_datetime_at_date(now, input) { 28 | Ok(v) => v, 29 | Err(e) => panic!("Failed to parse date from value '{input}': {e}"), 30 | }; 31 | 32 | assert_eq!( 33 | parsed.strftime("%Y-%m-%d %H:%M:%S%:z").to_string(), 34 | expected, 35 | "Input value: {input}" 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/items/ordinal.rs: -------------------------------------------------------------------------------- 1 | // For the full copyright and license information, please view the LICENSE 2 | // file that was distributed with this source code. 3 | 4 | use winnow::{ 5 | ascii::alpha1, 6 | combinator::{alt, opt}, 7 | ModalResult, Parser, 8 | }; 9 | 10 | use super::primitive::{dec_uint, s}; 11 | 12 | pub(super) fn ordinal(input: &mut &str) -> ModalResult { 13 | alt((text_ordinal, number_ordinal)).parse_next(input) 14 | } 15 | 16 | fn number_ordinal(input: &mut &str) -> ModalResult { 17 | let sign = opt(alt(('+'.value(1), '-'.value(-1)))).map(|s| s.unwrap_or(1)); 18 | (s(sign), s(dec_uint)) 19 | .verify_map(|(s, u): (i32, u32)| { 20 | let i: i32 = u.try_into().ok()?; 21 | Some(s * i) 22 | }) 23 | .parse_next(input) 24 | } 25 | 26 | fn text_ordinal(input: &mut &str) -> ModalResult { 27 | s(alpha1) 28 | .verify_map(|s: &str| { 29 | Some(match s { 30 | "last" => -1, 31 | "this" => 0, 32 | "next" | "first" => 1, 33 | "third" => 3, 34 | "fourth" => 4, 35 | "fifth" => 5, 36 | "sixth" => 6, 37 | "seventh" => 7, 38 | "eight" => 8, 39 | "ninth" => 9, 40 | "tenth" => 10, 41 | "eleventh" => 11, 42 | "twelfth" => 12, 43 | _ => return None, 44 | }) 45 | }) 46 | .parse_next(input) 47 | } 48 | -------------------------------------------------------------------------------- /src/items/pure.rs: -------------------------------------------------------------------------------- 1 | // For the full copyright and license information, please view the LICENSE 2 | // file that was distributed with this source code. 3 | 4 | //! Parse a pure number string. 5 | //! 6 | //! From the GNU docs: 7 | //! 8 | //! > The precise interpretation of a pure decimal number depends on the 9 | //! > context in the date string. 10 | //! > 11 | //! > If the decimal number is of the form YYYYMMDD and no other calendar 12 | //! > date item (*note Calendar date items::) appears before it in the date 13 | //! > string, then YYYY is read as the year, MM as the month number and DD as 14 | //! > the day of the month, for the specified calendar date. 15 | //! > 16 | //! > If the decimal number is of the form HHMM and no other time of day 17 | //! > item appears before it in the date string, then HH is read as the hour 18 | //! > of the day and MM as the minute of the hour, for the specified time of 19 | //! > day. MM can also be omitted. 20 | //! > 21 | //! > If both a calendar date and a time of day appear to the left of a 22 | //! > number in the date string, but no relative item, then the number 23 | //! > overrides the year. 24 | 25 | use winnow::{ModalResult, Parser}; 26 | 27 | use super::primitive::{dec_uint_str, s}; 28 | 29 | /// Parse a pure number string and return it as an owned `String`. We return a 30 | /// `String` here because the interpretation of the number depends on the 31 | /// parsing context in which it appears. The interpretation is deferred to the 32 | /// result building phase. 33 | pub(super) fn parse(input: &mut &str) -> ModalResult { 34 | s(dec_uint_str) 35 | .map(|s: &str| s.to_owned()) 36 | .parse_next(input) 37 | } 38 | -------------------------------------------------------------------------------- /src/items/combined.rs: -------------------------------------------------------------------------------- 1 | // For the full copyright and license information, please view the LICENSE 2 | // file that was distributed with this source code. 3 | 4 | //! Parse an ISO 8601 date and time item 5 | //! 6 | //! The GNU docs state: 7 | //! 8 | //! > The ISO 8601 date and time of day extended format consists of an ISO 8601 9 | //! > date, a ‘T’ character separator, and an ISO 8601 time of day. This format 10 | //! > is also recognized if the ‘T’ is replaced by a space. 11 | //! > 12 | //! > In this format, the time of day should use 24-hour notation. Fractional 13 | //! > seconds are allowed, with either comma or period preceding the fraction. 14 | //! > ISO 8601 fractional minutes and hours are not supported. Typically, hosts 15 | //! > support nanosecond timestamp resolution; excess precision is silently 16 | //! > discarded. 17 | use winnow::{ 18 | combinator::{alt, trace}, 19 | seq, ModalResult, Parser, 20 | }; 21 | 22 | use crate::items::space; 23 | 24 | use super::{date, primitive::s, time}; 25 | 26 | #[derive(PartialEq, Debug, Clone, Default)] 27 | pub(crate) struct DateTime { 28 | pub(crate) date: date::Date, 29 | pub(crate) time: time::Time, 30 | } 31 | 32 | pub(crate) fn parse(input: &mut &str) -> ModalResult { 33 | seq!(DateTime { 34 | date: trace("iso_date", alt((date::iso1, date::iso2))), 35 | // Note: the `T` is lowercased by the main parse function 36 | _: alt((s('t').void(), (' ', space).void())), 37 | time: trace("iso_time", time::iso), 38 | }) 39 | .parse_next(input) 40 | } 41 | 42 | #[cfg(test)] 43 | mod tests { 44 | use super::{parse, DateTime}; 45 | use crate::items::{date::Date, time::Time}; 46 | 47 | #[test] 48 | fn some_date() { 49 | let reference = Some(DateTime { 50 | date: Date { 51 | day: 10, 52 | month: 10, 53 | year: Some(2022), 54 | }, 55 | time: Time { 56 | hour: 10, 57 | minute: 10, 58 | second: 55, 59 | nanosecond: 0, 60 | offset: None, 61 | }, 62 | }); 63 | 64 | for mut s in [ 65 | "2022-10-10t10:10:55", 66 | "2022-10-10 10:10:55", 67 | "2022-10-10 t 10:10:55", 68 | "2022-10-10 10:10:55", 69 | "2022-10-10 (A comment!) t 10:10:55", 70 | "2022-10-10 (A comment!) 10:10:55", 71 | ] { 72 | let old_s = s.to_owned(); 73 | assert_eq!(parse(&mut s).ok(), reference, "Failed string: {old_s}") 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/items/year.rs: -------------------------------------------------------------------------------- 1 | // For the full copyright and license information, please view the LICENSE 2 | // file that was distributed with this source code. 3 | 4 | //! Parse a year from a string. 5 | //! 6 | //! The year must be parsed to a string first, this is to handle a specific GNU 7 | //! compatibility quirk. According to the GNU documentation: "if the year is 68 8 | //! or smaller, then 2000 is added to it; otherwise, if year is less than 100, 9 | //! then 1900 is added to it." This adjustment only applies to two-digit year 10 | //! strings. For example, `"00"` is interpreted as `2000`, whereas `"0"`, 11 | //! `"000"`, or `"0000"` are interpreted as `0`. 12 | 13 | use winnow::{stream::AsChar, token::take_while, ModalResult, Parser}; 14 | 15 | use super::primitive::s; 16 | 17 | // TODO: Leverage `TryFrom` trait. 18 | pub(super) fn year_from_str(year_str: &str) -> Result { 19 | let mut year = year_str 20 | .parse::() 21 | .map_err(|_| "year must be a valid u16 number")?; 22 | 23 | // If year is 68 or smaller, then 2000 is added to it; otherwise, if year 24 | // is less than 100, then 1900 is added to it. 25 | // 26 | // GNU quirk: this only applies to two-digit years. For example, 27 | // "98-01-01" will be parsed as "1998-01-01", whereas "098-01-01" will be 28 | // parsed as "0098-01-01". 29 | if year_str.len() == 2 { 30 | if year <= 68 { 31 | year += 2000 32 | } else { 33 | year += 1900 34 | } 35 | } 36 | 37 | // 2147485547 is the maximum value accepted by GNU, but chrono only 38 | // behaves like GNU for years in the range: [0, 9999], so we keep in the 39 | // range [0, 9999]. 40 | // 41 | // See discussion in https://github.com/uutils/parse_datetime/issues/160. 42 | if year > 9999 { 43 | return Err("year must be no greater than 9999"); 44 | } 45 | 46 | Ok(year) 47 | } 48 | 49 | pub(super) fn year_str<'a>(input: &mut &'a str) -> ModalResult<&'a str> { 50 | s(take_while(1.., AsChar::is_dec_digit)).parse_next(input) 51 | } 52 | 53 | #[cfg(test)] 54 | mod tests { 55 | use super::year_from_str; 56 | 57 | #[test] 58 | fn test_year() { 59 | // 2-characters are converted to 19XX/20XX 60 | assert_eq!(year_from_str("10").unwrap(), 2010u16); 61 | assert_eq!(year_from_str("68").unwrap(), 2068u16); 62 | assert_eq!(year_from_str("69").unwrap(), 1969u16); 63 | assert_eq!(year_from_str("99").unwrap(), 1999u16); 64 | 65 | // 3,4-characters are converted verbatim 66 | assert_eq!(year_from_str("468").unwrap(), 468u16); 67 | assert_eq!(year_from_str("469").unwrap(), 469u16); 68 | assert_eq!(year_from_str("1568").unwrap(), 1568u16); 69 | assert_eq!(year_from_str("1569").unwrap(), 1569u16); 70 | 71 | // years greater than 9999 are not accepted 72 | assert!(year_from_str("10000").is_err()); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # parse_datetime 2 | 3 | [![Crates.io](https://img.shields.io/crates/v/parse_datetime.svg)](https://crates.io/crates/parse_datetime) 4 | [![License](http://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/uutils/parse_datetime/blob/main/LICENSE) 5 | [![CodeCov](https://codecov.io/gh/uutils/parse_datetime/branch/main/graph/badge.svg)](https://codecov.io/gh/uutils/parse_datetime) 6 | 7 | A Rust crate for parsing human-readable relative time strings and human-readable datetime strings and converting them to a jiff's `Zoned` object. 8 | 9 | ## Features 10 | 11 | - Parses a variety of human-readable and standard time formats. 12 | - Supports positive and negative durations. 13 | - Allows for chaining time units (e.g., "1 hour 2 minutes" or "2 days 2 hours ago"). 14 | - Calculate durations relative to a specified date. 15 | - Relies on Jiff 16 | 17 | ## Usage 18 | 19 | Add `parse_datetime` to your `Cargo.toml` with: 20 | 21 | ``` 22 | cargo add parse_datetime 23 | ``` 24 | 25 | Then, import the crate and use the `parse_datetime_at_date` function: 26 | 27 | ```rs 28 | use jiff::{ToSpan, Zoned}; 29 | use parse_datetime::parse_datetime_at_date; 30 | 31 | let now = Zoned::now(); 32 | let after = parse_datetime_at_date(now.clone(), "+3 days"); 33 | 34 | assert_eq!( 35 | now.checked_add(3.days()).unwrap(), 36 | after.unwrap() 37 | ); 38 | ``` 39 | 40 | For DateTime parsing, import the `parse_datetime` function: 41 | 42 | ```rs 43 | use jiff::{civil::{date, time} ,Zoned}; 44 | use parse_datetime::parse_datetime; 45 | 46 | let dt = parse_datetime("2021-02-14 06:37:47"); 47 | assert_eq!(dt.unwrap(), Zoned::now().with().date(date(2021, 2, 14)).time(time(6, 37, 47, 0)).build().unwrap()); 48 | ``` 49 | 50 | ### Supported Formats 51 | 52 | The `parse_datetime` and `parse_datetime_at_date` functions support absolute datetime and the following relative times: 53 | 54 | - `num` `unit` (e.g., "-1 hour", "+3 days") 55 | - `unit` (e.g., "hour", "day") 56 | - "now" or "today" 57 | - "yesterday" 58 | - "tomorrow" 59 | - use "ago" for the past 60 | - use "next" or "last" with `unit` (e.g., "next week", "last year") 61 | - unix timestamps (for example "@0" "@1344000") 62 | 63 | `num` can be a positive or negative integer. 64 | `unit` can be one of the following: "fortnight", "week", "day", "hour", "minute", "min", "second", "sec" and their plural forms. 65 | 66 | ## Return Values 67 | 68 | ### parse_datetime and parse_datetime_at_date 69 | 70 | The `parse_datetime` and `parse_datetime_at_date` function return: 71 | 72 | - `Ok(Zoned)` - If the input string can be parsed as a `Zoned` object 73 | - `Err(ParseDateTimeError::InvalidInput)` - If the input string cannot be parsed 74 | 75 | ## Fuzzer 76 | 77 | To run the fuzzer: 78 | 79 | ``` 80 | $ cd fuzz 81 | $ cargo install cargo-fuzz 82 | $ cargo +nightly fuzz run fuzz_parse_datetime 83 | ``` 84 | 85 | ## License 86 | 87 | This project is licensed under the [MIT License](LICENSE). 88 | 89 | ## Note 90 | 91 | At some point, this crate was called humantime_to_duration. 92 | It has been renamed to cover more cases. 93 | -------------------------------------------------------------------------------- /src/items/primitive.rs: -------------------------------------------------------------------------------- 1 | // For the full copyright and license information, please view the LICENSE 2 | // file that was distributed with this source code. 3 | 4 | //! Primitive combinators. 5 | 6 | use std::str::FromStr; 7 | 8 | use winnow::{ 9 | ascii::{digit1, multispace0, Uint}, 10 | combinator::{alt, delimited, not, opt, peek, preceded, repeat, separated}, 11 | error::{ContextError, ParserError, StrContext, StrContextValue}, 12 | stream::AsChar, 13 | token::{none_of, one_of, take_while}, 14 | Parser, 15 | }; 16 | 17 | /// Allow spaces and comments before a parser 18 | /// 19 | /// Every token parser should be wrapped in this to allow spaces and comments. 20 | /// It is only preceding, because that allows us to check mandatory whitespace 21 | /// after running the parser. 22 | pub(super) fn s<'a, O, E>(p: impl Parser<&'a str, O, E>) -> impl Parser<&'a str, O, E> 23 | where 24 | E: ParserError<&'a str>, 25 | { 26 | preceded(space, p) 27 | } 28 | 29 | /// Parse the space in-between tokens 30 | /// 31 | /// You probably want to use the [`s`] combinator instead. 32 | pub(super) fn space<'a, E>(input: &mut &'a str) -> winnow::Result<(), E> 33 | where 34 | E: ParserError<&'a str>, 35 | { 36 | separated(0.., multispace0, alt((comment, ignored_hyphen_or_plus))).parse_next(input) 37 | } 38 | 39 | /// A hyphen or plus is ignored when it is not followed by a digit 40 | /// 41 | /// This includes being followed by a comment! Compare these inputs: 42 | /// ```txt 43 | /// - 12 weeks 44 | /// - (comment) 12 weeks 45 | /// ``` 46 | /// The last comment should be ignored. 47 | /// 48 | /// The plus is undocumented, but it seems to be ignored. 49 | fn ignored_hyphen_or_plus<'a, E>(input: &mut &'a str) -> winnow::Result<(), E> 50 | where 51 | E: ParserError<&'a str>, 52 | { 53 | ( 54 | alt(('-', '+')), 55 | multispace0, 56 | peek(not(take_while(1, AsChar::is_dec_digit))), 57 | ) 58 | .void() 59 | .parse_next(input) 60 | } 61 | 62 | /// Parse a comment 63 | /// 64 | /// A comment is given between parentheses, which must be balanced. Any other 65 | /// tokens can be within the comment. 66 | fn comment<'a, E>(input: &mut &'a str) -> winnow::Result<(), E> 67 | where 68 | E: ParserError<&'a str>, 69 | { 70 | delimited( 71 | '(', 72 | repeat(0.., alt((none_of(['(', ')']).void(), comment))), 73 | ')', 74 | ) 75 | .parse_next(input) 76 | } 77 | 78 | /// Parse a signed decimal integer. 79 | /// 80 | /// Rationale for not using `winnow::ascii::dec_int`: When upgrading winnow from 81 | /// 0.5 to 0.7, we discovered that `winnow::ascii::dec_int` now accepts only the 82 | /// following two forms: 83 | /// 84 | /// - 0 85 | /// - [+-]?[1-9][0-9]* 86 | /// 87 | /// Inputs like [+-]?0[0-9]* (e.g., `+012`) are therefore rejected. We provide a 88 | /// custom implementation to support such zero-prefixed integers. 89 | #[allow(unused)] 90 | pub(super) fn dec_int<'a, E>(input: &mut &'a str) -> winnow::Result 91 | where 92 | E: ParserError<&'a str>, 93 | { 94 | (opt(one_of(['+', '-'])), digit1) 95 | .void() 96 | .take() 97 | .verify_map(|s: &str| s.parse().ok()) 98 | .parse_next(input) 99 | } 100 | 101 | /// Parse an unsigned decimal integer. 102 | /// 103 | /// See the rationale for `dec_int` for why we don't use 104 | /// `winnow::ascii::dec_uint`. 105 | pub(super) fn dec_uint<'a, O, E>(input: &mut &'a str) -> winnow::Result 106 | where 107 | O: Uint + FromStr, 108 | E: ParserError<&'a str>, 109 | { 110 | dec_uint_str 111 | .verify_map(|s: &str| s.parse().ok()) 112 | .parse_next(input) 113 | } 114 | 115 | /// Parse an unsigned decimal integer as a string slice. 116 | pub(super) fn dec_uint_str<'a, E>(input: &mut &'a str) -> winnow::Result<&'a str, E> 117 | where 118 | E: ParserError<&'a str>, 119 | { 120 | digit1.void().take().parse_next(input) 121 | } 122 | 123 | /// Parse a colon preceded by whitespace. 124 | pub(super) fn colon<'a, E>(input: &mut &'a str) -> winnow::Result<(), E> 125 | where 126 | E: ParserError<&'a str>, 127 | { 128 | s(':').void().parse_next(input) 129 | } 130 | 131 | /// Parse a plus or minus character optionally preceeded by whitespace. 132 | pub(super) fn plus_or_minus<'a, E>(input: &mut &'a str) -> winnow::Result 133 | where 134 | E: ParserError<&'a str>, 135 | { 136 | s(alt(('+', '-'))).parse_next(input) 137 | } 138 | 139 | /// Create a context error with a reason. 140 | pub(super) fn ctx_err(reason: &'static str) -> ContextError { 141 | let mut err = ContextError::new(); 142 | err.push(StrContext::Expected(StrContextValue::Description(reason))); 143 | err 144 | } 145 | -------------------------------------------------------------------------------- /src/items/weekday.rs: -------------------------------------------------------------------------------- 1 | // For the full copyright and license information, please view the LICENSE 2 | // file that was distributed with this source code. 3 | 4 | // spell-checker:ignore wednes 5 | 6 | //! The GNU docs state: 7 | //! 8 | //! > The explicit mention of a day of the week will forward the date (only if 9 | //! > necessary) to reach that day of the week in the future. 10 | //! > 11 | //! > Days of the week may be spelled out in full: ‘Sunday’, ‘Monday’, 12 | //! > ‘Tuesday’, ‘Wednesday’, ‘Thursday’, ‘Friday’ or ‘Saturday’. Days may be 13 | //! > abbreviated to their first three letters, optionally followed by a 14 | //! > period. The special abbreviations ‘Tues’ for ‘Tuesday’, ‘Wednes’ for 15 | //! > ‘Wednesday’ and ‘Thur’ or ‘Thurs’ for ‘Thursday’ are also allowed. 16 | //! > 17 | //! > A number may precede a day of the week item to move forward supplementary 18 | //! > weeks. It is best used in expression like ‘third monday’. In this 19 | //! > context, ‘last day’ or ‘next day’ is also acceptable; they move one week 20 | //! > before or after the day that day by itself would represent. 21 | //! > 22 | //! > A comma following a day of the week item is ignored. 23 | 24 | use winnow::{ 25 | ascii::alpha1, 26 | combinator::{opt, terminated}, 27 | seq, ModalResult, Parser, 28 | }; 29 | 30 | use super::{ordinal::ordinal, primitive::s}; 31 | 32 | #[derive(PartialEq, Eq, Debug, Copy, Clone)] 33 | pub(crate) enum Day { 34 | Monday, 35 | Tuesday, 36 | Wednesday, 37 | Thursday, 38 | Friday, 39 | Saturday, 40 | Sunday, 41 | } 42 | 43 | #[derive(PartialEq, Eq, Debug)] 44 | pub(crate) struct Weekday { 45 | pub(crate) offset: i32, 46 | pub(crate) day: Day, 47 | } 48 | 49 | impl From for jiff::civil::Weekday { 50 | fn from(value: Day) -> Self { 51 | match value { 52 | Day::Monday => jiff::civil::Weekday::Monday, 53 | Day::Tuesday => jiff::civil::Weekday::Tuesday, 54 | Day::Wednesday => jiff::civil::Weekday::Wednesday, 55 | Day::Thursday => jiff::civil::Weekday::Thursday, 56 | Day::Friday => jiff::civil::Weekday::Friday, 57 | Day::Saturday => jiff::civil::Weekday::Saturday, 58 | Day::Sunday => jiff::civil::Weekday::Sunday, 59 | } 60 | } 61 | } 62 | 63 | /// Parse a weekday item. 64 | pub(super) fn parse(input: &mut &str) -> ModalResult { 65 | seq!(Weekday { 66 | offset: opt(ordinal).map(|o| o.unwrap_or_default()), 67 | day: terminated(day, opt(s(","))), 68 | }) 69 | .parse_next(input) 70 | } 71 | 72 | fn day(input: &mut &str) -> ModalResult { 73 | s(alpha1) 74 | .verify_map(|s: &str| { 75 | Some(match s { 76 | "monday" | "mon" | "mon." => Day::Monday, 77 | "tuesday" | "tue" | "tue." | "tues" => Day::Tuesday, 78 | "wednesday" | "wed" | "wed." | "wednes" => Day::Wednesday, 79 | "thursday" | "thu" | "thu." | "thur" | "thurs" => Day::Thursday, 80 | "friday" | "fri" | "fri." => Day::Friday, 81 | "saturday" | "sat" | "sat." => Day::Saturday, 82 | "sunday" | "sun" | "sun." => Day::Sunday, 83 | _ => return None, 84 | }) 85 | }) 86 | .parse_next(input) 87 | } 88 | 89 | #[cfg(test)] 90 | mod tests { 91 | use super::{parse, Day, Weekday}; 92 | 93 | #[test] 94 | fn this_monday() { 95 | for mut s in [ 96 | "monday", 97 | "mon", 98 | "mon.", 99 | "this monday", 100 | "this mon", 101 | "this mon.", 102 | "this monday", 103 | "this - monday", 104 | "0 monday", 105 | ] { 106 | assert_eq!( 107 | parse(&mut s).unwrap(), 108 | Weekday { 109 | offset: 0, 110 | day: Day::Monday, 111 | } 112 | ); 113 | } 114 | } 115 | 116 | #[test] 117 | fn next_tuesday() { 118 | for s in ["tuesday", "tue", "tue.", "tues"] { 119 | let s = format!("next {s}"); 120 | assert_eq!( 121 | parse(&mut s.as_ref()).unwrap(), 122 | Weekday { 123 | offset: 1, 124 | day: Day::Tuesday, 125 | } 126 | ); 127 | } 128 | } 129 | 130 | #[test] 131 | fn last_wednesday() { 132 | for s in ["wednesday", "wed", "wed.", "wednes"] { 133 | let s = format!("last {s}"); 134 | assert_eq!( 135 | parse(&mut s.as_ref()).unwrap(), 136 | Weekday { 137 | offset: -1, 138 | day: Day::Wednesday, 139 | } 140 | ); 141 | } 142 | } 143 | 144 | #[test] 145 | fn optional_comma() { 146 | for mut s in ["monday,", "mon,", "mon.,", "mon. ,"] { 147 | assert_eq!( 148 | parse(&mut s).unwrap(), 149 | Weekday { 150 | offset: 0, 151 | day: Day::Monday, 152 | } 153 | ); 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/items/epoch.rs: -------------------------------------------------------------------------------- 1 | // For the full copyright and license information, please view the LICENSE 2 | // file that was distributed with this source code. 3 | 4 | //! Parse a timestamp item. 5 | //! 6 | //! From the GNU docs: 7 | //! 8 | //! > If you precede a number with ‘@’, it represents an internal timestamp as 9 | //! > a count of seconds. The number can contain an internal decimal point 10 | //! > (either ‘.’ or ‘,’); any excess precision not supported by the internal 11 | //! > representation is truncated toward minus infinity. Such a number cannot 12 | //! > be combined with any other date item, as it specifies a complete 13 | //! > timestamp. 14 | //! > 15 | //! > On most hosts, these counts ignore the presence of leap seconds. For 16 | //! > example, on most hosts ‘@1483228799’ represents 2016-12-31 23:59:59 UTC, 17 | //! > ‘@1483228800’ represents 2017-01-01 00:00:00 UTC, and there is no way to 18 | //! > represent the intervening leap second 2016-12-31 23:59:60 UTC. 19 | 20 | use winnow::{ 21 | ascii::digit1, 22 | combinator::{opt, preceded}, 23 | token::one_of, 24 | ModalResult, Parser, 25 | }; 26 | 27 | use super::primitive::{dec_uint, plus_or_minus, s}; 28 | 29 | /// Represents a timestamp with nanosecond accuracy. 30 | /// 31 | /// # Invariants 32 | /// 33 | /// - `nanosecond` is always in the range of `0..1_000_000_000`. 34 | /// - Negative timestamps are represented by a negative `second` value and a 35 | /// positive `nanosecond` value. 36 | #[derive(Debug, PartialEq)] 37 | pub(super) struct Timestamp { 38 | second: i64, 39 | nanosecond: u32, 40 | } 41 | 42 | impl TryFrom for jiff::Timestamp { 43 | type Error = &'static str; 44 | 45 | fn try_from(ts: Timestamp) -> Result { 46 | jiff::Timestamp::new( 47 | ts.second, 48 | i32::try_from(ts.nanosecond).map_err(|_| "nanosecond in timestamp exceeds i32::MAX")?, 49 | ) 50 | .map_err(|_| "timestamp value is out of valid range") 51 | } 52 | } 53 | 54 | /// Parse a timestamp in the form of `@1234567890` or `@-1234567890.12345` or 55 | /// `@1234567890,12345`. 56 | pub(super) fn parse(input: &mut &str) -> ModalResult { 57 | (s("@"), opt(plus_or_minus), s(sec_and_nsec)) 58 | .verify_map(|(_, sign, (sec, nsec))| { 59 | let sec = i64::try_from(sec).ok()?; 60 | let (second, nanosecond) = match (sign, nsec) { 61 | (Some('-'), 0) => (-sec, 0), 62 | // Truncate towards minus infinity. 63 | (Some('-'), _) => ((-sec).checked_sub(1)?, 1_000_000_000 - nsec), 64 | _ => (sec, nsec), 65 | }; 66 | Some(Timestamp { second, nanosecond }) 67 | }) 68 | .parse_next(input) 69 | } 70 | 71 | /// Parse a second value in the form of `1234567890` or `1234567890.12345` or 72 | /// `1234567890,12345`. 73 | /// 74 | /// The first part represents whole seconds. The optional second part represents 75 | /// fractional seconds, parsed as a nanosecond value from up to 9 digits 76 | /// (padded with zeros on the right if fewer digits are present). If the second 77 | /// part is omitted, it defaults to 0 nanoseconds. 78 | pub(super) fn sec_and_nsec(input: &mut &str) -> ModalResult<(u64, u32)> { 79 | (dec_uint, opt(preceded(one_of(['.', ',']), digit1))) 80 | .verify_map(|(sec, opt_nsec_str)| match opt_nsec_str { 81 | Some(nsec_str) if nsec_str.len() >= 9 => Some((sec, nsec_str[..9].parse().ok()?)), 82 | Some(nsec_str) => { 83 | let multiplier = 10_u32.pow(9 - nsec_str.len() as u32); 84 | Some((sec, nsec_str.parse::().ok()?.checked_mul(multiplier)?)) 85 | } 86 | None => Some((sec, 0)), 87 | }) 88 | .parse_next(input) 89 | } 90 | 91 | #[cfg(test)] 92 | mod tests { 93 | use super::*; 94 | 95 | fn ts(second: i64, nanosecond: u32) -> Timestamp { 96 | Timestamp { second, nanosecond } 97 | } 98 | 99 | #[test] 100 | fn parse_sec_and_nsec() { 101 | for (input, expected) in [ 102 | ("1234567890", (1234567890, 0)), // only seconds 103 | ("1234567890.12345", (1234567890, 123450000)), // seconds and nanoseconds, '.' as floating point 104 | ("1234567890,12345", (1234567890, 123450000)), // seconds and nanoseconds, ',' as floating point 105 | ("1234567890.1234567890123", (1234567890, 123456789)), // nanoseconds with more than 9 digits, truncated 106 | ] { 107 | let mut s = input; 108 | assert_eq!(sec_and_nsec(&mut s).unwrap(), expected, "{input}"); 109 | } 110 | 111 | for input in [ 112 | ".1234567890", // invalid: no leading seconds 113 | "-1234567890", // invalid: negative input not allowed 114 | ] { 115 | let mut s = input; 116 | assert!(sec_and_nsec(&mut s).is_err(), "{input}"); 117 | } 118 | } 119 | 120 | #[test] 121 | fn timestamp() { 122 | for (input, expected) in [ 123 | ("@1234567890", ts(1234567890, 0)), // positive seconds, no nanoseconds 124 | ("@ 1234567890", ts(1234567890, 0)), // space after '@', positive seconds, no nanoseconds 125 | ("@-1234567890", ts(-1234567890, 0)), // negative seconds, no nanoseconds 126 | ("@ -1234567890", ts(-1234567890, 0)), // space after '@', negative seconds, no nanoseconds 127 | ("@ - 1234567890", ts(-1234567890, 0)), // space after '@' and after '-', negative seconds, no nanoseconds 128 | ("@1234567890.12345", ts(1234567890, 123450000)), // positive seconds with nanoseconds, '.' as floating point 129 | ("@1234567890,12345", ts(1234567890, 123450000)), // positive seconds with nanoseconds, ',' as floating point 130 | ("@-1234567890.12345", ts(-1234567891, 876550000)), // negative seconds with nanoseconds, '.' as floating point 131 | ("@1234567890.1234567890123", ts(1234567890, 123456789)), // nanoseconds with more than 9 digits, truncated 132 | ] { 133 | let mut s = input; 134 | assert_eq!(parse(&mut s).unwrap(), expected, "{input}"); 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /tests/date.rs: -------------------------------------------------------------------------------- 1 | // For the full copyright and license information, please view the LICENSE 2 | // file that was distributed with this source code. 3 | 4 | use jiff::{civil::DateTime, tz::TimeZone}; 5 | use rstest::rstest; 6 | 7 | mod common; 8 | use common::{check_absolute, check_relative}; 9 | 10 | // The expected values are produced by GNU date version 8.32 11 | // export LC_TIME=en_US.UTF-8 12 | // export TZ=UTC 13 | // date --rfc-3339=seconds --date="2022-11-14" 14 | // 15 | // Documentation for the date format can be found at: 16 | // https://www.gnu.org/software/coreutils/manual/html_node/Calendar-date-items.html 17 | 18 | #[rstest] 19 | #[case::iso8601("2022-11-14", "2022-11-14 00:00:00+00:00")] 20 | #[case::short_year_22("22-11-14", "2022-11-14 00:00:00+00:00")] 21 | #[case::short_year_68("68-11-14", "2068-11-14 00:00:00+00:00")] 22 | #[case::short_year_00("00-11-14", "2000-11-14 00:00:00+00:00")] 23 | #[case::short_year_69("69-11-14", "1969-11-14 00:00:00+00:00")] 24 | #[case::short_year_99("99-11-14", "1999-11-14 00:00:00+00:00")] 25 | #[case::us_style("11/14/2022", "2022-11-14 00:00:00+00:00")] 26 | #[case::us_style_short_year("11/14/22", "2022-11-14 00:00:00+00:00")] 27 | #[case::year_zero("0000-01-01", "0000-01-01 00:00:00+00:00")] 28 | #[case::year_001("001-11-14", "0001-11-14 00:00:00+00:00")] 29 | #[case::year_100("100-11-14", "0100-11-14 00:00:00+00:00")] 30 | #[case::year_999("999-11-14", "0999-11-14 00:00:00+00:00")] 31 | #[case::year_9999("9999-11-14", "9999-11-14 00:00:00+00:00")] 32 | /** TODO: https://github.com/uutils/parse_datetime/issues/160 33 | #[case::year_10000("10000-12-31", "10000-12-31 00:00:00+00:00")] 34 | #[case::year_100000("100000-12-31", "100000-12-31 00:00:00+00:00")] 35 | #[case::year_1000000("1000000-12-31", "1000000-12-31 00:00:00+00:00")] 36 | #[case::year_10000000("10000000-12-31", "10000000-12-31 00:00:00+00:00")] 37 | #[case::max_date("2147485547-12-31", "2147485547-12-31 00:00:00+00:00")] 38 | **/ 39 | #[case::long_month_in_the_middle("14 November 2022", "2022-11-14 00:00:00+00:00")] 40 | #[case::long_month_in_the_middle_lowercase("14 november 2022", "2022-11-14 00:00:00+00:00")] 41 | #[case::long_month_in_the_middle_uppercase("14 NOVEMBER 2022", "2022-11-14 00:00:00+00:00")] 42 | #[case::short_month_in_the_middle("14 nov 2022", "2022-11-14 00:00:00+00:00")] 43 | #[case::short_month_in_the_uppercase("14 NOV 2022", "2022-11-14 00:00:00+00:00")] 44 | #[case::long_month_in_the_middle_hyphened("14-november-2022", "2022-11-14 00:00:00+00:00")] 45 | #[case::long_month_in_the_middle_nospace("14november2022", "2022-11-14 00:00:00+00:00")] 46 | #[case::short_month_in_the_middle_hyphened("14-nov-2022", "2022-11-14 00:00:00+00:00")] 47 | #[case::short_month_in_the_middle_nospace("14nov2022", "2022-11-14 00:00:00+00:00")] 48 | #[case::long_month_at_start("November 14 2022", "2022-11-14 00:00:00+00:00")] 49 | #[case::long_month_at_start_with_comma("November 14, 2022", "2022-11-14 00:00:00+00:00")] 50 | #[case::short_month_at_start("nov 14 2022", "2022-11-14 00:00:00+00:00")] 51 | #[case::long_month_in_the_middle_jan("14 January 2022", "2022-01-14 00:00:00+00:00")] 52 | #[case::long_month_in_the_middle_feb("14 February 2022", "2022-02-14 00:00:00+00:00")] 53 | #[case::long_month_in_the_middle_mar("14 March 2022", "2022-03-14 00:00:00+00:00")] 54 | #[case::long_month_in_the_middle_apr("14 April 2022", "2022-04-14 00:00:00+00:00")] 55 | #[case::long_month_in_the_middle_may("14 May 2022", "2022-05-14 00:00:00+00:00")] 56 | #[case::long_month_in_the_middle_jun("14 June 2022", "2022-06-14 00:00:00+00:00")] 57 | #[case::long_month_in_the_middle_jul("14 July 2022", "2022-07-14 00:00:00+00:00")] 58 | #[case::long_month_in_the_middle_aug("14 August 2022", "2022-08-14 00:00:00+00:00")] 59 | #[case::long_month_in_the_middle_sep("14 September 2022", "2022-09-14 00:00:00+00:00")] 60 | #[case::long_month_in_the_middle_oct("14 October 2022", "2022-10-14 00:00:00+00:00")] 61 | #[case::long_month_in_the_middle_dec("14 December 2022", "2022-12-14 00:00:00+00:00")] 62 | #[case::short_month_in_the_middle_jan("14 jan 2022", "2022-01-14 00:00:00+00:00")] 63 | #[case::short_month_in_the_middle_feb("14 feb 2022", "2022-02-14 00:00:00+00:00")] 64 | #[case::short_month_in_the_middle_mar("14 mar 2022", "2022-03-14 00:00:00+00:00")] 65 | #[case::short_month_in_the_middle_apr("14 apr 2022", "2022-04-14 00:00:00+00:00")] 66 | #[case::short_month_in_the_middle_may("14 may 2022", "2022-05-14 00:00:00+00:00")] 67 | #[case::short_month_in_the_middle_jun("14 jun 2022", "2022-06-14 00:00:00+00:00")] 68 | #[case::short_month_in_the_middle_jul("14 jul 2022", "2022-07-14 00:00:00+00:00")] 69 | #[case::short_month_in_the_middle_aug("14 aug 2022", "2022-08-14 00:00:00+00:00")] 70 | #[case::short_month_in_the_middle_sep("14 sep 2022", "2022-09-14 00:00:00+00:00")] 71 | #[case::short_month_in_the_middle_sept("14 sept 2022", "2022-09-14 00:00:00+00:00")] 72 | #[case::short_month_in_the_middle_oct("14 oct 2022", "2022-10-14 00:00:00+00:00")] 73 | #[case::short_month_in_the_middle_dec("14 dec 2022", "2022-12-14 00:00:00+00:00")] 74 | fn test_absolute_date_numeric(#[case] input: &str, #[case] expected: &str) { 75 | check_absolute(input, expected); 76 | } 77 | 78 | #[rstest] 79 | #[case::us_style("11/14", 2022, "2022-11-14 00:00:00+00:00")] 80 | #[case::alphabetical_full_month_in_front("november 14", 2022, "2022-11-14 00:00:00+00:00")] 81 | #[case::alphabetical_full_month_at_back("14 november", 2022, "2022-11-14 00:00:00+00:00")] 82 | #[case::alphabetical_short_month_in_front("nov 14", 2022, "2022-11-14 00:00:00+00:00")] 83 | #[case::alphabetical_short_month_at_back("14 nov", 2022, "2022-11-14 00:00:00+00:00")] 84 | #[case::alphabetical_full_month_in_front("november 14", 2022, "2022-11-14 00:00:00+00:00")] 85 | #[case::alphabetical_full_month_at_back("14 november", 2022, "2022-11-14 00:00:00+00:00")] 86 | #[case::alphabetical_short_month_in_front("nov 14", 2022, "2022-11-14 00:00:00+00:00")] 87 | #[case::alphabetical_short_month_at_back("14 nov", 2022, "2022-11-14 00:00:00+00:00")] 88 | #[case::alphabetical_long_month_at_back_hyphen("14-november", 2022, "2022-11-14 00:00:00+00:00")] 89 | #[case::alphabetical_short_month_at_back_hyphen("14-nov", 2022, "2022-11-14 00:00:00+00:00")] 90 | fn test_date_omitting_year(#[case] input: &str, #[case] year: u32, #[case] expected: &str) { 91 | let now = format!("{year}-06-01 00:00:00") 92 | .parse::() 93 | .unwrap() 94 | .to_zoned(TimeZone::UTC) 95 | .unwrap(); 96 | check_relative(now, input, expected); 97 | } 98 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | 3 | name: Basic CI 4 | 5 | env: 6 | CARGO_TERM_COLOR: always 7 | RUST_MIN_SRV: "1.71.1" 8 | 9 | jobs: 10 | check: 11 | name: cargo check 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest, macOS-latest, windows-latest] 16 | steps: 17 | - uses: actions/checkout@v6 18 | - uses: dtolnay/rust-toolchain@stable 19 | - run: cargo check 20 | 21 | test: 22 | name: cargo test 23 | runs-on: ${{ matrix.os }} 24 | strategy: 25 | matrix: 26 | os: [ubuntu-latest, macOS-latest, windows-latest] 27 | steps: 28 | - uses: actions/checkout@v6 29 | - uses: dtolnay/rust-toolchain@stable 30 | - run: cargo test 31 | 32 | fmt: 33 | name: cargo fmt --all -- --check 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v6 37 | - uses: dtolnay/rust-toolchain@stable 38 | - run: rustup component add rustfmt 39 | - run: cargo fmt --all -- --check 40 | 41 | clippy: 42 | name: cargo clippy -- -D warnings 43 | runs-on: ${{ matrix.os }} 44 | strategy: 45 | matrix: 46 | os: [ubuntu-latest, macOS-latest, windows-latest] 47 | steps: 48 | - uses: actions/checkout@v6 49 | - uses: dtolnay/rust-toolchain@stable 50 | - run: rustup component add clippy 51 | - run: cargo clippy --all-targets -- -D warnings 52 | 53 | min_version: 54 | name: Minimum Supported Rust Version 55 | runs-on: ${{ matrix.os }} 56 | strategy: 57 | matrix: 58 | os: [ubuntu-latest, macOS-latest, windows-latest] 59 | steps: 60 | - uses: actions/checkout@v6 61 | - uses: dtolnay/rust-toolchain@stable 62 | with: 63 | toolchain: ${{ env.RUST_MIN_SRV }} 64 | - run: cargo test 65 | 66 | coverage: 67 | name: Code Coverage 68 | runs-on: ${{ matrix.job.os }} 69 | strategy: 70 | fail-fast: true 71 | matrix: 72 | job: 73 | - { os: ubuntu-latest , features: unix } 74 | - { os: macos-latest , features: macos } 75 | - { os: windows-latest , features: windows } 76 | steps: 77 | - uses: actions/checkout@v6 78 | - name: Initialize workflow variables 79 | id: vars 80 | shell: bash 81 | run: | 82 | ## VARs setup 83 | outputs() { step_id="vars"; for var in "$@" ; do echo steps.${step_id}.outputs.${var}="${!var}"; echo "${var}=${!var}" >> $GITHUB_OUTPUT; done; } 84 | # toolchain 85 | TOOLCHAIN="nightly" ## default to "nightly" toolchain (required for certain required unstable compiler flags) ## !maint: refactor when stable channel has needed support 86 | # * specify gnu-type TOOLCHAIN for windows; `grcov` requires gnu-style code coverage data files 87 | case ${{ matrix.job.os }} in windows-*) TOOLCHAIN="$TOOLCHAIN-x86_64-pc-windows-gnu" ;; esac; 88 | # * use requested TOOLCHAIN if specified 89 | if [ -n "${{ matrix.job.toolchain }}" ]; then TOOLCHAIN="${{ matrix.job.toolchain }}" ; fi 90 | outputs TOOLCHAIN 91 | # target-specific options 92 | # * CODECOV_FLAGS 93 | CODECOV_FLAGS=$( echo "${{ matrix.job.os }}" | sed 's/[^[:alnum:]]/_/g' ) 94 | outputs CODECOV_FLAGS 95 | 96 | - name: rust toolchain ~ install 97 | uses: dtolnay/rust-toolchain@nightly 98 | with: 99 | components: llvm-tools-preview 100 | - name: Test 101 | run: cargo test --no-fail-fast 102 | env: 103 | CARGO_INCREMENTAL: "0" 104 | RUSTC_WRAPPER: "" 105 | RUSTFLAGS: "-Cinstrument-coverage -Zcoverage-options=branch -Ccodegen-units=1 -Copt-level=0 -Coverflow-checks=off -Zpanic_abort_tests -Cpanic=abort" 106 | RUSTDOCFLAGS: "-Cpanic=abort" 107 | LLVM_PROFILE_FILE: "parse_datetime-%p-%m.profraw" 108 | - name: "`grcov` ~ install" 109 | id: build_grcov 110 | shell: bash 111 | run: | 112 | git clone https://github.com/mozilla/grcov.git ~/grcov/ 113 | cd ~/grcov 114 | # Hardcode the version of crossbeam-epoch. See 115 | # https://github.com/uutils/coreutils/issues/3680 116 | sed -i -e "s|tempfile =|crossbeam-epoch = \"=0.9.8\"\ntempfile =|" Cargo.toml 117 | cargo install --path . 118 | cd - 119 | # Uncomment when the upstream issue 120 | # https://github.com/mozilla/grcov/issues/849 is fixed 121 | # uses: actions-rs/install@v0.1 122 | # with: 123 | # crate: grcov 124 | # version: latest 125 | # use-tool-cache: false 126 | - name: Generate coverage data (via `grcov`) 127 | id: coverage 128 | shell: bash 129 | run: | 130 | ## Generate coverage data 131 | COVERAGE_REPORT_DIR="target/debug" 132 | COVERAGE_REPORT_FILE="${COVERAGE_REPORT_DIR}/lcov.info" 133 | mkdir -p "${COVERAGE_REPORT_DIR}" 134 | # display coverage files 135 | grcov . --binary-path="${COVERAGE_REPORT_DIR}" --output-type files --ignore build.rs --ignore "vendor/*" --ignore "/*" --ignore "[a-zA-Z]:/*" --excl-br-line "^\s*((debug_)?assert(_eq|_ne)?!|#\[derive\()" | sort --unique 136 | # generate coverage report 137 | grcov . --binary-path="${COVERAGE_REPORT_DIR}" --output-type lcov --output-path "${COVERAGE_REPORT_FILE}" --branch --ignore build.rs --ignore "vendor/*" --ignore "/*" --ignore "[a-zA-Z]:/*" --excl-br-line "^\s*((debug_)?assert(_eq|_ne)?!|#\[derive\()" 138 | echo "report=${COVERAGE_REPORT_FILE}" >> $GITHUB_OUTPUT 139 | - name: Upload coverage results (to Codecov.io) 140 | uses: codecov/codecov-action@v5 141 | with: 142 | token: ${{ secrets.CODECOV_TOKEN }} 143 | files: ${{ steps.coverage.outputs.report }} 144 | ## flags: IntegrationTests, UnitTests, ${{ steps.vars.outputs.CODECOV_FLAGS }} 145 | flags: ${{ steps.vars.outputs.CODECOV_FLAGS }} 146 | name: codecov-umbrella 147 | fail_ci_if_error: false 148 | 149 | fuzz: 150 | name: Run the fuzzers 151 | runs-on: ubuntu-latest 152 | env: 153 | RUN_FOR: 60 154 | steps: 155 | - uses: actions/checkout@v6 156 | - uses: dtolnay/rust-toolchain@nightly 157 | - name: Install `cargo-fuzz` 158 | run: cargo install cargo-fuzz 159 | - uses: Swatinem/rust-cache@v2 160 | - name: Run from_str for XX seconds 161 | shell: bash 162 | run: | 163 | ## Run it 164 | cd fuzz 165 | cargo +nightly fuzz run fuzz_parse_datetime -- -max_total_time=${{ env.RUN_FOR }} -detect_leaks=0 166 | -------------------------------------------------------------------------------- /tests/time.rs: -------------------------------------------------------------------------------- 1 | // For the full copyright and license information, please view the LICENSE 2 | // file that was distributed with this source code. 3 | 4 | use jiff::{civil::DateTime, tz::TimeZone, Zoned}; 5 | use parse_datetime::parse_datetime_at_date; 6 | use rstest::rstest; 7 | 8 | // The expected values are produced by GNU date version 8.32 9 | // export LC_TIME=en_US.UTF-8 10 | // export TZ=UTC 11 | // date date --date="12:34:56+09:00" +"%H:%M:%S.%N" 12 | // 13 | // Documentation for the date format can be found at: 14 | // https://www.gnu.org/software/coreutils/manual/html_node/Time-of-day-items.html 15 | 16 | pub fn check_time(input: &str, expected: &str, format: &str, base: Option) { 17 | std::env::set_var("TZ", "UTC0"); 18 | let now = base.unwrap_or(Zoned::now()); 19 | let parsed = match parse_datetime_at_date(now, input) { 20 | Ok(v) => v, 21 | Err(e) => panic!("Failed to parse time from value '{input}': {e}"), 22 | } 23 | .with_time_zone(TimeZone::UTC); 24 | 25 | assert_eq!( 26 | format!("{}", parsed.strftime(format)), 27 | expected, 28 | "Input value: {input}" 29 | ); 30 | } 31 | 32 | #[rstest] 33 | #[case::full_time("12:34:56", "12:34:56.000000000")] 34 | #[case::full_time_with_spaces("12 : 34 : 56", "12:34:56.000000000")] 35 | #[case::full_time_midnight("00:00:00", "00:00:00.000000000")] 36 | #[case::full_time_almost_midnight("23:59:59", "23:59:59.000000000")] 37 | #[case::full_time_decimal_seconds("12:34:56.666", "12:34:56.666000000")] 38 | #[case::full_time_decimal_seconds("12:34:56.999999999", "12:34:56.999999999")] 39 | #[case::full_time_decimal_seconds("12:34:56.9999999999", "12:34:56.999999999")] 40 | #[case::full_time_decimal_seconds_after_comma("12:34:56,666", "12:34:56.666000000")] 41 | #[case::without_seconds("12:34", "12:34:00.000000000")] 42 | fn test_time_24h_format(#[case] input: &str, #[case] expected: &str) { 43 | check_time(input, expected, "%H:%M:%S%.9f", None); 44 | } 45 | 46 | #[rstest] 47 | #[case::full_time_am("12:34:56am", "00:34:56.000000000")] 48 | #[case::full_time_pm("12:34:56pm", "12:34:56.000000000")] 49 | #[case::full_time_am_with_dots("12:34:56a.m.", "00:34:56.000000000")] 50 | #[case::full_time_pm_with_dots("12:34:56p.m.", "12:34:56.000000000")] 51 | #[case::full_time_with_spaces("12 : 34 : 56 am", "00:34:56.000000000")] 52 | #[case::full_time_capital("12:34:56pm", "12:34:56.000000000")] 53 | #[case::full_time_midnight("00:00:00", "00:00:00.000000000")] 54 | #[case::full_time_almost_midnight("23:59:59", "23:59:59.000000000")] 55 | #[case::full_time_decimal_seconds("12:34:56.666pm", "12:34:56.666000000")] 56 | #[case::full_time_decimal_seconds_after_comma("12:34:56,666pm", "12:34:56.666000000")] 57 | #[case::without_seconds("12:34pm", "12:34:00.000000000")] 58 | fn test_time_12h_format(#[case] input: &str, #[case] expected: &str) { 59 | check_time(input, expected, "%H:%M:%S%.9f", None); 60 | } 61 | 62 | #[rstest] 63 | #[case::utc("12:34:56+00:00", "12:34:56.000000000")] 64 | #[case::utc_with_minus("12:34:56-00:00", "12:34:56.000000000")] 65 | #[case::corrected_plus("12:34:56+09:00", "03:34:56.000000000")] 66 | #[case::corrected_minus("12:34:56-09:00", "21:34:56.000000000")] 67 | #[case::corrected_no_colon("12:34:56+0900", "03:34:56.000000000")] 68 | #[case::corrected_plus_hours_only("12:34:56+09", "03:34:56.000000000")] 69 | #[case::corrected_minus_hours_only("12:34:56-09", "21:34:56.000000000")] 70 | #[case::corrected_plus_minutes("12:34:56+09:12", "03:22:56.000000000")] 71 | #[case::corrected_minus_minutes("12:34:56-09:26", "22:00:56.000000000")] 72 | #[case::corrected_plus_single_digit("12:34:56+9", "03:34:56.000000000")] 73 | #[case::corrected_minus_single_digit("12:34:56-9", "21:34:56.000000000")] 74 | #[case::with_space("12:34:56 -09:00", "21:34:56.000000000")] 75 | #[case::with_space("12:34:56 - 09:00", "21:34:56.000000000")] 76 | #[case::with_space_only_hours("12:34:56 -09", "21:34:56.000000000")] 77 | #[case::with_space_one_digit("12:34:56 -9", "21:34:56.000000000")] 78 | #[case::gnu_compatibility("12:34:56+", "12:34:56.000000000")] 79 | #[case::gnu_compatibility("12:34:56+-", "12:34:56.000000000")] 80 | #[case::gnu_compatibility("12:34:56+-01", "13:34:56.000000000")] 81 | #[case::gnu_compatibility("12:34:56+-+++---++", "12:34:56.000000000")] 82 | #[case::gnu_compatibility("12:34:56+1-", "11:34:56.000000000")] 83 | #[case::gnu_compatibility("12:34:56+--+1-+-", "11:34:56.000000000")] 84 | fn test_time_correction(#[case] input: &str, #[case] expected: &str) { 85 | check_time(input, expected, "%H:%M:%S%.9f", None); 86 | } 87 | 88 | #[rstest] 89 | #[case::plus_12("11:34:56+12:00", "2022-06-09 23:34:56")] 90 | #[case::minus_12("12:34:56-12:00", "2022-06-11 00:34:56")] 91 | #[case::plus_1259("12:34:56+12:59", "2022-06-09 23:35:56")] 92 | #[case::minus_1259("12:34:56-12:59", "2022-06-11 01:33:56")] 93 | #[case::plus_24("12:34:56+24:00", "2022-06-09 12:34:56")] 94 | #[case::minus_24("12:34:56-24:00", "2022-06-11 12:34:56")] 95 | #[case::plus_13("11:34:56+13:00", "2022-06-09 22:34:56")] 96 | #[case::minus_13("12:34:56-13:00", "2022-06-11 01:34:56")] 97 | #[case::plus_36("12:34:56 m+24", "2022-06-09 00:34:56")] 98 | #[case::minus_36("12:34:56 y-24:00", "2022-06-12 00:34:56")] 99 | fn test_time_correction_with_overflow(#[case] input: &str, #[case] expected: &str) { 100 | let now = "2022-06-10 00:00:00" 101 | .parse::() 102 | .unwrap() 103 | .to_zoned(TimeZone::UTC) 104 | .unwrap(); 105 | check_time(input, expected, "%Y-%m-%d %H:%M:%S", Some(now)); 106 | } 107 | 108 | #[rstest] 109 | #[case("24:00:00")] 110 | #[case("23:60:00")] 111 | #[case("23:59:60")] 112 | #[case("13:00:00am")] 113 | #[case("13:00:00pm")] 114 | #[case("00:00:00am")] 115 | #[case("00:00:00pm")] 116 | #[case("23:59:59 a.m")] 117 | #[case("23:59:59 pm.")] 118 | #[case("23:59:59+24:01")] 119 | #[case("23:59:59-24:01")] 120 | #[case("10:59am+01")] 121 | #[case("10:59+01pm")] 122 | #[case("23:59:59+00:00:00")] 123 | fn test_time_invalid(#[case] input: &str) { 124 | let result = parse_datetime::parse_datetime(input); 125 | assert_eq!( 126 | result, 127 | Err(parse_datetime::ParseDateTimeError::InvalidInput), 128 | "Input string '{input}' did not produce an error when parsing" 129 | ); 130 | } 131 | 132 | #[rstest] 133 | #[case::decimal_1_whole("1.123456789 seconds ago")] 134 | #[case::decimal_2_whole("12.123456789 seconds ago")] 135 | #[case::decimal_3_whole("123.123456789 seconds ago")] 136 | #[case::decimal_4_whole("1234.123456789 seconds ago")] 137 | #[case::decimal_5_whole("12345.123456789 seconds ago")] 138 | #[case::decimal_6_whole("123456.123456789 seconds ago")] 139 | #[case::decimal_7_whole("1234567.123456789 seconds ago")] 140 | #[case::decimal_8_whole("12345678.123456789 seconds ago")] 141 | #[case::decimal_9_whole("123456789.123456789 seconds ago")] 142 | #[case::decimal_10_whole("1234567891.123456789 seconds ago")] 143 | #[case::decimal_11_whole("12345678912.123456789 seconds ago")] 144 | #[case::decimal_12_whole("123456789123.123456789 seconds ago")] 145 | fn test_time_seconds_ago(#[case] input: &str) { 146 | let result = parse_datetime::parse_datetime(input); 147 | assert!( 148 | result.is_ok(), 149 | "Input string '{input}', produced {result:?}, instead of Ok(Zoned)" 150 | ); 151 | } 152 | 153 | #[rstest] 154 | #[case::decimal_13_whole("1234567891234.123456789 seconds ago")] 155 | #[case::decimal_14_whole("12345678912345.123456789 seconds ago")] 156 | #[case::decimal_15_whole("123456789123456.123456789 seconds ago")] 157 | fn test_time_seconds_ago_invalid(#[case] input: &str) { 158 | let result = parse_datetime::parse_datetime(input); 159 | assert_eq!( 160 | result, 161 | Err(parse_datetime::ParseDateTimeError::InvalidInput), 162 | "Input string '{input}' did not produce an error when parsing" 163 | ); 164 | } 165 | -------------------------------------------------------------------------------- /fuzz/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "arbitrary" 7 | version = "1.4.2" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" 10 | 11 | [[package]] 12 | name = "autocfg" 13 | version = "1.5.0" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 16 | 17 | [[package]] 18 | name = "cc" 19 | version = "1.2.47" 20 | source = "registry+https://github.com/rust-lang/crates.io-index" 21 | checksum = "cd405d82c84ff7f35739f175f67d8b9fb7687a0e84ccdc78bd3568839827cf07" 22 | dependencies = [ 23 | "find-msvc-tools", 24 | "jobserver", 25 | "libc", 26 | "shlex", 27 | ] 28 | 29 | [[package]] 30 | name = "cfg-if" 31 | version = "1.0.4" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 34 | 35 | [[package]] 36 | name = "find-msvc-tools" 37 | version = "0.1.5" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" 40 | 41 | [[package]] 42 | name = "fuzz_parse_datetime" 43 | version = "0.2.0" 44 | dependencies = [ 45 | "libfuzzer-sys", 46 | "parse_datetime", 47 | ] 48 | 49 | [[package]] 50 | name = "getrandom" 51 | version = "0.3.4" 52 | source = "registry+https://github.com/rust-lang/crates.io-index" 53 | checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" 54 | dependencies = [ 55 | "cfg-if", 56 | "libc", 57 | "r-efi", 58 | "wasip2", 59 | ] 60 | 61 | [[package]] 62 | name = "jiff" 63 | version = "0.2.16" 64 | source = "registry+https://github.com/rust-lang/crates.io-index" 65 | checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35" 66 | dependencies = [ 67 | "jiff-static", 68 | "jiff-tzdb-platform", 69 | "log", 70 | "portable-atomic", 71 | "portable-atomic-util", 72 | "serde_core", 73 | "windows-sys", 74 | ] 75 | 76 | [[package]] 77 | name = "jiff-static" 78 | version = "0.2.16" 79 | source = "registry+https://github.com/rust-lang/crates.io-index" 80 | checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69" 81 | dependencies = [ 82 | "proc-macro2", 83 | "quote", 84 | "syn", 85 | ] 86 | 87 | [[package]] 88 | name = "jiff-tzdb" 89 | version = "0.1.4" 90 | source = "registry+https://github.com/rust-lang/crates.io-index" 91 | checksum = "c1283705eb0a21404d2bfd6eef2a7593d240bc42a0bdb39db0ad6fa2ec026524" 92 | 93 | [[package]] 94 | name = "jiff-tzdb-platform" 95 | version = "0.1.3" 96 | source = "registry+https://github.com/rust-lang/crates.io-index" 97 | checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" 98 | dependencies = [ 99 | "jiff-tzdb", 100 | ] 101 | 102 | [[package]] 103 | name = "jobserver" 104 | version = "0.1.34" 105 | source = "registry+https://github.com/rust-lang/crates.io-index" 106 | checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" 107 | dependencies = [ 108 | "getrandom", 109 | "libc", 110 | ] 111 | 112 | [[package]] 113 | name = "libc" 114 | version = "0.2.177" 115 | source = "registry+https://github.com/rust-lang/crates.io-index" 116 | checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" 117 | 118 | [[package]] 119 | name = "libfuzzer-sys" 120 | version = "0.4.10" 121 | source = "registry+https://github.com/rust-lang/crates.io-index" 122 | checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" 123 | dependencies = [ 124 | "arbitrary", 125 | "cc", 126 | ] 127 | 128 | [[package]] 129 | name = "log" 130 | version = "0.4.28" 131 | source = "registry+https://github.com/rust-lang/crates.io-index" 132 | checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" 133 | 134 | [[package]] 135 | name = "memchr" 136 | version = "2.7.6" 137 | source = "registry+https://github.com/rust-lang/crates.io-index" 138 | checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" 139 | 140 | [[package]] 141 | name = "num-traits" 142 | version = "0.2.19" 143 | source = "registry+https://github.com/rust-lang/crates.io-index" 144 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 145 | dependencies = [ 146 | "autocfg", 147 | ] 148 | 149 | [[package]] 150 | name = "parse_datetime" 151 | version = "0.13.2" 152 | dependencies = [ 153 | "jiff", 154 | "num-traits", 155 | "winnow", 156 | ] 157 | 158 | [[package]] 159 | name = "portable-atomic" 160 | version = "1.11.1" 161 | source = "registry+https://github.com/rust-lang/crates.io-index" 162 | checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" 163 | 164 | [[package]] 165 | name = "portable-atomic-util" 166 | version = "0.2.4" 167 | source = "registry+https://github.com/rust-lang/crates.io-index" 168 | checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" 169 | dependencies = [ 170 | "portable-atomic", 171 | ] 172 | 173 | [[package]] 174 | name = "proc-macro2" 175 | version = "1.0.103" 176 | source = "registry+https://github.com/rust-lang/crates.io-index" 177 | checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" 178 | dependencies = [ 179 | "unicode-ident", 180 | ] 181 | 182 | [[package]] 183 | name = "quote" 184 | version = "1.0.42" 185 | source = "registry+https://github.com/rust-lang/crates.io-index" 186 | checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" 187 | dependencies = [ 188 | "proc-macro2", 189 | ] 190 | 191 | [[package]] 192 | name = "r-efi" 193 | version = "5.3.0" 194 | source = "registry+https://github.com/rust-lang/crates.io-index" 195 | checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 196 | 197 | [[package]] 198 | name = "serde_core" 199 | version = "1.0.228" 200 | source = "registry+https://github.com/rust-lang/crates.io-index" 201 | checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" 202 | dependencies = [ 203 | "serde_derive", 204 | ] 205 | 206 | [[package]] 207 | name = "serde_derive" 208 | version = "1.0.228" 209 | source = "registry+https://github.com/rust-lang/crates.io-index" 210 | checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 211 | dependencies = [ 212 | "proc-macro2", 213 | "quote", 214 | "syn", 215 | ] 216 | 217 | [[package]] 218 | name = "shlex" 219 | version = "1.3.0" 220 | source = "registry+https://github.com/rust-lang/crates.io-index" 221 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 222 | 223 | [[package]] 224 | name = "syn" 225 | version = "2.0.110" 226 | source = "registry+https://github.com/rust-lang/crates.io-index" 227 | checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" 228 | dependencies = [ 229 | "proc-macro2", 230 | "quote", 231 | "unicode-ident", 232 | ] 233 | 234 | [[package]] 235 | name = "unicode-ident" 236 | version = "1.0.22" 237 | source = "registry+https://github.com/rust-lang/crates.io-index" 238 | checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" 239 | 240 | [[package]] 241 | name = "wasip2" 242 | version = "1.0.1+wasi-0.2.4" 243 | source = "registry+https://github.com/rust-lang/crates.io-index" 244 | checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" 245 | dependencies = [ 246 | "wit-bindgen", 247 | ] 248 | 249 | [[package]] 250 | name = "windows-link" 251 | version = "0.2.1" 252 | source = "registry+https://github.com/rust-lang/crates.io-index" 253 | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 254 | 255 | [[package]] 256 | name = "windows-sys" 257 | version = "0.61.2" 258 | source = "registry+https://github.com/rust-lang/crates.io-index" 259 | checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 260 | dependencies = [ 261 | "windows-link", 262 | ] 263 | 264 | [[package]] 265 | name = "winnow" 266 | version = "0.7.13" 267 | source = "registry+https://github.com/rust-lang/crates.io-index" 268 | checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" 269 | dependencies = [ 270 | "memchr", 271 | ] 272 | 273 | [[package]] 274 | name = "wit-bindgen" 275 | version = "0.46.0" 276 | source = "registry+https://github.com/rust-lang/crates.io-index" 277 | checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" 278 | -------------------------------------------------------------------------------- /src/items/relative.rs: -------------------------------------------------------------------------------- 1 | // For the full copyright and license information, please view the LICENSE 2 | // file that was distributed with this source code. 3 | 4 | //! Parse a relative datetime item 5 | //! 6 | //! The GNU docs state: 7 | //! 8 | //! > The unit of time displacement may be selected by the string ‘year’ or 9 | //! > ‘month’ for moving by whole years or months. These are fuzzy units, as 10 | //! > years and months are not all of equal duration. More precise units are 11 | //! > ‘fortnight’ which is worth 14 days, ‘week’ worth 7 days, ‘day’ worth 24 12 | //! > hours, ‘hour’ worth 60 minutes, ‘minute’ or ‘min’ worth 60 seconds, and 13 | //! > ‘second’ or ‘sec’ worth one second. An ‘s’ suffix on these units is 14 | //! > accepted and ignored. 15 | //! > 16 | //! > The unit of time may be preceded by a multiplier, given as an optionally 17 | //! > signed number. Unsigned numbers are taken as positively signed. No number 18 | //! > at all implies 1 for a multiplier. Following a relative item by the 19 | //! > string ‘ago’ is equivalent to preceding the unit by a multiplier with 20 | //! > value -1. 21 | //! > 22 | //! > The string ‘tomorrow’ is worth one day in the future (equivalent to 23 | //! > ‘day’), the string ‘yesterday’ is worth one day in the past (equivalent 24 | //! > to ‘day ago’). 25 | //! > 26 | //! > The strings ‘now’ or ‘today’ are relative items corresponding to 27 | //! > zero-valued time displacement, these strings come from the fact a 28 | //! > zero-valued time displacement represents the current time when not 29 | //! > otherwise changed by previous items. They may be used to stress other 30 | //! > items, like in ‘12:00 today’. The string ‘this’ also has the meaning of a 31 | //! > zero-valued time displacement, but is preferred in date strings like 32 | //! > ‘this thursday’. 33 | 34 | use winnow::{ 35 | ascii::alpha1, 36 | combinator::{alt, opt}, 37 | ModalResult, Parser, 38 | }; 39 | 40 | use super::{epoch::sec_and_nsec, ordinal::ordinal, primitive::s}; 41 | 42 | #[derive(Clone, Copy, Debug, PartialEq)] 43 | pub(crate) enum Relative { 44 | Years(i32), 45 | Months(i32), 46 | Days(i32), 47 | Hours(i32), 48 | Minutes(i32), 49 | Seconds(i64, u32), 50 | } 51 | 52 | impl TryFrom for jiff::Span { 53 | type Error = &'static str; 54 | 55 | fn try_from(relative: Relative) -> Result { 56 | match relative { 57 | Relative::Years(years) => jiff::Span::new().try_years(years), 58 | Relative::Months(months) => jiff::Span::new().try_months(months), 59 | Relative::Days(days) => jiff::Span::new().try_days(days), 60 | Relative::Hours(hours) => jiff::Span::new().try_hours(hours), 61 | Relative::Minutes(minutes) => jiff::Span::new().try_minutes(minutes), 62 | Relative::Seconds(seconds, nanoseconds) => jiff::Span::new() 63 | .try_seconds(seconds) 64 | .and_then(|span| span.try_nanoseconds(nanoseconds)), 65 | } 66 | .map_err(|_| "relative value is invalid") 67 | } 68 | } 69 | 70 | pub(super) fn parse(input: &mut &str) -> ModalResult { 71 | alt(( 72 | s("tomorrow").value(Relative::Days(1)), 73 | s("yesterday").value(Relative::Days(-1)), 74 | // For "today" and "now", the unit is arbitrary 75 | s("today").value(Relative::Days(0)), 76 | s("now").value(Relative::Days(0)), 77 | seconds, 78 | displacement, 79 | )) 80 | .parse_next(input) 81 | } 82 | 83 | fn seconds(input: &mut &str) -> ModalResult { 84 | ( 85 | opt(alt((s('+').value(1), s('-').value(-1)))), 86 | s(sec_and_nsec), 87 | s(alpha1).verify(|s: &str| matches!(s, "seconds" | "second" | "sec" | "secs")), 88 | ago, 89 | ) 90 | .verify_map(|(sign, (sec, nsec), _, ago)| { 91 | let sec = i64::try_from(sec).ok()?; 92 | let sign = sign.unwrap_or(1) * if ago { -1 } else { 1 }; 93 | let (second, nanosecond) = match (sign, nsec) { 94 | (-1, 0) => (-sec, 0), 95 | // Truncate towards minus infinity. 96 | (-1, _) => ((-sec).checked_sub(1)?, 1_000_000_000 - nsec), 97 | _ => (sec, nsec), 98 | }; 99 | Some(Relative::Seconds(second, nanosecond)) 100 | }) 101 | .parse_next(input) 102 | } 103 | 104 | fn displacement(input: &mut &str) -> ModalResult { 105 | (opt(ordinal), s(alpha1), ago) 106 | .verify_map(|(n, unit, ago): (Option, &str, bool)| { 107 | let multiplier = n.unwrap_or(1) * if ago { -1 } else { 1 }; 108 | Some(match unit.strip_suffix('s').unwrap_or(unit) { 109 | "year" => Relative::Years(multiplier), 110 | "month" => Relative::Months(multiplier), 111 | "fortnight" => Relative::Days(multiplier.checked_mul(14)?), 112 | "week" => Relative::Days(multiplier.checked_mul(7)?), 113 | "day" => Relative::Days(multiplier), 114 | "hour" => Relative::Hours(multiplier), 115 | "minute" | "min" => Relative::Minutes(multiplier), 116 | "second" | "sec" => Relative::Seconds(multiplier as i64, 0), 117 | _ => return None, 118 | }) 119 | }) 120 | .parse_next(input) 121 | } 122 | 123 | fn ago(input: &mut &str) -> ModalResult { 124 | opt(s("ago")).map(|o| o.is_some()).parse_next(input) 125 | } 126 | 127 | #[cfg(test)] 128 | mod tests { 129 | use super::{parse, Relative}; 130 | 131 | #[test] 132 | fn all() { 133 | for (s, rel) in [ 134 | // Seconds 135 | ("second", Relative::Seconds(1, 0)), 136 | ("sec", Relative::Seconds(1, 0)), 137 | ("seconds", Relative::Seconds(1, 0)), 138 | ("secs", Relative::Seconds(1, 0)), 139 | ("second ago", Relative::Seconds(-1, 0)), 140 | ("3 seconds", Relative::Seconds(3, 0)), 141 | ("+ 3 seconds", Relative::Seconds(3, 0)), 142 | ("3.5 seconds", Relative::Seconds(3, 500_000_000)), 143 | ("-3.5 seconds", Relative::Seconds(-4, 500_000_000)), 144 | ("+3.5 seconds", Relative::Seconds(3, 500_000_000)), 145 | ("+ 3.5 seconds", Relative::Seconds(3, 500_000_000)), 146 | ("3.5 seconds ago", Relative::Seconds(-4, 500_000_000)), 147 | ("- 3.5 seconds ago", Relative::Seconds(3, 500_000_000)), 148 | // Minutes 149 | ("minute", Relative::Minutes(1)), 150 | ("minutes", Relative::Minutes(1)), 151 | ("min", Relative::Minutes(1)), 152 | ("mins", Relative::Minutes(1)), 153 | ("10 minutes", Relative::Minutes(10)), 154 | ("-10 minutes", Relative::Minutes(-10)), 155 | ("- 10 minutes", Relative::Minutes(-10)), 156 | ("10 minutes ago", Relative::Minutes(-10)), 157 | ("-10 minutes ago", Relative::Minutes(10)), 158 | ("- 10 minutes ago", Relative::Minutes(10)), 159 | ("-10 minutes ago", Relative::Minutes(10)), 160 | ("- 10 minutes ago", Relative::Minutes(10)), 161 | // Hours 162 | ("hour", Relative::Hours(1)), 163 | ("hours", Relative::Hours(1)), 164 | ("10 hours", Relative::Hours(10)), 165 | ("+10 hours", Relative::Hours(10)), 166 | ("+ 10 hours", Relative::Hours(10)), 167 | ("-10 hours", Relative::Hours(-10)), 168 | ("- 10 hours", Relative::Hours(-10)), 169 | ("10 hours ago", Relative::Hours(-10)), 170 | ("-10 hours ago", Relative::Hours(10)), 171 | ("- 10 hours ago", Relative::Hours(10)), 172 | // Days 173 | ("day", Relative::Days(1)), 174 | ("days", Relative::Days(1)), 175 | ("10 days", Relative::Days(10)), 176 | ("+10 days", Relative::Days(10)), 177 | ("+ 10 days", Relative::Days(10)), 178 | ("-10 days", Relative::Days(-10)), 179 | ("- 10 days", Relative::Days(-10)), 180 | ("10 days ago", Relative::Days(-10)), 181 | ("-10 days ago", Relative::Days(10)), 182 | ("- 10 days ago", Relative::Days(10)), 183 | // Multiple days 184 | ("fortnight", Relative::Days(14)), 185 | ("fortnights", Relative::Days(14)), 186 | ("2 fortnights ago", Relative::Days(-28)), 187 | ("+2 fortnights ago", Relative::Days(-28)), 188 | ("+ 2 fortnights ago", Relative::Days(-28)), 189 | ("week", Relative::Days(7)), 190 | ("weeks", Relative::Days(7)), 191 | ("2 weeks ago", Relative::Days(-14)), 192 | // Other 193 | ("year", Relative::Years(1)), 194 | ("years", Relative::Years(1)), 195 | ("month", Relative::Months(1)), 196 | ("months", Relative::Months(1)), 197 | // Special 198 | ("yesterday", Relative::Days(-1)), 199 | ("tomorrow", Relative::Days(1)), 200 | ("today", Relative::Days(0)), 201 | ("now", Relative::Days(0)), 202 | // This something 203 | ("this day", Relative::Days(0)), 204 | ("this second", Relative::Seconds(0, 0)), 205 | ("this year", Relative::Years(0)), 206 | // Weird stuff 207 | ("next week ago", Relative::Days(-7)), 208 | ("last week ago", Relative::Days(7)), 209 | ("this week ago", Relative::Days(0)), 210 | ] { 211 | let mut t = s; 212 | assert_eq!(parse(&mut t).ok(), Some(rel), "Failed string: {s}") 213 | } 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "1.1.4" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "autocfg" 16 | version = "1.5.0" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 19 | 20 | [[package]] 21 | name = "cfg-if" 22 | version = "1.0.4" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 25 | 26 | [[package]] 27 | name = "equivalent" 28 | version = "1.0.2" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 31 | 32 | [[package]] 33 | name = "futures-core" 34 | version = "0.3.31" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" 37 | 38 | [[package]] 39 | name = "futures-macro" 40 | version = "0.3.31" 41 | source = "registry+https://github.com/rust-lang/crates.io-index" 42 | checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" 43 | dependencies = [ 44 | "proc-macro2", 45 | "quote", 46 | "syn", 47 | ] 48 | 49 | [[package]] 50 | name = "futures-task" 51 | version = "0.3.31" 52 | source = "registry+https://github.com/rust-lang/crates.io-index" 53 | checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" 54 | 55 | [[package]] 56 | name = "futures-timer" 57 | version = "3.0.3" 58 | source = "registry+https://github.com/rust-lang/crates.io-index" 59 | checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" 60 | 61 | [[package]] 62 | name = "futures-util" 63 | version = "0.3.31" 64 | source = "registry+https://github.com/rust-lang/crates.io-index" 65 | checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" 66 | dependencies = [ 67 | "futures-core", 68 | "futures-macro", 69 | "futures-task", 70 | "pin-project-lite", 71 | "pin-utils", 72 | "slab", 73 | ] 74 | 75 | [[package]] 76 | name = "glob" 77 | version = "0.3.3" 78 | source = "registry+https://github.com/rust-lang/crates.io-index" 79 | checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" 80 | 81 | [[package]] 82 | name = "hashbrown" 83 | version = "0.16.1" 84 | source = "registry+https://github.com/rust-lang/crates.io-index" 85 | checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" 86 | 87 | [[package]] 88 | name = "indexmap" 89 | version = "2.11.4" 90 | source = "registry+https://github.com/rust-lang/crates.io-index" 91 | checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" 92 | dependencies = [ 93 | "equivalent", 94 | "hashbrown", 95 | ] 96 | 97 | [[package]] 98 | name = "jiff" 99 | version = "0.2.16" 100 | source = "registry+https://github.com/rust-lang/crates.io-index" 101 | checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35" 102 | dependencies = [ 103 | "jiff-static", 104 | "jiff-tzdb-platform", 105 | "log", 106 | "portable-atomic", 107 | "portable-atomic-util", 108 | "serde_core", 109 | "windows-sys", 110 | ] 111 | 112 | [[package]] 113 | name = "jiff-static" 114 | version = "0.2.16" 115 | source = "registry+https://github.com/rust-lang/crates.io-index" 116 | checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69" 117 | dependencies = [ 118 | "proc-macro2", 119 | "quote", 120 | "syn", 121 | ] 122 | 123 | [[package]] 124 | name = "jiff-tzdb" 125 | version = "0.1.4" 126 | source = "registry+https://github.com/rust-lang/crates.io-index" 127 | checksum = "c1283705eb0a21404d2bfd6eef2a7593d240bc42a0bdb39db0ad6fa2ec026524" 128 | 129 | [[package]] 130 | name = "jiff-tzdb-platform" 131 | version = "0.1.3" 132 | source = "registry+https://github.com/rust-lang/crates.io-index" 133 | checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" 134 | dependencies = [ 135 | "jiff-tzdb", 136 | ] 137 | 138 | [[package]] 139 | name = "log" 140 | version = "0.4.28" 141 | source = "registry+https://github.com/rust-lang/crates.io-index" 142 | checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" 143 | 144 | [[package]] 145 | name = "memchr" 146 | version = "2.7.6" 147 | source = "registry+https://github.com/rust-lang/crates.io-index" 148 | checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" 149 | 150 | [[package]] 151 | name = "num-traits" 152 | version = "0.2.19" 153 | source = "registry+https://github.com/rust-lang/crates.io-index" 154 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 155 | dependencies = [ 156 | "autocfg", 157 | ] 158 | 159 | [[package]] 160 | name = "parse_datetime" 161 | version = "0.13.3" 162 | dependencies = [ 163 | "jiff", 164 | "num-traits", 165 | "rstest", 166 | "winnow", 167 | ] 168 | 169 | [[package]] 170 | name = "pin-project-lite" 171 | version = "0.2.16" 172 | source = "registry+https://github.com/rust-lang/crates.io-index" 173 | checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" 174 | 175 | [[package]] 176 | name = "pin-utils" 177 | version = "0.1.0" 178 | source = "registry+https://github.com/rust-lang/crates.io-index" 179 | checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 180 | 181 | [[package]] 182 | name = "portable-atomic" 183 | version = "1.11.1" 184 | source = "registry+https://github.com/rust-lang/crates.io-index" 185 | checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" 186 | 187 | [[package]] 188 | name = "portable-atomic-util" 189 | version = "0.2.4" 190 | source = "registry+https://github.com/rust-lang/crates.io-index" 191 | checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" 192 | dependencies = [ 193 | "portable-atomic", 194 | ] 195 | 196 | [[package]] 197 | name = "proc-macro-crate" 198 | version = "3.4.0" 199 | source = "registry+https://github.com/rust-lang/crates.io-index" 200 | checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" 201 | dependencies = [ 202 | "toml_edit", 203 | ] 204 | 205 | [[package]] 206 | name = "proc-macro2" 207 | version = "1.0.103" 208 | source = "registry+https://github.com/rust-lang/crates.io-index" 209 | checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" 210 | dependencies = [ 211 | "unicode-ident", 212 | ] 213 | 214 | [[package]] 215 | name = "quote" 216 | version = "1.0.42" 217 | source = "registry+https://github.com/rust-lang/crates.io-index" 218 | checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" 219 | dependencies = [ 220 | "proc-macro2", 221 | ] 222 | 223 | [[package]] 224 | name = "regex" 225 | version = "1.12.2" 226 | source = "registry+https://github.com/rust-lang/crates.io-index" 227 | checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" 228 | dependencies = [ 229 | "aho-corasick", 230 | "memchr", 231 | "regex-automata", 232 | "regex-syntax", 233 | ] 234 | 235 | [[package]] 236 | name = "regex-automata" 237 | version = "0.4.13" 238 | source = "registry+https://github.com/rust-lang/crates.io-index" 239 | checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" 240 | dependencies = [ 241 | "aho-corasick", 242 | "memchr", 243 | "regex-syntax", 244 | ] 245 | 246 | [[package]] 247 | name = "regex-syntax" 248 | version = "0.8.8" 249 | source = "registry+https://github.com/rust-lang/crates.io-index" 250 | checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" 251 | 252 | [[package]] 253 | name = "relative-path" 254 | version = "1.9.3" 255 | source = "registry+https://github.com/rust-lang/crates.io-index" 256 | checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" 257 | 258 | [[package]] 259 | name = "rstest" 260 | version = "0.26.1" 261 | source = "registry+https://github.com/rust-lang/crates.io-index" 262 | checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" 263 | dependencies = [ 264 | "futures-timer", 265 | "futures-util", 266 | "rstest_macros", 267 | ] 268 | 269 | [[package]] 270 | name = "rstest_macros" 271 | version = "0.26.1" 272 | source = "registry+https://github.com/rust-lang/crates.io-index" 273 | checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" 274 | dependencies = [ 275 | "cfg-if", 276 | "glob", 277 | "proc-macro-crate", 278 | "proc-macro2", 279 | "quote", 280 | "regex", 281 | "relative-path", 282 | "rustc_version", 283 | "syn", 284 | "unicode-ident", 285 | ] 286 | 287 | [[package]] 288 | name = "rustc_version" 289 | version = "0.4.1" 290 | source = "registry+https://github.com/rust-lang/crates.io-index" 291 | checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" 292 | dependencies = [ 293 | "semver", 294 | ] 295 | 296 | [[package]] 297 | name = "semver" 298 | version = "1.0.27" 299 | source = "registry+https://github.com/rust-lang/crates.io-index" 300 | checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" 301 | 302 | [[package]] 303 | name = "serde_core" 304 | version = "1.0.228" 305 | source = "registry+https://github.com/rust-lang/crates.io-index" 306 | checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" 307 | dependencies = [ 308 | "serde_derive", 309 | ] 310 | 311 | [[package]] 312 | name = "serde_derive" 313 | version = "1.0.228" 314 | source = "registry+https://github.com/rust-lang/crates.io-index" 315 | checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 316 | dependencies = [ 317 | "proc-macro2", 318 | "quote", 319 | "syn", 320 | ] 321 | 322 | [[package]] 323 | name = "slab" 324 | version = "0.4.11" 325 | source = "registry+https://github.com/rust-lang/crates.io-index" 326 | checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" 327 | 328 | [[package]] 329 | name = "syn" 330 | version = "2.0.110" 331 | source = "registry+https://github.com/rust-lang/crates.io-index" 332 | checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" 333 | dependencies = [ 334 | "proc-macro2", 335 | "quote", 336 | "unicode-ident", 337 | ] 338 | 339 | [[package]] 340 | name = "toml_datetime" 341 | version = "0.7.1" 342 | source = "registry+https://github.com/rust-lang/crates.io-index" 343 | checksum = "a197c0ec7d131bfc6f7e82c8442ba1595aeab35da7adbf05b6b73cd06a16b6be" 344 | dependencies = [ 345 | "serde_core", 346 | ] 347 | 348 | [[package]] 349 | name = "toml_edit" 350 | version = "0.23.5" 351 | source = "registry+https://github.com/rust-lang/crates.io-index" 352 | checksum = "c2ad0b7ae9cfeef5605163839cb9221f453399f15cfb5c10be9885fcf56611f9" 353 | dependencies = [ 354 | "indexmap", 355 | "toml_datetime", 356 | "toml_parser", 357 | "winnow", 358 | ] 359 | 360 | [[package]] 361 | name = "toml_parser" 362 | version = "1.0.2" 363 | source = "registry+https://github.com/rust-lang/crates.io-index" 364 | checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" 365 | dependencies = [ 366 | "winnow", 367 | ] 368 | 369 | [[package]] 370 | name = "unicode-ident" 371 | version = "1.0.22" 372 | source = "registry+https://github.com/rust-lang/crates.io-index" 373 | checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" 374 | 375 | [[package]] 376 | name = "windows-link" 377 | version = "0.2.1" 378 | source = "registry+https://github.com/rust-lang/crates.io-index" 379 | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 380 | 381 | [[package]] 382 | name = "windows-sys" 383 | version = "0.61.2" 384 | source = "registry+https://github.com/rust-lang/crates.io-index" 385 | checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 386 | dependencies = [ 387 | "windows-link", 388 | ] 389 | 390 | [[package]] 391 | name = "winnow" 392 | version = "0.7.14" 393 | source = "registry+https://github.com/rust-lang/crates.io-index" 394 | checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" 395 | dependencies = [ 396 | "memchr", 397 | ] 398 | -------------------------------------------------------------------------------- /src/items/builder.rs: -------------------------------------------------------------------------------- 1 | // For the full copyright and license information, please view the LICENSE 2 | // file that was distributed with this source code. 3 | 4 | use jiff::{civil, Span, Zoned}; 5 | 6 | use super::{date, epoch, error, offset, relative, time, weekday, year, Item}; 7 | 8 | /// The builder is used to construct a DateTime object from various components. 9 | /// The parser creates a `DateTimeBuilder` object with the parsed components, 10 | /// but without the baseline date and time. So you normally need to set the base 11 | /// date and time using the `set_base()` method before calling `build()`, or 12 | /// leave it unset to use the current date and time as the base. 13 | #[derive(Debug, Default)] 14 | pub(crate) struct DateTimeBuilder { 15 | base: Option, 16 | timestamp: Option, 17 | date: Option, 18 | time: Option, 19 | weekday: Option, 20 | offset: Option, 21 | timezone: Option, 22 | relative: Vec, 23 | } 24 | 25 | impl DateTimeBuilder { 26 | pub(super) fn new() -> Self { 27 | Self::default() 28 | } 29 | 30 | /// Sets the base date and time for the builder. If not set, the current 31 | /// date and time will be used. 32 | pub(super) fn set_base(mut self, base: Zoned) -> Self { 33 | self.base = Some(base); 34 | self 35 | } 36 | 37 | /// Sets the timezone rule for the builder. 38 | /// 39 | /// By default, the builder uses the time zone rules indicated by the `TZ` 40 | /// environment variable, or the system default rules if `TZ` is not set. 41 | /// This method allows overriding the time zone rules. 42 | fn set_timezone(mut self, tz: jiff::tz::TimeZone) -> Result { 43 | if self.timezone.is_some() { 44 | return Err("timezone rule cannot appear more than once"); 45 | } 46 | 47 | self.timezone = Some(tz); 48 | Ok(self) 49 | } 50 | 51 | /// Sets a timestamp value. Timestamp values are exclusive to other date/time 52 | /// items (date, time, weekday, timezone, relative adjustments). 53 | pub(super) fn set_timestamp(mut self, ts: epoch::Timestamp) -> Result { 54 | if self.timestamp.is_some() { 55 | return Err("timestamp cannot appear more than once"); 56 | } else if self.date.is_some() 57 | || self.time.is_some() 58 | || self.weekday.is_some() 59 | || self.offset.is_some() 60 | || !self.relative.is_empty() 61 | { 62 | return Err("timestamp cannot be combined with other date/time items"); 63 | } 64 | 65 | self.timestamp = Some(ts); 66 | Ok(self) 67 | } 68 | 69 | fn set_date(mut self, date: date::Date) -> Result { 70 | if self.timestamp.is_some() { 71 | return Err("timestamp cannot be combined with other date/time items"); 72 | } else if self.date.is_some() { 73 | return Err("date cannot appear more than once"); 74 | } 75 | 76 | self.date = Some(date); 77 | Ok(self) 78 | } 79 | 80 | fn set_time(mut self, time: time::Time) -> Result { 81 | if self.timestamp.is_some() { 82 | return Err("timestamp cannot be combined with other date/time items"); 83 | } else if self.time.is_some() { 84 | return Err("time cannot appear more than once"); 85 | } else if self.offset.is_some() && time.offset.is_some() { 86 | return Err("time offset and timezone are mutually exclusive"); 87 | } 88 | 89 | self.time = Some(time); 90 | Ok(self) 91 | } 92 | 93 | fn set_weekday(mut self, weekday: weekday::Weekday) -> Result { 94 | if self.timestamp.is_some() { 95 | return Err("timestamp cannot be combined with other date/time items"); 96 | } else if self.weekday.is_some() { 97 | return Err("weekday cannot appear more than once"); 98 | } 99 | 100 | self.weekday = Some(weekday); 101 | Ok(self) 102 | } 103 | 104 | fn set_offset(mut self, timezone: offset::Offset) -> Result { 105 | if self.timestamp.is_some() { 106 | return Err("timestamp cannot be combined with other date/time items"); 107 | } else if self.offset.is_some() 108 | || self.time.as_ref().and_then(|t| t.offset.as_ref()).is_some() 109 | { 110 | return Err("time offset cannot appear more than once"); 111 | } 112 | 113 | self.offset = Some(timezone); 114 | Ok(self) 115 | } 116 | 117 | fn push_relative(mut self, relative: relative::Relative) -> Result { 118 | if self.timestamp.is_some() { 119 | return Err("timestamp cannot be combined with other date/time items"); 120 | } 121 | 122 | self.relative.push(relative); 123 | Ok(self) 124 | } 125 | 126 | /// Sets a pure number that can be interpreted as either a year or time 127 | /// depending on the current state of the builder. 128 | /// 129 | /// If a date is already set but lacks a year, the number is interpreted as 130 | /// a year. Otherwise, it's interpreted as a time in HHMM, HMM, HH, or H 131 | /// format. 132 | fn set_pure(mut self, pure: String) -> Result { 133 | if self.timestamp.is_some() { 134 | return Err("timestamp cannot be combined with other date/time items"); 135 | } 136 | 137 | if let Some(date) = self.date.as_mut() { 138 | if date.year.is_none() { 139 | date.year = Some(year::year_from_str(&pure)?); 140 | return Ok(self); 141 | } 142 | } 143 | 144 | let (mut hour_str, mut minute_str) = match pure.len() { 145 | 1..=2 => (pure.as_str(), "0"), 146 | 3..=4 => pure.split_at(pure.len() - 2), 147 | _ => { 148 | return Err("pure number must be 1-4 digits when interpreted as time"); 149 | } 150 | }; 151 | 152 | let hour = time::hour24(&mut hour_str).map_err(|_| "invalid hour in pure number")?; 153 | let minute = time::minute(&mut minute_str).map_err(|_| "invalid minute in pure number")?; 154 | 155 | let time = time::Time { 156 | hour, 157 | minute, 158 | ..Default::default() 159 | }; 160 | self.set_time(time) 161 | } 162 | 163 | /// Build a `Zoned` object from the pieces accumulated in this builder. 164 | /// 165 | /// Resolution order (mirrors GNU `date` semantics): 166 | /// 167 | /// 1. Base instant. 168 | /// - a. If `self.base` is provided, start with it. 169 | /// - b. Else if a `timezone` rule is present, start with "now" in that 170 | /// timezone. 171 | /// - c. Else start with current system local time. 172 | /// 173 | /// 2. Absolute timestamp override. 174 | /// - a. If `self.timestamp` is set, it fully determines the result. 175 | /// 176 | /// 3. Time of day truncation. 177 | /// - a. If any of date, time, weekday, offset, timezone is set, zero the 178 | /// time of day to 00:00:00 before applying fields. 179 | /// 180 | /// 4. Fieldwise resolution (applied to the base instant). 181 | /// - a. Apply date. If year is absent in the parsed date, inherit the year 182 | /// from the base instant. 183 | /// - b. Apply time. If time carries an explicit numeric offset, apply the 184 | /// offset before setting time. 185 | /// - c. Apply weekday (e.g., "next Friday" or "last Monday"). 186 | /// - d. Apply relative adjustments (e.g., "+3 days", "-2 months"). 187 | /// - e. Apply final fixed offset if present. 188 | pub(super) fn build(self) -> Result { 189 | // 1. Choose the base instant. 190 | let base = match (self.base, &self.timezone) { 191 | (Some(b), _) => b, 192 | (None, Some(tz)) => jiff::Timestamp::now().to_zoned(tz.clone()), 193 | (None, None) => Zoned::now(), 194 | }; 195 | 196 | // 2. Absolute timestamp override everything else. 197 | if let Some(ts) = self.timestamp { 198 | let ts = jiff::Timestamp::try_from(ts)?; 199 | return Ok(ts.to_zoned(base.offset().to_time_zone())); 200 | } 201 | 202 | // 3. Determine whether to truncate the time of day. 203 | let need_midnight = self.date.is_some() 204 | || self.time.is_some() 205 | || self.weekday.is_some() 206 | || self.offset.is_some() 207 | || self.timezone.is_some(); 208 | 209 | let mut dt = if need_midnight { 210 | base.with().time(civil::time(0, 0, 0, 0)).build()? 211 | } else { 212 | base 213 | }; 214 | 215 | // 4a. Apply date. 216 | if let Some(date) = self.date { 217 | let d: civil::Date = if date.year.is_some() { 218 | date.try_into()? 219 | } else { 220 | date.with_year(dt.date().year() as u16).try_into()? 221 | }; 222 | dt = dt.with().date(d).build()?; 223 | } 224 | 225 | // 4b. Apply time. 226 | if let Some(time) = self.time.clone() { 227 | if let Some(offset) = &time.offset { 228 | dt = dt.datetime().to_zoned(offset.try_into()?)?; 229 | } 230 | 231 | let t: civil::Time = time.try_into()?; 232 | dt = dt.with().time(t).build()?; 233 | } 234 | 235 | // 4c. Apply weekday. 236 | if let Some(weekday::Weekday { mut offset, day }) = self.weekday { 237 | if self.time.is_none() { 238 | dt = dt.with().time(civil::time(0, 0, 0, 0)).build()?; 239 | } 240 | 241 | let target = day.into(); 242 | 243 | // If the current day is not the target day, we need to adjust 244 | // the x value to ensure we find the correct day. 245 | // 246 | // Consider this: 247 | // Assuming today is Monday, next Friday is actually THIS Friday; 248 | // but next Monday is indeed NEXT Monday. 249 | if dt.date().weekday() != target && offset > 0 { 250 | offset -= 1; 251 | } 252 | 253 | // Calculate the delta to the target day. 254 | // 255 | // Assuming today is Thursday, here are some examples: 256 | // 257 | // Example 1: last Thursday (x = -1, day = Thursday) 258 | // delta = (3 - 3) % 7 + (-1) * 7 = -7 259 | // 260 | // Example 2: last Monday (x = -1, day = Monday) 261 | // delta = (0 - 3) % 7 + (-1) * 7 = -3 262 | // 263 | // Example 3: next Monday (x = 1, day = Monday) 264 | // delta = (0 - 3) % 7 + (0) * 7 = 4 265 | // (Note that we have adjusted the x value above) 266 | // 267 | // Example 4: next Thursday (x = 1, day = Thursday) 268 | // delta = (3 - 3) % 7 + (1) * 7 = 7 269 | let delta = (target.since(civil::Weekday::Monday) as i32 270 | - dt.date().weekday().since(civil::Weekday::Monday) as i32) 271 | .rem_euclid(7) 272 | + offset.checked_mul(7).ok_or("multiplication overflow")?; 273 | 274 | dt = dt.checked_add(Span::new().try_days(delta)?)?; 275 | } 276 | 277 | // 4d. Apply relative adjustments. 278 | for rel in self.relative { 279 | dt = dt.checked_add::(if let relative::Relative::Months(x) = rel { 280 | // *NOTE* This is done in this way to conform to GNU behavior. 281 | let days = dt.date().last_of_month().day() as i32; 282 | Span::new().try_days(days.checked_mul(x).ok_or("multiplication overflow")?)? 283 | } else { 284 | rel.try_into()? 285 | })?; 286 | } 287 | 288 | // 4e. Apply final fixed offset. 289 | if let Some(offset) = self.offset { 290 | let (offset, hour_adjustment) = offset.normalize(); 291 | dt = dt.checked_add(Span::new().hours(hour_adjustment))?; 292 | dt = dt.datetime().to_zoned((&offset).try_into()?)?; 293 | } 294 | 295 | Ok(dt) 296 | } 297 | } 298 | 299 | impl TryFrom> for DateTimeBuilder { 300 | type Error = &'static str; 301 | 302 | fn try_from(items: Vec) -> Result { 303 | let mut builder = DateTimeBuilder::new(); 304 | 305 | for item in items { 306 | builder = match item { 307 | Item::Timestamp(ts) => builder.set_timestamp(ts)?, 308 | Item::DateTime(dt) => builder.set_date(dt.date)?.set_time(dt.time)?, 309 | Item::Date(d) => builder.set_date(d)?, 310 | Item::Time(t) => builder.set_time(t)?, 311 | Item::Weekday(weekday) => builder.set_weekday(weekday)?, 312 | Item::Offset(offset) => builder.set_offset(offset)?, 313 | Item::Relative(rel) => builder.push_relative(rel)?, 314 | Item::TimeZone(tz) => builder.set_timezone(tz)?, 315 | Item::Pure(pure) => builder.set_pure(pure)?, 316 | } 317 | } 318 | 319 | Ok(builder) 320 | } 321 | } 322 | -------------------------------------------------------------------------------- /src/items/time.rs: -------------------------------------------------------------------------------- 1 | // For the full copyright and license information, please view the LICENSE 2 | // file that was distributed with this source code. 3 | 4 | // spell-checker:ignore shhmm colonless 5 | 6 | //! Parse a time item (without a date). 7 | //! 8 | //! The GNU docs state: 9 | //! 10 | //! > More generally, the time of day may be given as ‘hour:minute:second’, 11 | //! > where hour is a number between 0 and 23, minute is a number between 0 and 12 | //! > 59, and second is a number between 0 and 59 possibly followed by ‘.’ or 13 | //! > ‘,’ and a fraction containing one or more digits. Alternatively, 14 | //! > ‘:second’ can be omitted, in which case it is taken to be zero. On the 15 | //! > rare hosts that support leap seconds, second may be 60. 16 | //! > 17 | //! > If the time is followed by ‘am’ or ‘pm’ (or ‘a.m.’ or ‘p.m.’), hour is 18 | //! > restricted to run from 1 to 12, and ‘:minute’ may be omitted (taken to be 19 | //! > zero). ‘am’ indicates the first half of the day, ‘pm’ indicates the 20 | //! > second half of the day. In this notation, 12 is the predecessor of 1: 21 | //! > midnight is ‘12am’ while noon is ‘12pm’. (This is the zero-oriented 22 | //! > interpretation of ‘12am’ and ‘12pm’, as opposed to the old tradition 23 | //! > derived from Latin which uses ‘12m’ for noon and ‘12pm’ for midnight.) 24 | //! > 25 | //! > The time may alternatively be followed by a time zone correction, 26 | //! > expressed as ‘shhmm’, where s is ‘+’ or ‘-’, hh is a number of zone hours 27 | //! > and mm is a number of zone minutes. The zone minutes term, mm, may be 28 | //! > omitted, in which case the one- or two-digit correction is interpreted as 29 | //! > a number of hours. You can also separate hh from mm with a colon. When a 30 | //! > time zone correction is given this way, it forces interpretation of the 31 | //! > time relative to Coordinated Universal Time (UTC), overriding any 32 | //! > previous specification for the time zone or the local time zone. For 33 | //! > example, ‘+0530’ and ‘+05:30’ both stand for the time zone 5.5 hours 34 | //! > ahead of UTC (e.g., India). This is the best way to specify a time zone 35 | //! > correction by fractional parts of an hour. The maximum zone correction is 36 | //! > 24 hours. 37 | //! > 38 | //! > Either ‘am’/‘pm’ or a time zone correction may be specified, but not both. 39 | 40 | use winnow::{ 41 | combinator::{alt, opt, preceded}, 42 | error::ErrMode, 43 | ModalResult, Parser, 44 | }; 45 | 46 | use super::{ 47 | epoch::sec_and_nsec, 48 | offset::{timezone_offset, Offset}, 49 | primitive::{colon, ctx_err, dec_uint, s}, 50 | }; 51 | 52 | #[derive(PartialEq, Clone, Debug, Default)] 53 | pub(crate) struct Time { 54 | pub(crate) hour: u8, 55 | pub(crate) minute: u8, 56 | pub(crate) second: u8, 57 | pub(crate) nanosecond: u32, 58 | pub(super) offset: Option, 59 | } 60 | 61 | impl TryFrom