Add extended runtime + ParsedDateTime API for large years (#263)

This commit is contained in:
abhishekpradhan
2026-03-31 09:51:07 +02:00
committed by GitHub
parent f6f1257961
commit ee4971cc73
14 changed files with 1773 additions and 153 deletions
Generated
+1 -1
View File
@@ -158,7 +158,7 @@ dependencies = [
[[package]]
name = "parse_datetime"
version = "0.14.0"
version = "0.15.0"
dependencies = [
"jiff",
"num-traits",
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "parse_datetime"
description = "parsing human-readable time strings and converting them to a DateTime"
version = "0.14.0"
version = "0.15.0"
edition = "2021"
license = "MIT"
repository = "https://github.com/uutils/parse_datetime"
+15 -9
View File
@@ -4,7 +4,8 @@
[![License](http://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/uutils/parse_datetime/blob/main/LICENSE)
[![CodeCov](https://codecov.io/gh/uutils/parse_datetime/branch/main/graph/badge.svg)](https://codecov.io/gh/uutils/parse_datetime)
A Rust crate for parsing human-readable relative time strings and human-readable datetime strings and converting them to a jiff's `Zoned` object.
A Rust crate for parsing human-readable relative time strings and
human-readable datetime strings.
## Features
@@ -26,25 +27,28 @@ Then, import the crate and use the `parse_datetime_at_date` function:
```rs
use jiff::{ToSpan, Zoned};
use parse_datetime::parse_datetime_at_date;
use parse_datetime::{parse_datetime_at_date, ParsedDateTime};
let now = Zoned::now();
let after = parse_datetime_at_date(now.clone(), "+3 days");
assert_eq!(
now.checked_add(3.days()).unwrap(),
after.unwrap()
);
match after.unwrap() {
ParsedDateTime::InRange(z) => assert_eq!(now.checked_add(3.days()).unwrap(), z),
ParsedDateTime::Extended(_) => unreachable!("unexpected for this input"),
}
```
For DateTime parsing, import the `parse_datetime` function:
```rs
use jiff::{civil::{date, time} ,Zoned};
use parse_datetime::parse_datetime;
use parse_datetime::{parse_datetime, ParsedDateTime};
let dt = parse_datetime("2021-02-14 06:37:47");
assert_eq!(dt.unwrap(), Zoned::now().with().date(date(2021, 2, 14)).time(time(6, 37, 47, 0)).build().unwrap());
match dt.unwrap() {
ParsedDateTime::InRange(z) => assert_eq!(z, Zoned::now().with().date(date(2021, 2, 14)).time(time(6, 37, 47, 0)).build().unwrap()),
ParsedDateTime::Extended(_) => unreachable!("unexpected for this input"),
}
```
### Supported Formats
@@ -69,7 +73,9 @@ The `parse_datetime` and `parse_datetime_at_date` functions support absolute dat
The `parse_datetime` and `parse_datetime_at_date` function return:
- `Ok(Zoned)` - If the input string can be parsed as a `Zoned` object
- `Ok(ParsedDateTime)` - If the input string can be parsed
- `ParsedDateTime::InRange(Zoned)` for years supported by `jiff::Zoned`
- `ParsedDateTime::Extended(ExtendedDateTime)` for out-of-range years (for example `>9999`)
- `Err(ParseDateTimeError::InvalidInput)` - If the input string cannot be parsed
## Fuzzer
+35 -35
View File
@@ -16,9 +16,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "cc"
version = "1.2.47"
version = "1.2.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd405d82c84ff7f35739f175f67d8b9fb7687a0e84ccdc78bd3568839827cf07"
checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -34,9 +34,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "find-msvc-tools"
version = "0.1.5"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fuzz_parse_datetime"
@@ -60,9 +60,9 @@ dependencies = [
[[package]]
name = "jiff"
version = "0.2.16"
version = "0.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35"
checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
dependencies = [
"jiff-static",
"jiff-tzdb-platform",
@@ -75,9 +75,9 @@ dependencies = [
[[package]]
name = "jiff-static"
version = "0.2.16"
version = "0.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69"
checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
dependencies = [
"proc-macro2",
"quote",
@@ -86,9 +86,9 @@ dependencies = [
[[package]]
name = "jiff-tzdb"
version = "0.1.4"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1283705eb0a21404d2bfd6eef2a7593d240bc42a0bdb39db0ad6fa2ec026524"
checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076"
[[package]]
name = "jiff-tzdb-platform"
@@ -111,9 +111,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.177"
version = "0.2.183"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
[[package]]
name = "libfuzzer-sys"
@@ -127,15 +127,15 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.28"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "memchr"
version = "2.7.6"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "num-traits"
@@ -148,7 +148,7 @@ dependencies = [
[[package]]
name = "parse_datetime"
version = "0.13.3"
version = "0.15.0"
dependencies = [
"jiff",
"num-traits",
@@ -157,33 +157,33 @@ dependencies = [
[[package]]
name = "portable-atomic"
version = "1.11.1"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-atomic-util"
version = "0.2.4"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
dependencies = [
"portable-atomic",
]
[[package]]
name = "proc-macro2"
version = "1.0.103"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.42"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
@@ -222,9 +222,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "syn"
version = "2.0.110"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
@@ -233,15 +233,15 @@ dependencies = [
[[package]]
name = "unicode-ident"
version = "1.0.22"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "wasip2"
version = "1.0.1+wasi-0.2.4"
version = "1.0.2+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
dependencies = [
"wit-bindgen",
]
@@ -263,15 +263,15 @@ dependencies = [
[[package]]
name = "winnow"
version = "0.7.13"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
version = "0.46.0"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+724
View File
File diff suppressed because it is too large Load Diff
+552 -28
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -33,7 +33,7 @@ use super::primitive::{dec_uint, plus_or_minus, s};
/// - `nanosecond` is always in the range of `0..1_000_000_000`.
/// - Negative timestamps are represented by a negative `second` value and a
/// positive `nanosecond` value.
#[derive(Debug, PartialEq)]
#[derive(Debug, PartialEq, Clone, Copy)]
pub(super) struct Timestamp {
second: i64,
nanosecond: u32,
+232 -15
View File
@@ -48,6 +48,7 @@ mod primitive;
pub(crate) mod error;
use crate::ParsedDateTime;
use jiff::Zoned;
use primitive::space;
use winnow::{
@@ -62,6 +63,7 @@ use error::Error;
#[derive(PartialEq, Debug)]
enum Item {
#[cfg(test)]
Timestamp(epoch::Timestamp),
DateTime(combined::DateTime),
Date(date::Date),
@@ -73,18 +75,21 @@ enum Item {
Pure(String),
}
/// Parse a date and time string and build a `Zoned` object. The parsed result
/// is resolved against the given base date and time.
pub(crate) fn parse_at_date<S: AsRef<str> + Clone>(base: Zoned, input: S) -> Result<Zoned, Error> {
/// Parse a date and time string and resolve it against the given base date and
/// time, returning a [`ParsedDateTime`] result.
pub(crate) fn parse_at_date<S: AsRef<str> + Clone>(
base: Zoned,
input: S,
) -> Result<ParsedDateTime, Error> {
match parse(&mut input.as_ref()) {
Ok(builder) => builder.set_base(base).build(),
Err(e) => Err(e.into()),
}
}
/// Parse a date and time string and build a `Zoned` object. The parsed result
/// is resolved against the current local date and time.
pub(crate) fn parse_at_local<S: AsRef<str> + Clone>(input: S) -> Result<Zoned, Error> {
/// Parse a date and time string and resolve it against the current local date
/// and time, returning a [`ParsedDateTime`] result.
pub(crate) fn parse_at_local<S: AsRef<str> + Clone>(input: S) -> Result<ParsedDateTime, Error> {
match parse(&mut input.as_ref()) {
Ok(builder) => builder.build(), // the builder uses current local date and time if no base is given.
Err(e) => Err(e.into()),
@@ -209,12 +214,9 @@ fn parse_timestamp(input: &mut &str) -> ModalResult<DateTimeBuilder> {
trace(
"parse_timestamp",
// Expect exactly one timestamp and then EOF (allowing trailing spaces).
terminated(epoch::parse.map(Item::Timestamp), preceded(space, eof)),
terminated(epoch::parse, preceded(space, eof)),
)
.verify_map(|item: Item| match item {
Item::Timestamp(ts) => DateTimeBuilder::new().set_timestamp(ts).ok(),
_ => None,
})
.verify_map(|ts| DateTimeBuilder::new().set_timestamp(ts).ok())
.parse_next(input)
}
@@ -277,12 +279,13 @@ fn expect_error(input: &mut &str, reason: &'static str) -> ErrMode<ContextError>
#[cfg(test)]
mod tests {
use crate::ParsedDateTime;
use jiff::{civil::DateTime, tz::TimeZone, ToSpan, Zoned};
use super::*;
fn at_date(builder: DateTimeBuilder, base: Zoned) -> Zoned {
builder.set_base(base).build().unwrap()
builder.set_base(base).build().unwrap().expect_in_range()
}
fn at_utc(builder: DateTimeBuilder) -> Zoned {
@@ -299,6 +302,49 @@ mod tests {
.to_string()
}
fn format_offset_colon(seconds: i32) -> String {
let sign = if seconds < 0 { '-' } else { '+' };
let abs = seconds.unsigned_abs();
let hours = abs / 3600;
let minutes = (abs % 3600) / 60;
format!("{sign}{hours:02}:{minutes:02}")
}
fn assert_extended_datetime(input: &str, base: Zoned, expected: &str) {
match parse_at_date(base, input).unwrap() {
ParsedDateTime::Extended(dt) => {
let actual = format!(
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}{}",
dt.year,
dt.month,
dt.day,
dt.hour,
dt.minute,
dt.second,
format_offset_colon(dt.offset_seconds)
);
assert_eq!(actual, expected, "{input}");
}
ParsedDateTime::InRange(z) => {
panic!("expected extended datetime, got in-range: {z}");
}
}
}
fn expect_extended_datetime(parsed: ParsedDateTime) -> crate::ExtendedDateTime {
match parsed {
ParsedDateTime::Extended(dt) => dt,
ParsedDateTime::InRange(z) => panic!("expected extended datetime, got in-range: {z}"),
}
}
fn expect_in_range_datetime(parsed: ParsedDateTime) -> Zoned {
match parsed {
ParsedDateTime::InRange(z) => z,
ParsedDateTime::Extended(dt) => panic!("expected in-range datetime, got {dt:?}"),
}
}
#[test]
fn date_and_time() {
assert_eq!(
@@ -408,9 +454,12 @@ mod tests {
let result = parse(&mut "2025-05-19 @1690466034");
assert!(result.is_err());
// Pure number as year (large years are parsed successfully).
// Pure number as year (large years are accepted).
let result = parse(&mut "jul 18 12:30 10000");
assert!(result.is_ok());
let built = result.unwrap().build().unwrap();
let dt = expect_extended_datetime(built);
assert_eq!(dt.year, 10000);
// Pure number as time (too long).
let result = parse(&mut "01:02 12345");
@@ -459,6 +508,170 @@ mod tests {
.contains("base year must be non-negative"));
}
#[test]
fn boundary_rollover_from_9999_falls_back_to_extended() {
let base = "2000-01-01 00:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
assert_extended_datetime("9999-12-31 +1 day", base, "10000-01-01 00:00:00+00:00");
}
#[test]
fn boundary_year_9999_absolute_date_parses() {
let base = "2000-01-01 00:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
assert_extended_datetime("9999-12-31", base, "9999-12-31 00:00:00+00:00");
}
#[test]
fn boundary_year_9999_with_time_parses() {
let base = "2000-01-01 00:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
assert_extended_datetime("9999-12-31 12:00", base, "9999-12-31 12:00:00+00:00");
}
#[test]
fn boundary_year_9999_with_utc_timezone_parses() {
let base = "2000-01-01 00:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
assert_extended_datetime("9999-12-31 12:00 UTC", base, "9999-12-31 12:00:00+00:00");
}
#[test]
fn boundary_year_9999_with_explicit_offset_parses() {
let base = "2000-01-01 00:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
assert_extended_datetime("9999-12-31 12:00 +01:00", base, "9999-12-31 12:00:00+01:00");
}
#[test]
fn large_year_relative_parity_months_and_years() {
let base = "2000-01-01 00:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
assert_extended_datetime(
"10000-01-31 +2 months",
base.clone(),
"10000-03-31 00:00:00+00:00",
);
assert_extended_datetime(
"10000-01-31 +3 months",
base.clone(),
"10000-05-01 00:00:00+00:00",
);
assert_extended_datetime(
"10000-08-31 +6 months",
base.clone(),
"10001-03-03 00:00:00+00:00",
);
assert_extended_datetime(
"10000-05-31 3 months ago",
base.clone(),
"10000-03-02 00:00:00+00:00",
);
assert_extended_datetime("10000-02-29 +1 year", base, "10001-03-01 00:00:00+00:00");
}
#[test]
fn rejects_year_above_gnu_max() {
assert!(parse_at_local("2147485548-01-01").is_err());
}
#[test]
fn large_year_can_return_in_range_after_relative() {
let base = "2000-01-01 00:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
let result = parse_at_date(base, "10000-01-01 -1000 years").unwrap();
let z = expect_in_range_datetime(result);
assert_eq!(
z.strftime("%Y-%m-%d %H:%M:%S%:z").to_string(),
"9000-01-01 00:00:00+00:00"
);
}
#[test]
fn large_year_can_return_in_range_after_relative_with_named_timezone_rule() {
let base = "2000-01-01 00:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
let result = parse_at_date(base, "TZ=\"Europe/Paris\" 10000-01-01 -1000 years").unwrap();
let z = expect_in_range_datetime(result);
assert_eq!(
z.strftime("%Y-%m-%d %H:%M:%S%:z").to_string(),
"9000-01-01 00:00:00+01:00"
);
}
#[test]
fn large_year_time_with_explicit_offset_is_extended() {
let base = "2000-01-01 00:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
let dt =
expect_extended_datetime(parse_at_date(base, "10000-01-01 12:34:56+02:00").unwrap());
assert_eq!((dt.year, dt.month, dt.day), (10000, 1, 1));
assert_eq!((dt.hour, dt.minute, dt.second), (12, 34, 56));
assert_eq!(dt.offset_seconds, 2 * 3600);
}
#[test]
fn parse_at_date_returns_error_for_invalid_input() {
let base = "2000-01-01 00:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
let result = parse_at_date(base, "not-a-date");
assert!(result.is_err());
}
#[test]
#[should_panic(expected = "expected extended datetime")]
fn assert_extended_datetime_panics_for_in_range_input() {
let base = "2000-01-01 00:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
assert_extended_datetime("2001-01-01", base, "2001-01-01 00:00:00+00:00");
}
#[test]
#[should_panic(expected = "expected in-range datetime")]
fn expect_in_range_datetime_panics_for_extended_input() {
let base = "2000-01-01 00:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
let parsed = parse_at_date(base, "10000-01-01").unwrap();
let _ = expect_in_range_datetime(parsed);
}
#[test]
fn relative_weekday() {
// Jan 1 2025 is a Wed
@@ -573,11 +786,15 @@ mod tests {
for (input, expected) in [
(
r#"TZ="Europe/Paris" 2025-01-02"#,
"2025-01-02 00:00:00[Europe/Paris]".parse().unwrap(),
"2025-01-02 00:00:00[Europe/Paris]"
.parse::<Zoned>()
.unwrap(),
),
(
r#"TZ="Europe/Paris" 2025-01-02 03:04:05"#,
"2025-01-02 03:04:05[Europe/Paris]".parse().unwrap(),
"2025-01-02 03:04:05[Europe/Paris]"
.parse::<Zoned>()
.unwrap(),
),
] {
assert_eq!(parse_build(input), expected, "{input}");
+17
View File
@@ -108,6 +108,15 @@ impl Offset {
hour_adjustment,
)
}
pub(super) fn total_seconds(&self) -> i32 {
let secs = (self.hours as i32) * 3600 + (self.minutes as i32) * 60;
if self.negative {
-secs
} else {
secs
}
}
}
impl TryFrom<(bool, u8, u8)> for Offset {
@@ -465,4 +474,12 @@ mod tests {
);
}
}
#[test]
fn total_seconds() {
assert_eq!(off(false, 0, 0).total_seconds(), 0);
assert_eq!(off(false, 5, 30).total_seconds(), 19_800);
assert_eq!(off(true, 5, 30).total_seconds(), -19_800);
assert_eq!(off(false, 24, 0).total_seconds(), 86_400);
}
}
+1 -1
View File
@@ -40,7 +40,7 @@ pub(crate) enum Day {
Sunday,
}
#[derive(PartialEq, Eq, Debug)]
#[derive(PartialEq, Eq, Debug, Clone)]
pub(crate) struct Weekday {
pub(crate) offset: i32,
pub(crate) day: Day,
+138 -50
View File
@@ -1,6 +1,7 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
//! A Rust crate for parsing human-readable relative time strings and human-readable datetime strings and converting them to a `DateTime`.
//! A Rust crate for parsing human-readable relative time strings and
//! human-readable datetime strings.
//! The function supports the following formats for time:
//!
//! * ISO formats
@@ -13,8 +14,56 @@ use std::fmt::{self, Display};
use jiff::Zoned;
mod extended;
mod items;
pub use extended::{DateParts, ExtendedDateTime, TimeParts};
/// Maximum year accepted by GNU `date`.
pub const GNU_MAX_YEAR: u32 = 2_147_485_547;
/// Parsed datetime output.
///
/// - [`ParsedDateTime::InRange`] contains a standard [`jiff::Zoned`] value.
/// - [`ParsedDateTime::Extended`] contains an out-of-range year representation
/// (for example, years greater than `9999`) that cannot be represented by
/// `jiff::Zoned`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParsedDateTime {
InRange(Zoned),
Extended(ExtendedDateTime),
}
impl ParsedDateTime {
pub fn as_zoned(&self) -> Option<&Zoned> {
match self {
ParsedDateTime::InRange(z) => Some(z),
ParsedDateTime::Extended(_) => None,
}
}
pub fn into_zoned(self) -> Option<Zoned> {
match self {
ParsedDateTime::InRange(z) => Some(z),
ParsedDateTime::Extended(_) => None,
}
}
pub fn expect_in_range(self) -> Zoned {
self.into_zoned()
.expect("ParsedDateTime is not representable as jiff::Zoned")
}
}
impl PartialEq<Zoned> for ParsedDateTime {
fn eq(&self, other: &Zoned) -> bool {
match self {
ParsedDateTime::InRange(z) => z == other,
ParsedDateTime::Extended(_) => false,
}
}
}
#[derive(Debug, PartialEq)]
pub enum ParseDateTimeError {
InvalidInput,
@@ -23,12 +72,7 @@ pub enum ParseDateTimeError {
impl Display for ParseDateTimeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ParseDateTimeError::InvalidInput => {
write!(
f,
"Invalid input string: cannot be parsed as a relative time"
)
}
ParseDateTimeError::InvalidInput => write!(f, "Invalid input string"),
}
}
}
@@ -41,8 +85,8 @@ impl From<items::error::Error> for ParseDateTimeError {
}
}
/// Parses a time string and returns a `Zoned` object representing the absolute
/// time of the string.
/// Parses a time string and returns a [`ParsedDateTime`] representing the
/// absolute time of the string.
///
/// # Arguments
///
@@ -51,29 +95,34 @@ impl From<items::error::Error> for ParseDateTimeError {
/// # Examples
///
/// ```
/// use jiff::Zoned;
/// use parse_datetime::parse_datetime;
/// use parse_datetime::{parse_datetime, ParsedDateTime};
///
/// let time = parse_datetime("2023-06-03 12:00:01Z").unwrap();
/// assert_eq!(time.strftime("%F %T").to_string(), "2023-06-03 12:00:01");
/// match time {
/// ParsedDateTime::InRange(z) => {
/// assert_eq!(z.strftime("%F %T").to_string(), "2023-06-03 12:00:01");
/// }
/// ParsedDateTime::Extended(_) => unreachable!("unexpected for this input"),
/// }
/// ```
///
///
/// # Returns
///
/// * `Ok(Zoned)` - If the input string can be parsed as a time
/// * `Err(ParseDateTimeError)` - If the input string cannot be parsed as a
/// relative time
/// * `Ok(ParsedDateTime)` - If the input string can be parsed as a time
/// * `Err(ParseDateTimeError)` - If the input string cannot be parsed
///
/// # Errors
///
/// This function will return `Err(ParseDateTimeError::InvalidInput)` if the
/// input string cannot be parsed as a relative time.
pub fn parse_datetime<S: AsRef<str> + Clone>(input: S) -> Result<Zoned, ParseDateTimeError> {
/// input string cannot be parsed.
pub fn parse_datetime<S: AsRef<str> + Clone>(
input: S,
) -> Result<ParsedDateTime, ParseDateTimeError> {
items::parse_at_local(input).map_err(|e| e.into())
}
/// Parses a time string at a specific date and returns a `Zoned` object
/// Parses a time string at a specific date and returns a [`ParsedDateTime`]
/// representing the absolute time of the string.
///
/// # Arguments
@@ -85,31 +134,30 @@ pub fn parse_datetime<S: AsRef<str> + Clone>(input: S) -> Result<Zoned, ParseDat
///
/// ```
/// use jiff::Zoned;
/// use parse_datetime::parse_datetime_at_date;
/// use parse_datetime::{parse_datetime_at_date, ParsedDateTime};
///
/// let now = Zoned::now();
/// let after = parse_datetime_at_date(now, "2024-09-13UTC +3 days").unwrap();
///
/// assert_eq!(
/// "2024-09-16",
/// after.strftime("%F").to_string()
/// );
/// match after {
/// ParsedDateTime::InRange(z) => assert_eq!("2024-09-16", z.strftime("%F").to_string()),
/// ParsedDateTime::Extended(_) => unreachable!("unexpected for this input"),
/// }
/// ```
///
/// # Returns
///
/// * `Ok(Zoned)` - If the input string can be parsed as a time
/// * `Err(ParseDateTimeError)` - If the input string cannot be parsed as a
/// relative time
/// * `Ok(ParsedDateTime)` - If the input string can be parsed as a time
/// * `Err(ParseDateTimeError)` - If the input string cannot be parsed
///
/// # Errors
///
/// This function will return `Err(ParseDateTimeError::InvalidInput)` if the
/// input string cannot be parsed as a relative time.
/// input string cannot be parsed.
pub fn parse_datetime_at_date<S: AsRef<str> + Clone>(
date: Zoned,
input: S,
) -> Result<Zoned, ParseDateTimeError> {
) -> Result<ParsedDateTime, ParseDateTimeError> {
items::parse_at_date(date, input).map_err(|e| e.into())
}
@@ -131,35 +179,35 @@ mod tests {
#[test]
fn test_t_sep() {
let dt = "2021-02-15T06:37:47 +0000";
let actual = parse_datetime(dt).unwrap();
let actual = parse_datetime(dt).unwrap().expect_in_range();
assert_eq!(actual.timestamp().as_second(), TEST_TIME);
}
#[test]
fn test_space_sep() {
let dt = "2021-02-15 06:37:47 +0000";
let actual = parse_datetime(dt).unwrap();
let actual = parse_datetime(dt).unwrap().expect_in_range();
assert_eq!(actual.timestamp().as_second(), TEST_TIME);
}
#[test]
fn test_space_sep_offset() {
let dt = "2021-02-14 22:37:47 -0800";
let actual = parse_datetime(dt).unwrap();
let actual = parse_datetime(dt).unwrap().expect_in_range();
assert_eq!(actual.timestamp().as_second(), TEST_TIME);
}
#[test]
fn test_t_sep_offset() {
let dt = "2021-02-14T22:37:47 -0800";
let actual = parse_datetime(dt).unwrap();
let actual = parse_datetime(dt).unwrap().expect_in_range();
assert_eq!(actual.timestamp().as_second(), TEST_TIME);
}
#[test]
fn test_t_sep_single_digit_offset_no_space() {
let dt = "2021-02-14T22:37:47-8";
let actual = parse_datetime(dt).unwrap();
let actual = parse_datetime(dt).unwrap().expect_in_range();
assert_eq!(actual.timestamp().as_second(), TEST_TIME);
}
@@ -184,7 +232,7 @@ mod tests {
#[test]
fn test_epoch_seconds() {
let dt = "@1613371067";
let actual = parse_datetime(dt).unwrap();
let actual = parse_datetime(dt).unwrap().expect_in_range();
assert_eq!(actual.timestamp().as_second(), TEST_TIME);
}
@@ -215,14 +263,14 @@ mod tests {
.build()
.unwrap();
assert_eq!(expected, parse_datetime("1987-05-07").unwrap());
assert_eq!(expected, parse_datetime("1987-5-07").unwrap());
assert_eq!(expected, parse_datetime("1987-05-7").unwrap());
assert_eq!(expected, parse_datetime("1987-5-7").unwrap());
assert_eq!(expected, parse_datetime("5/7/1987").unwrap());
assert_eq!(expected, parse_datetime("5/07/1987").unwrap());
assert_eq!(expected, parse_datetime("05/7/1987").unwrap());
assert_eq!(expected, parse_datetime("05/07/1987").unwrap());
assert_eq!(parse_datetime("1987-05-07").unwrap(), expected);
assert_eq!(parse_datetime("1987-5-07").unwrap(), expected);
assert_eq!(parse_datetime("1987-05-7").unwrap(), expected);
assert_eq!(parse_datetime("1987-5-7").unwrap(), expected);
assert_eq!(parse_datetime("5/7/1987").unwrap(), expected);
assert_eq!(parse_datetime("5/07/1987").unwrap(), expected);
assert_eq!(parse_datetime("05/7/1987").unwrap(), expected);
assert_eq!(parse_datetime("05/07/1987").unwrap(), expected);
}
}
@@ -245,7 +293,7 @@ mod tests {
let expected = format!("{}{}", Zoned::now().strftime("%Y%m%d"), "0000+0700");
for offset in offsets {
let actual = parse_datetime(offset).unwrap();
let actual = parse_datetime(offset).unwrap().expect_in_range();
assert_eq!(expected, actual.strftime("%Y%m%d%H%M%z").to_string());
}
}
@@ -255,7 +303,7 @@ mod tests {
let offsets = vec!["UTC+00:15", "UTC+0015", "Z+00:15", "Z+0015"];
let expected = format!("{}{}", Zoned::now().strftime("%Y%m%d"), "0000+0015");
for offset in offsets {
let actual = parse_datetime(offset).unwrap();
let actual = parse_datetime(offset).unwrap().expect_in_range();
assert_eq!(expected, actual.strftime("%Y%m%d%H%M%z").to_string());
}
}
@@ -352,7 +400,9 @@ mod tests {
use crate::parse_datetime_at_date;
fn get_formatted_date(date: &Zoned, weekday: &str) -> String {
let result = parse_datetime_at_date(date.clone(), weekday).unwrap();
let result = parse_datetime_at_date(date.clone(), weekday)
.unwrap()
.expect_in_range();
result.strftime("%F %T %9f").to_string()
}
@@ -415,12 +465,16 @@ mod tests {
for offset in offsets {
// positive offset
let time = Timestamp::from_second(offset).unwrap();
let dt = parse_datetime(format!("@{offset}")).unwrap();
let dt = parse_datetime(format!("@{offset}"))
.unwrap()
.expect_in_range();
assert_eq!(dt.timestamp(), time);
// negative offset
let time = Timestamp::from_second(-offset).unwrap();
let dt = parse_datetime(format!("@-{offset}")).unwrap();
let dt = parse_datetime(format!("@-{offset}"))
.unwrap()
.expect_in_range();
assert_eq!(dt.timestamp(), time);
}
}
@@ -466,6 +520,24 @@ mod tests {
assert_eq!(actual, expected);
}
#[test]
fn parsed_datetime_accessors_cover_both_variants() {
let in_range = parse_datetime("2023-06-03 12:00:01Z").unwrap();
assert!(in_range.as_zoned().is_some());
assert!(in_range.into_zoned().is_some());
let extended = parse_datetime("10000-01-01").unwrap();
assert!(extended.as_zoned().is_none());
assert!(extended.into_zoned().is_none());
}
#[test]
fn parsed_datetime_extended_never_equals_zoned() {
let extended = parse_datetime("10000-01-01").unwrap();
let zoned = Zoned::now();
assert_ne!(extended, zoned);
}
#[test]
fn test_parse_invalid_datetime() {
assert!(crate::parse_datetime("bogus +1 day").is_err());
@@ -631,6 +703,7 @@ mod tests {
assert_eq!(
parse_datetime("28 feb + 1 month")
.expect("parse_datetime")
.expect_in_range()
.strftime("%m%d")
.to_string(),
"0328"
@@ -650,6 +723,7 @@ mod tests {
assert_eq!(
parse_datetime("28 feb 2023 + 1 day")
.unwrap()
.expect_in_range()
.strftime("%m%d")
.to_string(),
"0301"
@@ -661,6 +735,7 @@ mod tests {
assert_eq!(
parse_datetime("2024-01-31 + 1 month")
.unwrap()
.expect_in_range()
.strftime("%Y-%m-%dT%H:%M:%S")
.to_string(),
"2024-03-02T00:00:00",
@@ -669,6 +744,7 @@ mod tests {
assert_eq!(
parse_datetime("2024-02-29 + 1 month")
.unwrap()
.expect_in_range()
.strftime("%Y-%m-%dT%H:%M:%S")
.to_string(),
"2024-03-29T00:00:00",
@@ -685,19 +761,31 @@ mod tests {
let input = "0000-03-02 00:00:00";
assert_eq!(
input,
parse_datetime(input).unwrap().strftime(FMT).to_string()
parse_datetime(input)
.unwrap()
.expect_in_range()
.strftime(FMT)
.to_string()
);
let input = "2621-03-10 00:00:00";
assert_eq!(
input,
parse_datetime(input).unwrap().strftime(FMT).to_string()
parse_datetime(input)
.unwrap()
.expect_in_range()
.strftime(FMT)
.to_string()
);
let input = "1038-03-10 00:00:00";
assert_eq!(
input,
parse_datetime(input).unwrap().strftime(FMT).to_string()
parse_datetime(input)
.unwrap()
.expect_in_range()
.strftime(FMT)
.to_string()
);
}
}
+27 -11
View File
@@ -4,7 +4,31 @@
use std::env;
use jiff::Zoned;
use parse_datetime::{parse_datetime, parse_datetime_at_date};
use parse_datetime::{parse_datetime, parse_datetime_at_date, ParsedDateTime};
fn format_offset_colon(seconds: i32) -> String {
let sign = if seconds < 0 { '-' } else { '+' };
let abs = seconds.unsigned_abs();
let h = abs / 3600;
let m = (abs % 3600) / 60;
format!("{sign}{h:02}:{m:02}")
}
fn format_for_assert(parsed: ParsedDateTime) -> String {
match parsed {
ParsedDateTime::InRange(z) => z.strftime("%Y-%m-%d %H:%M:%S%:z").to_string(),
ParsedDateTime::Extended(dt) => format!(
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}{}",
dt.year,
dt.month,
dt.day,
dt.hour,
dt.minute,
dt.second,
format_offset_colon(dt.offset_seconds)
),
}
}
pub fn check_absolute(input: &str, expected: &str) {
env::set_var("TZ", "UTC0");
@@ -14,11 +38,7 @@ pub fn check_absolute(input: &str, expected: &str) {
Err(e) => panic!("Failed to parse date from value '{input}': {e}"),
};
assert_eq!(
parsed.strftime("%Y-%m-%d %H:%M:%S%:z").to_string(),
expected,
"Input value: {input}"
);
assert_eq!(format_for_assert(parsed), expected, "Input value: {input}");
}
pub fn check_relative(now: Zoned, input: &str, expected: &str) {
@@ -29,9 +49,5 @@ pub fn check_relative(now: Zoned, input: &str, expected: &str) {
Err(e) => panic!("Failed to parse date from value '{input}': {e}"),
};
assert_eq!(
parsed.strftime("%Y-%m-%d %H:%M:%S%:z").to_string(),
expected,
"Input value: {input}"
);
assert_eq!(format_for_assert(parsed), expected, "Input value: {input}");
}
+27
View File
@@ -0,0 +1,27 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
mod common;
use common::{check_absolute, check_relative};
use jiff::{civil::DateTime, tz::TimeZone};
#[test]
fn helper_formats_extended_values() {
check_absolute("10000-01-01", "10000-01-01 00:00:00+00:00");
}
#[test]
fn helper_formats_extended_rollover() {
check_absolute("9999-12-31 +1 day", "10000-01-01 00:00:00+00:00");
}
#[test]
fn helper_formats_relative_extended_values() {
let base = "2000-01-01 00:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::UTC)
.unwrap();
check_relative(base, "10000-01-01 +1 day", "10000-01-02 00:00:00+00:00");
}
+2 -1
View File
@@ -20,6 +20,7 @@ pub fn check_time(input: &str, expected: &str, format: &str, base: Option<Zoned>
Ok(v) => v,
Err(e) => panic!("Failed to parse time from value '{input}': {e}"),
}
.expect_in_range()
.with_time_zone(TimeZone::UTC);
assert_eq!(
@@ -263,7 +264,7 @@ fn test_time_seconds_ago(#[case] input: &str) {
let result = parse_datetime::parse_datetime(input);
assert!(
result.is_ok(),
"Input string '{input}', produced {result:?}, instead of Ok(Zoned)"
"Input string '{input}', produced {result:?}, instead of Ok(ParsedDateTime)"
);
}