From 7d80124f102a28bb72d659045dcfb2bf9c6c3f8c Mon Sep 17 00:00:00 2001 From: abhishekpradhan Date: Sat, 14 Feb 2026 12:42:17 -0500 Subject: [PATCH 1/3] parse_datetime: add extended-year parsing with GNU-compatible rules --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 24 ++-- src/extended.rs | 311 +++++++++++++++++++++++++++++++++++++++++++ src/items/builder.rs | 183 ++++++++++++++++++++++++- src/items/date.rs | 16 +-- src/items/epoch.rs | 2 +- src/items/mod.rs | 32 +++-- src/items/offset.rs | 9 ++ src/items/year.rs | 39 +++--- src/lib.rs | 113 ++++++++++++---- tests/common/mod.rs | 38 ++++-- tests/date.rs | 25 +++- 13 files changed, 704 insertions(+), 92 deletions(-) create mode 100644 src/extended.rs diff --git a/Cargo.lock b/Cargo.lock index 0ccfce1..2098b9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -158,7 +158,7 @@ dependencies = [ [[package]] name = "parse_datetime" -version = "0.13.3" +version = "0.14.0" dependencies = [ "jiff", "num-traits", diff --git a/Cargo.toml b/Cargo.toml index 825ebc7..27f8047 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "parse_datetime" description = "parsing human-readable time strings and converting them to a DateTime" -version = "0.13.3" +version = "0.14.0" edition = "2021" license = "MIT" repository = "https://github.com/uutils/parse_datetime" diff --git a/README.md b/README.md index 9497ef6..e64a77d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/extended.rs b/src/extended.rs new file mode 100644 index 0000000..25233eb --- /dev/null +++ b/src/extended.rs @@ -0,0 +1,311 @@ +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use crate::GNU_MAX_YEAR; + +const SECONDS_PER_DAY: i64 = 86_400; + +/// A date-time representation that supports years beyond Jiff's civil range. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtendedDateTime { + pub year: u32, + pub month: u8, + pub day: u8, + pub hour: u8, + pub minute: u8, + pub second: u8, + pub nanosecond: u32, + /// Offset in seconds east of UTC. + pub offset_seconds: i32, +} + +impl ExtendedDateTime { + pub fn new( + year: u32, + month: u8, + day: u8, + hour: u8, + minute: u8, + second: u8, + nanosecond: u32, + offset_seconds: i32, + ) -> Result { + if year > GNU_MAX_YEAR { + return Err("year must be no greater than 2147485547"); + } + if !(1..=12).contains(&month) { + return Err("month must be between 1 and 12"); + } + let dim = days_in_month(year, month); + if day == 0 || day > dim { + return Err("day is not valid for the given month"); + } + if hour > 23 { + return Err("hour must be between 0 and 23"); + } + if minute > 59 { + return Err("minute must be between 0 and 59"); + } + if second > 59 { + return Err("second must be between 0 and 59"); + } + if nanosecond >= 1_000_000_000 { + return Err("nanosecond must be between 0 and 999999999"); + } + if offset_seconds.unsigned_abs() > 24 * 3600 { + return Err("offset must be between -24:00 and +24:00"); + } + Ok(Self { + year, + month, + day, + hour, + minute, + second, + nanosecond, + offset_seconds, + }) + } + + pub fn from_unix_seconds( + unix_seconds: i64, + nanosecond: u32, + offset_seconds: i32, + ) -> Result { + if nanosecond >= 1_000_000_000 { + return Err("nanosecond must be between 0 and 999999999"); + } + if offset_seconds.unsigned_abs() > 24 * 3600 { + return Err("offset must be between -24:00 and +24:00"); + } + + let local = unix_seconds + .checked_add(offset_seconds as i64) + .ok_or("timestamp overflow")?; + let days = local.div_euclid(SECONDS_PER_DAY); + let sod = local.rem_euclid(SECONDS_PER_DAY); + let (year, month, day) = civil_from_days(days); + let year: u32 = year.try_into().map_err(|_| "year must be non-negative")?; + let month: u8 = month.try_into().map_err(|_| "month is invalid")?; + let day: u8 = day.try_into().map_err(|_| "day is invalid")?; + let hour = (sod / 3600) as u8; + let minute = ((sod % 3600) / 60) as u8; + let second = (sod % 60) as u8; + + Self::new( + year, + month, + day, + hour, + minute, + second, + nanosecond, + offset_seconds, + ) + } + + pub fn with_date(self, year: u32, month: u8, day: u8) -> Result { + Self::new( + year, + month, + day, + self.hour, + self.minute, + self.second, + self.nanosecond, + self.offset_seconds, + ) + } + + pub fn with_time( + self, + hour: u8, + minute: u8, + second: u8, + nanosecond: u32, + ) -> Result { + Self::new( + self.year, + self.month, + self.day, + hour, + minute, + second, + nanosecond, + self.offset_seconds, + ) + } + + pub fn with_offset(self, offset_seconds: i32) -> Result { + Self::new( + self.year, + self.month, + self.day, + self.hour, + self.minute, + self.second, + self.nanosecond, + offset_seconds, + ) + } + + pub fn checked_add_days(self, days: i64) -> Result { + let unix = self.unix_seconds(); + let delta = days + .checked_mul(SECONDS_PER_DAY) + .ok_or("seconds overflow")?; + let unix = unix.checked_add(delta).ok_or("timestamp overflow")?; + Self::from_unix_seconds(unix, self.nanosecond, self.offset_seconds) + } + + pub fn checked_add_hours(self, hours: i64) -> Result { + self.checked_add_seconds(hours.checked_mul(3600).ok_or("seconds overflow")?, 0) + } + + pub fn checked_add_minutes(self, minutes: i64) -> Result { + self.checked_add_seconds(minutes.checked_mul(60).ok_or("seconds overflow")?, 0) + } + + pub fn checked_add_seconds( + self, + delta_seconds: i64, + delta_nanoseconds: u32, + ) -> Result { + let mut unix = self.unix_seconds(); + unix = unix + .checked_add(delta_seconds) + .ok_or("timestamp overflow")?; + let mut ns = self + .nanosecond + .checked_add(delta_nanoseconds) + .ok_or("nanosecond overflow")?; + if ns >= 1_000_000_000 { + unix = unix.checked_add(1).ok_or("timestamp overflow")?; + ns -= 1_000_000_000; + } + Self::from_unix_seconds(unix, ns, self.offset_seconds) + } + + pub fn checked_add_years(self, years: i32) -> Result { + let year = (self.year as i64) + .checked_add(years as i64) + .ok_or("year overflow")?; + let year: u32 = year.try_into().map_err(|_| "year must be non-negative")?; + if year > GNU_MAX_YEAR { + return Err("year must be no greater than 2147485547"); + } + + // GNU-compatible clamp for leap day. + let month = self.month; + let day = if month == 2 && self.day == 29 && !is_leap_year(year) { + 28 + } else { + self.day + }; + self.with_date(year, month, day) + } + + pub fn day_of_year(&self) -> u16 { + let mut doy = 0u16; + let mut month = 1u8; + while month < self.month { + doy += days_in_month(self.year, month) as u16; + month += 1; + } + doy + self.day as u16 + } + + /// Returns Unix timestamp seconds for this date-time. + pub fn unix_seconds(&self) -> i64 { + let days = days_from_civil(self.year as i64, self.month as i64, self.day as i64); + let daytime = (self.hour as i64) * 3600 + (self.minute as i64) * 60 + (self.second as i64); + days * SECONDS_PER_DAY + daytime - self.offset_seconds as i64 + } + + /// Weekday in range 0..=6 where 0=Sunday, 1=Monday, ..., 6=Saturday. + pub fn weekday_sunday0(&self) -> u8 { + let days = days_from_civil(self.year as i64, self.month as i64, self.day as i64); + (((days + 4).rem_euclid(7) + 7).rem_euclid(7)) as u8 + } + + /// Weekday in range 0..=6 where 0=Monday, 1=Tuesday, ..., 6=Sunday. + pub fn weekday_monday0(&self) -> u8 { + (self.weekday_sunday0() + 6) % 7 + } +} + +pub fn is_leap_year(year: u32) -> bool { + (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) +} + +pub fn days_in_month(year: u32, month: u8) -> u8 { + match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 => { + if is_leap_year(year) { + 29 + } else { + 28 + } + } + _ => 0, + } +} + +/// Howard Hinnant's civil date to days algorithm. +/// +/// Returns the number of days since 1970-01-01 in the proleptic Gregorian +/// calendar with astronomical year numbering. +fn days_from_civil(year: i64, month: i64, day: i64) -> i64 { + let y = year - if month <= 2 { 1 } else { 0 }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; + let mp = month + if month > 2 { -3 } else { 9 }; + let doy = (153 * mp + 2) / 5 + day - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146097 + doe - 719468 +} + +/// Inverse of `days_from_civil`. +fn civil_from_days(days: i64) -> (i64, i64, i64) { + let z = days + 719468; + let era = if z >= 0 { z } else { z - 146096 } / 146097; + let doe = z - era * 146097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let mut year = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = mp + if mp < 10 { 3 } else { -9 }; + year += if month <= 2 { 1 } else { 0 }; + (year, month, day) +} + +#[cfg(test)] +mod tests { + use super::{is_leap_year, ExtendedDateTime}; + + #[test] + fn leap_year_rules() { + assert!(is_leap_year(2000)); + assert!(!is_leap_year(2100)); + assert!(is_leap_year(10000)); + } + + #[test] + fn unix_seconds_large_year() { + let dt = ExtendedDateTime::new(10000, 1, 1, 0, 0, 0, 0, 0).unwrap(); + assert_eq!(dt.unix_seconds(), 253402300800); + } + + #[test] + fn unix_roundtrip() { + let dt = + ExtendedDateTime::new(2147485547, 12, 31, 23, 59, 59, 123_456_789, 5 * 3600).unwrap(); + let unix = dt.unix_seconds(); + let rt = + ExtendedDateTime::from_unix_seconds(unix, dt.nanosecond, dt.offset_seconds).unwrap(); + assert_eq!(dt, rt); + } +} diff --git a/src/items/builder.rs b/src/items/builder.rs index 5fe841c..1f05538 100644 --- a/src/items/builder.rs +++ b/src/items/builder.rs @@ -4,6 +4,7 @@ use jiff::{civil, Span, Zoned}; use super::{date, epoch, error, offset, relative, time, weekday, year, Item}; +use crate::{ExtendedDateTime, ParsedDateTime}; /// The builder is used to construct a DateTime object from various components. /// The parser creates a `DateTimeBuilder` object with the parsed components, @@ -185,7 +186,13 @@ impl DateTimeBuilder { /// - c. Apply weekday (e.g., "next Friday" or "last Monday"). /// - d. Apply relative adjustments (e.g., "+3 days", "-2 months"). /// - e. Apply final fixed offset if present. - pub(super) fn build(self) -> Result { + pub(super) fn build(self) -> Result { + if let Some(date) = self.date.as_ref() { + if date.year.unwrap_or(0) > 9999 { + return self.build_extended(); + } + } + // 1. Choose the base instant. // If a TZ="..." prefix was parsed, it should override the base's timezone // while keeping the base's timestamp for relative date calculations. @@ -200,7 +207,9 @@ impl DateTimeBuilder { // 2. Absolute timestamp override everything else. if let Some(ts) = self.timestamp { let ts = jiff::Timestamp::try_from(ts)?; - return Ok(ts.to_zoned(base.offset().to_time_zone())); + return Ok(ParsedDateTime::InRange( + ts.to_zoned(base.offset().to_time_zone()), + )); } // 3. Determine whether to truncate the time of day. @@ -221,7 +230,7 @@ impl DateTimeBuilder { let d: civil::Date = if date.year.is_some() { date.try_into()? } else { - date.with_year(dt.date().year() as u16).try_into()? + date.with_year(dt.date().year() as u32).try_into()? }; dt = dt.with().date(d).build()?; } @@ -296,7 +305,173 @@ impl DateTimeBuilder { dt = dt.datetime().to_zoned((&offset).try_into()?)?; } - Ok(dt) + Ok(ParsedDateTime::InRange(dt)) + } + + fn build_extended(self) -> Result { + if self.timestamp.is_some() { + return Err("timestamp cannot be combined with large years".into()); + } + let DateTimeBuilder { + base, + timestamp: _, + date, + time, + weekday, + offset, + timezone, + relative, + } = self; + + let has_timezone = timezone.is_some(); + let base = match (base, timezone) { + (Some(b), Some(tz)) => b.timestamp().to_zoned(tz), + (Some(b), None) => b, + (None, Some(tz)) => jiff::Timestamp::now().to_zoned(tz), + (None, None) => Zoned::now(), + }; + let rule_tz = base.time_zone().clone(); + + let need_midnight = date.is_some() + || time.is_some() + || weekday.is_some() + || offset.is_some() + || has_timezone; + let mut dt = ExtendedDateTime::new( + u32::try_from(base.year()).map_err(|_| "year must be non-negative")?, + base.month() as u8, + base.day() as u8, + if need_midnight { 0 } else { base.hour() as u8 }, + if need_midnight { + 0 + } else { + base.minute() as u8 + }, + if need_midnight { + 0 + } else { + base.second() as u8 + }, + if need_midnight { + 0 + } else { + base.subsec_nanosecond() as u32 + }, + base.offset().seconds(), + )?; + + if let Some(date) = date { + let year = date.year.unwrap_or(dt.year); + dt = dt.with_date(year, date.month, date.day)?; + } + + let had_time_item = time.is_some(); + let has_time_offset = time.as_ref().and_then(|t| t.offset.as_ref()).is_some(); + if let Some(time) = time { + if let Some(offset) = time.offset { + dt = dt.with_offset(offset.total_seconds())?; + } + dt = dt.with_time(time.hour, time.minute, time.second, time.nanosecond)?; + } + + if let Some(weekday::Weekday { + mut offset, + day: target_day, + }) = weekday + { + if !had_time_item { + dt = dt.with_time(0, 0, 0, 0)?; + } + + let target = weekday_monday0(target_day); + if dt.weekday_monday0() != target && offset > 0 { + offset -= 1; + } + + let delta = (target as i32 - dt.weekday_monday0() as i32).rem_euclid(7) + + offset.checked_mul(7).ok_or("multiplication overflow")?; + dt = dt.checked_add_days(delta as i64)?; + } + + for rel in relative { + dt = match rel { + relative::Relative::Years(years) => dt.checked_add_years(years)?, + relative::Relative::Months(months) => { + // Mirror the in-range path: treat one "month" as the + // current month's day count. + let month_len = i64::from(crate::extended::days_in_month(dt.year, dt.month)); + dt.checked_add_days( + month_len + .checked_mul(i64::from(months)) + .ok_or("multiplication overflow")?, + )? + } + relative::Relative::Days(days) => dt.checked_add_days(days as i64)?, + relative::Relative::Hours(hours) => dt.checked_add_hours(hours as i64)?, + relative::Relative::Minutes(minutes) => dt.checked_add_minutes(minutes as i64)?, + relative::Relative::Seconds(seconds, nanos) => { + dt.checked_add_seconds(seconds, nanos)? + } + }; + } + + if !has_time_offset && offset.is_none() { + let offset_seconds = resolve_rule_offset_for_extended(&rule_tz, &dt)?; + dt = dt.with_offset(offset_seconds)?; + } + + if let Some(offset) = offset { + let (offset, hour_adjustment) = offset.normalize(); + dt = dt.checked_add_hours(hour_adjustment as i64)?; + dt = dt.with_offset(offset.total_seconds())?; + } + + if dt.year <= 9999 { + let ts = jiff::Timestamp::new(dt.unix_seconds(), dt.nanosecond as i32)?; + let tz = jiff::tz::Offset::from_seconds(dt.offset_seconds)?.to_time_zone(); + return Ok(ParsedDateTime::InRange(ts.to_zoned(tz))); + } + + Ok(ParsedDateTime::Extended(dt)) + } +} + +fn surrogate_year_for_rules(year: u32) -> i16 { + if year <= 9_999 { + year as i16 + } else { + const BASE: u32 = 9_600; + (BASE + ((year - BASE) % 400)) as i16 + } +} + +fn resolve_rule_offset_for_extended( + tz: &jiff::tz::TimeZone, + dt: &ExtendedDateTime, +) -> Result { + let surrogate_year = surrogate_year_for_rules(dt.year); + let surrogate_dt = civil::DateTime::new( + surrogate_year, + dt.month as i8, + dt.day as i8, + dt.hour as i8, + dt.minute as i8, + dt.second as i8, + dt.nanosecond as i32, + )?; + let zoned = tz.to_ambiguous_zoned(surrogate_dt).compatible()?; + Ok(zoned.offset().seconds()) +} + +fn weekday_monday0(day: weekday::Day) -> u8 { + match day { + weekday::Day::Monday => 0, + weekday::Day::Tuesday => 1, + weekday::Day::Wednesday => 2, + weekday::Day::Thursday => 3, + weekday::Day::Friday => 4, + weekday::Day::Saturday => 5, + weekday::Day::Sunday => 6, } } diff --git a/src/items/date.rs b/src/items/date.rs index 5de3351..97cfdef 100644 --- a/src/items/date.rs +++ b/src/items/date.rs @@ -44,11 +44,11 @@ use super::{ pub(crate) struct Date { pub(crate) day: u8, pub(crate) month: u8, - pub(crate) year: Option, + pub(crate) year: Option, } impl Date { - pub(super) fn with_year(self, year: u16) -> Self { + pub(super) fn with_year(self, year: u32) -> Self { Date { day: self.day, month: self.month, @@ -118,12 +118,12 @@ impl TryFrom for jiff::civil::Date { type Error = &'static str; fn try_from(date: Date) -> Result { - jiff::civil::Date::new( - date.year.unwrap_or(0) as i16, - date.month as i8, - date.day as i8, - ) - .map_err(|_| "date is not valid") + let year = date.year.unwrap_or(0); + let year: i16 = year + .try_into() + .map_err(|_| "date year is outside the supported range")?; + jiff::civil::Date::new(year, date.month as i8, date.day as i8) + .map_err(|_| "date is not valid") } } diff --git a/src/items/epoch.rs b/src/items/epoch.rs index 60edc8f..6661fd0 100644 --- a/src/items/epoch.rs +++ b/src/items/epoch.rs @@ -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, diff --git a/src/items/mod.rs b/src/items/mod.rs index deb790c..6064d84 100644 --- a/src/items/mod.rs +++ b/src/items/mod.rs @@ -48,6 +48,7 @@ mod primitive; pub(crate) mod error; +use crate::ParsedDateTime; use jiff::Zoned; use primitive::space; use winnow::{ @@ -75,7 +76,10 @@ enum Item { /// 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 + Clone>(base: Zoned, input: S) -> Result { +pub(crate) fn parse_at_date + Clone>( + base: Zoned, + input: S, +) -> Result { match parse(&mut input.as_ref()) { Ok(builder) => builder.set_base(base).build(), Err(e) => Err(e.into()), @@ -84,7 +88,7 @@ pub(crate) fn parse_at_date + Clone>(base: Zoned, input: S) -> Res /// 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 + Clone>(input: S) -> Result { +pub(crate) fn parse_at_local + Clone>(input: S) -> Result { 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()), @@ -277,12 +281,13 @@ fn expect_error(input: &mut &str, reason: &'static str) -> ErrMode #[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 { @@ -408,13 +413,14 @@ mod tests { let result = parse(&mut "2025-05-19 @1690466034"); assert!(result.is_err()); - // Pure number as year (too large). + // Pure number as year (large years are accepted). let result = parse(&mut "jul 18 12:30 10000"); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("year must be no greater than 9999")); + assert!(result.is_ok()); + let built = result.unwrap().build().unwrap(); + match built { + ParsedDateTime::Extended(dt) => assert_eq!(dt.year, 10000), + ParsedDateTime::InRange(_) => panic!("expected an extended datetime"), + } // Pure number as time (too long). let result = parse(&mut "01:02 12345"); @@ -563,11 +569,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::() + .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::() + .unwrap(), ), ] { assert_eq!(parse_build(input), expected, "{input}"); diff --git a/src/items/offset.rs b/src/items/offset.rs index fe92b0f..91fce80 100644 --- a/src/items/offset.rs +++ b/src/items/offset.rs @@ -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 { diff --git a/src/items/year.rs b/src/items/year.rs index 564d12d..aaeff5b 100644 --- a/src/items/year.rs +++ b/src/items/year.rs @@ -12,13 +12,15 @@ use winnow::{stream::AsChar, token::take_while, ModalResult, Parser}; +use crate::GNU_MAX_YEAR; + use super::primitive::s; // TODO: Leverage `TryFrom` trait. -pub(super) fn year_from_str(year_str: &str) -> Result { +pub(super) fn year_from_str(year_str: &str) -> Result { let mut year = year_str - .parse::() - .map_err(|_| "year must be a valid u16 number")?; + .parse::() + .map_err(|_| "year must be a valid u32 number")?; // If year is 68 or smaller, then 2000 is added to it; otherwise, if year // is less than 100, then 1900 is added to it. @@ -34,13 +36,8 @@ pub(super) fn year_from_str(year_str: &str) -> Result { } } - // 2147485547 is the maximum value accepted by GNU, but chrono only - // behaves like GNU for years in the range: [0, 9999], so we keep in the - // range [0, 9999]. - // - // See discussion in https://github.com/uutils/parse_datetime/issues/160. - if year > 9999 { - return Err("year must be no greater than 9999"); + if year > GNU_MAX_YEAR { + return Err("year must be no greater than 2147485547"); } Ok(year) @@ -57,18 +54,20 @@ mod tests { #[test] fn test_year() { // 2-characters are converted to 19XX/20XX - assert_eq!(year_from_str("10").unwrap(), 2010u16); - assert_eq!(year_from_str("68").unwrap(), 2068u16); - assert_eq!(year_from_str("69").unwrap(), 1969u16); - assert_eq!(year_from_str("99").unwrap(), 1999u16); + assert_eq!(year_from_str("10").unwrap(), 2010u32); + assert_eq!(year_from_str("68").unwrap(), 2068u32); + assert_eq!(year_from_str("69").unwrap(), 1969u32); + assert_eq!(year_from_str("99").unwrap(), 1999u32); // 3,4-characters are converted verbatim - assert_eq!(year_from_str("468").unwrap(), 468u16); - assert_eq!(year_from_str("469").unwrap(), 469u16); - assert_eq!(year_from_str("1568").unwrap(), 1568u16); - assert_eq!(year_from_str("1569").unwrap(), 1569u16); + assert_eq!(year_from_str("468").unwrap(), 468u32); + assert_eq!(year_from_str("469").unwrap(), 469u32); + assert_eq!(year_from_str("1568").unwrap(), 1568u32); + assert_eq!(year_from_str("1569").unwrap(), 1569u32); - // years greater than 9999 are not accepted - assert!(year_from_str("10000").is_err()); + // very large years are accepted up to GNU's upper bound + assert_eq!(year_from_str("10000").unwrap(), 10000u32); + assert_eq!(year_from_str("2147485547").unwrap(), 2_147_485_547u32); + assert!(year_from_str("2147485548").is_err()); } } diff --git a/src/lib.rs b/src/lib.rs index d24dbd9..92388c3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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 @@ -10,11 +11,69 @@ //! use std::error::Error; use std::fmt::{self, Display}; +use std::ops::Deref; use jiff::Zoned; +mod extended; mod items; +pub use extended::ExtendedDateTime; + +/// 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 { + 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 Deref for ParsedDateTime { + type Target = Zoned; + + fn deref(&self) -> &Self::Target { + self.as_zoned() + .expect("ParsedDateTime is not representable as jiff::Zoned") + } +} + +impl PartialEq 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, @@ -41,8 +100,8 @@ impl From 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,17 +110,21 @@ impl From 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 +/// * `Ok(ParsedDateTime)` - If the input string can be parsed as a time /// * `Err(ParseDateTimeError)` - If the input string cannot be parsed as a /// relative time /// @@ -69,11 +132,13 @@ impl From for ParseDateTimeError { /// /// This function will return `Err(ParseDateTimeError::InvalidInput)` if the /// input string cannot be parsed as a relative time. -pub fn parse_datetime + Clone>(input: S) -> Result { +pub fn parse_datetime + Clone>( + input: S, +) -> Result { 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,20 +150,20 @@ pub fn parse_datetime + Clone>(input: S) -> Result 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 +/// * `Ok(ParsedDateTime)` - If the input string can be parsed as a time /// * `Err(ParseDateTimeError)` - If the input string cannot be parsed as a /// relative time /// @@ -109,7 +174,7 @@ pub fn parse_datetime + Clone>(input: S) -> Result + Clone>( date: Zoned, input: S, -) -> Result { +) -> Result { items::parse_at_date(date, input).map_err(|e| e.into()) } @@ -215,14 +280,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); } } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 9173dc6..1348e68 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -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}"); } diff --git a/tests/date.rs b/tests/date.rs index 0f12cc0..1ade427 100644 --- a/tests/date.rs +++ b/tests/date.rs @@ -29,13 +29,11 @@ use common::{check_absolute, check_relative}; #[case::year_100("100-11-14", "0100-11-14 00:00:00+00:00")] #[case::year_999("999-11-14", "0999-11-14 00:00:00+00:00")] #[case::year_9999("9999-11-14", "9999-11-14 00:00:00+00:00")] -/** TODO: https://github.com/uutils/parse_datetime/issues/160 #[case::year_10000("10000-12-31", "10000-12-31 00:00:00+00:00")] #[case::year_100000("100000-12-31", "100000-12-31 00:00:00+00:00")] #[case::year_1000000("1000000-12-31", "1000000-12-31 00:00:00+00:00")] #[case::year_10000000("10000000-12-31", "10000000-12-31 00:00:00+00:00")] #[case::max_date("2147485547-12-31", "2147485547-12-31 00:00:00+00:00")] -**/ #[case::long_month_in_the_middle("14 November 2022", "2022-11-14 00:00:00+00:00")] #[case::long_month_in_the_middle_lowercase("14 november 2022", "2022-11-14 00:00:00+00:00")] #[case::long_month_in_the_middle_uppercase("14 NOVEMBER 2022", "2022-11-14 00:00:00+00:00")] @@ -75,6 +73,29 @@ fn test_absolute_date_numeric(#[case] input: &str, #[case] expected: &str) { check_absolute(input, expected); } +#[test] +fn test_out_of_range_large_year_is_rejected() { + assert!(parse_datetime::parse_datetime("2147485548-01-01").is_err()); +} + +#[test] +fn test_large_year_relative_adjustments() { + check_absolute("10000-01-31 +1 month", "10000-03-02 00:00:00+00:00"); + check_absolute("10000-01-01 +3 days", "10000-01-04 00:00:00+00:00"); +} + +#[test] +fn test_large_year_tz_rule() { + check_absolute( + r#"TZ="UTC+5" 10000-01-01 00:00"#, + "10000-01-01 00:00:00-05:00", + ); + check_absolute( + r#"TZ="America/New_York" 10000-07-01 00:00"#, + "10000-07-01 00:00:00-04:00", + ); +} + #[rstest] #[case::us_style("11/14", 2022, "2022-11-14 00:00:00+00:00")] #[case::alphabetical_full_month_in_front("november 14", 2022, "2022-11-14 00:00:00+00:00")] From 85bede18e4e6ca5c6b67317356948f080d95a049 Mon Sep 17 00:00:00 2001 From: abhishekpradhan Date: Sat, 14 Feb 2026 13:33:54 -0500 Subject: [PATCH 2/3] Revert "parse_datetime: add extended-year parsing with GNU-compatible rules" This reverts commit 7d80124f102a28bb72d659045dcfb2bf9c6c3f8c. --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 24 ++-- src/extended.rs | 311 ------------------------------------------- src/items/builder.rs | 183 +------------------------ src/items/date.rs | 16 +-- src/items/epoch.rs | 2 +- src/items/mod.rs | 32 ++--- src/items/offset.rs | 9 -- src/items/year.rs | 39 +++--- src/lib.rs | 113 ++++------------ tests/common/mod.rs | 38 ++---- tests/date.rs | 25 +--- 13 files changed, 92 insertions(+), 704 deletions(-) delete mode 100644 src/extended.rs diff --git a/Cargo.lock b/Cargo.lock index 2098b9c..0ccfce1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -158,7 +158,7 @@ dependencies = [ [[package]] name = "parse_datetime" -version = "0.14.0" +version = "0.13.3" dependencies = [ "jiff", "num-traits", diff --git a/Cargo.toml b/Cargo.toml index 27f8047..825ebc7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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.13.3" edition = "2021" license = "MIT" repository = "https://github.com/uutils/parse_datetime" diff --git a/README.md b/README.md index e64a77d..9497ef6 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,7 @@ [![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. +A Rust crate for parsing human-readable relative time strings and human-readable datetime strings and converting them to a jiff's `Zoned` object. ## Features @@ -27,28 +26,25 @@ Then, import the crate and use the `parse_datetime_at_date` function: ```rs use jiff::{ToSpan, Zoned}; -use parse_datetime::{parse_datetime_at_date, ParsedDateTime}; +use parse_datetime::parse_datetime_at_date; let now = Zoned::now(); let after = parse_datetime_at_date(now.clone(), "+3 days"); -match after.unwrap() { - ParsedDateTime::InRange(z) => assert_eq!(now.checked_add(3.days()).unwrap(), z), - ParsedDateTime::Extended(_) => unreachable!("unexpected for this input"), -} +assert_eq!( + now.checked_add(3.days()).unwrap(), + after.unwrap() +); ``` For DateTime parsing, import the `parse_datetime` function: ```rs use jiff::{civil::{date, time} ,Zoned}; -use parse_datetime::{parse_datetime, ParsedDateTime}; +use parse_datetime::parse_datetime; let dt = parse_datetime("2021-02-14 06:37:47"); -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"), -} +assert_eq!(dt.unwrap(), Zoned::now().with().date(date(2021, 2, 14)).time(time(6, 37, 47, 0)).build().unwrap()); ``` ### Supported Formats @@ -73,9 +69,7 @@ The `parse_datetime` and `parse_datetime_at_date` functions support absolute dat The `parse_datetime` and `parse_datetime_at_date` function return: -- `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`) +- `Ok(Zoned)` - If the input string can be parsed as a `Zoned` object - `Err(ParseDateTimeError::InvalidInput)` - If the input string cannot be parsed ## Fuzzer diff --git a/src/extended.rs b/src/extended.rs deleted file mode 100644 index 25233eb..0000000 --- a/src/extended.rs +++ /dev/null @@ -1,311 +0,0 @@ -// For the full copyright and license information, please view the LICENSE -// file that was distributed with this source code. - -use crate::GNU_MAX_YEAR; - -const SECONDS_PER_DAY: i64 = 86_400; - -/// A date-time representation that supports years beyond Jiff's civil range. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ExtendedDateTime { - pub year: u32, - pub month: u8, - pub day: u8, - pub hour: u8, - pub minute: u8, - pub second: u8, - pub nanosecond: u32, - /// Offset in seconds east of UTC. - pub offset_seconds: i32, -} - -impl ExtendedDateTime { - pub fn new( - year: u32, - month: u8, - day: u8, - hour: u8, - minute: u8, - second: u8, - nanosecond: u32, - offset_seconds: i32, - ) -> Result { - if year > GNU_MAX_YEAR { - return Err("year must be no greater than 2147485547"); - } - if !(1..=12).contains(&month) { - return Err("month must be between 1 and 12"); - } - let dim = days_in_month(year, month); - if day == 0 || day > dim { - return Err("day is not valid for the given month"); - } - if hour > 23 { - return Err("hour must be between 0 and 23"); - } - if minute > 59 { - return Err("minute must be between 0 and 59"); - } - if second > 59 { - return Err("second must be between 0 and 59"); - } - if nanosecond >= 1_000_000_000 { - return Err("nanosecond must be between 0 and 999999999"); - } - if offset_seconds.unsigned_abs() > 24 * 3600 { - return Err("offset must be between -24:00 and +24:00"); - } - Ok(Self { - year, - month, - day, - hour, - minute, - second, - nanosecond, - offset_seconds, - }) - } - - pub fn from_unix_seconds( - unix_seconds: i64, - nanosecond: u32, - offset_seconds: i32, - ) -> Result { - if nanosecond >= 1_000_000_000 { - return Err("nanosecond must be between 0 and 999999999"); - } - if offset_seconds.unsigned_abs() > 24 * 3600 { - return Err("offset must be between -24:00 and +24:00"); - } - - let local = unix_seconds - .checked_add(offset_seconds as i64) - .ok_or("timestamp overflow")?; - let days = local.div_euclid(SECONDS_PER_DAY); - let sod = local.rem_euclid(SECONDS_PER_DAY); - let (year, month, day) = civil_from_days(days); - let year: u32 = year.try_into().map_err(|_| "year must be non-negative")?; - let month: u8 = month.try_into().map_err(|_| "month is invalid")?; - let day: u8 = day.try_into().map_err(|_| "day is invalid")?; - let hour = (sod / 3600) as u8; - let minute = ((sod % 3600) / 60) as u8; - let second = (sod % 60) as u8; - - Self::new( - year, - month, - day, - hour, - minute, - second, - nanosecond, - offset_seconds, - ) - } - - pub fn with_date(self, year: u32, month: u8, day: u8) -> Result { - Self::new( - year, - month, - day, - self.hour, - self.minute, - self.second, - self.nanosecond, - self.offset_seconds, - ) - } - - pub fn with_time( - self, - hour: u8, - minute: u8, - second: u8, - nanosecond: u32, - ) -> Result { - Self::new( - self.year, - self.month, - self.day, - hour, - minute, - second, - nanosecond, - self.offset_seconds, - ) - } - - pub fn with_offset(self, offset_seconds: i32) -> Result { - Self::new( - self.year, - self.month, - self.day, - self.hour, - self.minute, - self.second, - self.nanosecond, - offset_seconds, - ) - } - - pub fn checked_add_days(self, days: i64) -> Result { - let unix = self.unix_seconds(); - let delta = days - .checked_mul(SECONDS_PER_DAY) - .ok_or("seconds overflow")?; - let unix = unix.checked_add(delta).ok_or("timestamp overflow")?; - Self::from_unix_seconds(unix, self.nanosecond, self.offset_seconds) - } - - pub fn checked_add_hours(self, hours: i64) -> Result { - self.checked_add_seconds(hours.checked_mul(3600).ok_or("seconds overflow")?, 0) - } - - pub fn checked_add_minutes(self, minutes: i64) -> Result { - self.checked_add_seconds(minutes.checked_mul(60).ok_or("seconds overflow")?, 0) - } - - pub fn checked_add_seconds( - self, - delta_seconds: i64, - delta_nanoseconds: u32, - ) -> Result { - let mut unix = self.unix_seconds(); - unix = unix - .checked_add(delta_seconds) - .ok_or("timestamp overflow")?; - let mut ns = self - .nanosecond - .checked_add(delta_nanoseconds) - .ok_or("nanosecond overflow")?; - if ns >= 1_000_000_000 { - unix = unix.checked_add(1).ok_or("timestamp overflow")?; - ns -= 1_000_000_000; - } - Self::from_unix_seconds(unix, ns, self.offset_seconds) - } - - pub fn checked_add_years(self, years: i32) -> Result { - let year = (self.year as i64) - .checked_add(years as i64) - .ok_or("year overflow")?; - let year: u32 = year.try_into().map_err(|_| "year must be non-negative")?; - if year > GNU_MAX_YEAR { - return Err("year must be no greater than 2147485547"); - } - - // GNU-compatible clamp for leap day. - let month = self.month; - let day = if month == 2 && self.day == 29 && !is_leap_year(year) { - 28 - } else { - self.day - }; - self.with_date(year, month, day) - } - - pub fn day_of_year(&self) -> u16 { - let mut doy = 0u16; - let mut month = 1u8; - while month < self.month { - doy += days_in_month(self.year, month) as u16; - month += 1; - } - doy + self.day as u16 - } - - /// Returns Unix timestamp seconds for this date-time. - pub fn unix_seconds(&self) -> i64 { - let days = days_from_civil(self.year as i64, self.month as i64, self.day as i64); - let daytime = (self.hour as i64) * 3600 + (self.minute as i64) * 60 + (self.second as i64); - days * SECONDS_PER_DAY + daytime - self.offset_seconds as i64 - } - - /// Weekday in range 0..=6 where 0=Sunday, 1=Monday, ..., 6=Saturday. - pub fn weekday_sunday0(&self) -> u8 { - let days = days_from_civil(self.year as i64, self.month as i64, self.day as i64); - (((days + 4).rem_euclid(7) + 7).rem_euclid(7)) as u8 - } - - /// Weekday in range 0..=6 where 0=Monday, 1=Tuesday, ..., 6=Sunday. - pub fn weekday_monday0(&self) -> u8 { - (self.weekday_sunday0() + 6) % 7 - } -} - -pub fn is_leap_year(year: u32) -> bool { - (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) -} - -pub fn days_in_month(year: u32, month: u8) -> u8 { - match month { - 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, - 4 | 6 | 9 | 11 => 30, - 2 => { - if is_leap_year(year) { - 29 - } else { - 28 - } - } - _ => 0, - } -} - -/// Howard Hinnant's civil date to days algorithm. -/// -/// Returns the number of days since 1970-01-01 in the proleptic Gregorian -/// calendar with astronomical year numbering. -fn days_from_civil(year: i64, month: i64, day: i64) -> i64 { - let y = year - if month <= 2 { 1 } else { 0 }; - let era = if y >= 0 { y } else { y - 399 } / 400; - let yoe = y - era * 400; - let mp = month + if month > 2 { -3 } else { 9 }; - let doy = (153 * mp + 2) / 5 + day - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - era * 146097 + doe - 719468 -} - -/// Inverse of `days_from_civil`. -fn civil_from_days(days: i64) -> (i64, i64, i64) { - let z = days + 719468; - let era = if z >= 0 { z } else { z - 146096 } / 146097; - let doe = z - era * 146097; - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; - let mut year = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let day = doy - (153 * mp + 2) / 5 + 1; - let month = mp + if mp < 10 { 3 } else { -9 }; - year += if month <= 2 { 1 } else { 0 }; - (year, month, day) -} - -#[cfg(test)] -mod tests { - use super::{is_leap_year, ExtendedDateTime}; - - #[test] - fn leap_year_rules() { - assert!(is_leap_year(2000)); - assert!(!is_leap_year(2100)); - assert!(is_leap_year(10000)); - } - - #[test] - fn unix_seconds_large_year() { - let dt = ExtendedDateTime::new(10000, 1, 1, 0, 0, 0, 0, 0).unwrap(); - assert_eq!(dt.unix_seconds(), 253402300800); - } - - #[test] - fn unix_roundtrip() { - let dt = - ExtendedDateTime::new(2147485547, 12, 31, 23, 59, 59, 123_456_789, 5 * 3600).unwrap(); - let unix = dt.unix_seconds(); - let rt = - ExtendedDateTime::from_unix_seconds(unix, dt.nanosecond, dt.offset_seconds).unwrap(); - assert_eq!(dt, rt); - } -} diff --git a/src/items/builder.rs b/src/items/builder.rs index 1f05538..5fe841c 100644 --- a/src/items/builder.rs +++ b/src/items/builder.rs @@ -4,7 +4,6 @@ use jiff::{civil, Span, Zoned}; use super::{date, epoch, error, offset, relative, time, weekday, year, Item}; -use crate::{ExtendedDateTime, ParsedDateTime}; /// The builder is used to construct a DateTime object from various components. /// The parser creates a `DateTimeBuilder` object with the parsed components, @@ -186,13 +185,7 @@ impl DateTimeBuilder { /// - c. Apply weekday (e.g., "next Friday" or "last Monday"). /// - d. Apply relative adjustments (e.g., "+3 days", "-2 months"). /// - e. Apply final fixed offset if present. - pub(super) fn build(self) -> Result { - if let Some(date) = self.date.as_ref() { - if date.year.unwrap_or(0) > 9999 { - return self.build_extended(); - } - } - + pub(super) fn build(self) -> Result { // 1. Choose the base instant. // If a TZ="..." prefix was parsed, it should override the base's timezone // while keeping the base's timestamp for relative date calculations. @@ -207,9 +200,7 @@ impl DateTimeBuilder { // 2. Absolute timestamp override everything else. if let Some(ts) = self.timestamp { let ts = jiff::Timestamp::try_from(ts)?; - return Ok(ParsedDateTime::InRange( - ts.to_zoned(base.offset().to_time_zone()), - )); + return Ok(ts.to_zoned(base.offset().to_time_zone())); } // 3. Determine whether to truncate the time of day. @@ -230,7 +221,7 @@ impl DateTimeBuilder { let d: civil::Date = if date.year.is_some() { date.try_into()? } else { - date.with_year(dt.date().year() as u32).try_into()? + date.with_year(dt.date().year() as u16).try_into()? }; dt = dt.with().date(d).build()?; } @@ -305,173 +296,7 @@ impl DateTimeBuilder { dt = dt.datetime().to_zoned((&offset).try_into()?)?; } - Ok(ParsedDateTime::InRange(dt)) - } - - fn build_extended(self) -> Result { - if self.timestamp.is_some() { - return Err("timestamp cannot be combined with large years".into()); - } - let DateTimeBuilder { - base, - timestamp: _, - date, - time, - weekday, - offset, - timezone, - relative, - } = self; - - let has_timezone = timezone.is_some(); - let base = match (base, timezone) { - (Some(b), Some(tz)) => b.timestamp().to_zoned(tz), - (Some(b), None) => b, - (None, Some(tz)) => jiff::Timestamp::now().to_zoned(tz), - (None, None) => Zoned::now(), - }; - let rule_tz = base.time_zone().clone(); - - let need_midnight = date.is_some() - || time.is_some() - || weekday.is_some() - || offset.is_some() - || has_timezone; - let mut dt = ExtendedDateTime::new( - u32::try_from(base.year()).map_err(|_| "year must be non-negative")?, - base.month() as u8, - base.day() as u8, - if need_midnight { 0 } else { base.hour() as u8 }, - if need_midnight { - 0 - } else { - base.minute() as u8 - }, - if need_midnight { - 0 - } else { - base.second() as u8 - }, - if need_midnight { - 0 - } else { - base.subsec_nanosecond() as u32 - }, - base.offset().seconds(), - )?; - - if let Some(date) = date { - let year = date.year.unwrap_or(dt.year); - dt = dt.with_date(year, date.month, date.day)?; - } - - let had_time_item = time.is_some(); - let has_time_offset = time.as_ref().and_then(|t| t.offset.as_ref()).is_some(); - if let Some(time) = time { - if let Some(offset) = time.offset { - dt = dt.with_offset(offset.total_seconds())?; - } - dt = dt.with_time(time.hour, time.minute, time.second, time.nanosecond)?; - } - - if let Some(weekday::Weekday { - mut offset, - day: target_day, - }) = weekday - { - if !had_time_item { - dt = dt.with_time(0, 0, 0, 0)?; - } - - let target = weekday_monday0(target_day); - if dt.weekday_monday0() != target && offset > 0 { - offset -= 1; - } - - let delta = (target as i32 - dt.weekday_monday0() as i32).rem_euclid(7) - + offset.checked_mul(7).ok_or("multiplication overflow")?; - dt = dt.checked_add_days(delta as i64)?; - } - - for rel in relative { - dt = match rel { - relative::Relative::Years(years) => dt.checked_add_years(years)?, - relative::Relative::Months(months) => { - // Mirror the in-range path: treat one "month" as the - // current month's day count. - let month_len = i64::from(crate::extended::days_in_month(dt.year, dt.month)); - dt.checked_add_days( - month_len - .checked_mul(i64::from(months)) - .ok_or("multiplication overflow")?, - )? - } - relative::Relative::Days(days) => dt.checked_add_days(days as i64)?, - relative::Relative::Hours(hours) => dt.checked_add_hours(hours as i64)?, - relative::Relative::Minutes(minutes) => dt.checked_add_minutes(minutes as i64)?, - relative::Relative::Seconds(seconds, nanos) => { - dt.checked_add_seconds(seconds, nanos)? - } - }; - } - - if !has_time_offset && offset.is_none() { - let offset_seconds = resolve_rule_offset_for_extended(&rule_tz, &dt)?; - dt = dt.with_offset(offset_seconds)?; - } - - if let Some(offset) = offset { - let (offset, hour_adjustment) = offset.normalize(); - dt = dt.checked_add_hours(hour_adjustment as i64)?; - dt = dt.with_offset(offset.total_seconds())?; - } - - if dt.year <= 9999 { - let ts = jiff::Timestamp::new(dt.unix_seconds(), dt.nanosecond as i32)?; - let tz = jiff::tz::Offset::from_seconds(dt.offset_seconds)?.to_time_zone(); - return Ok(ParsedDateTime::InRange(ts.to_zoned(tz))); - } - - Ok(ParsedDateTime::Extended(dt)) - } -} - -fn surrogate_year_for_rules(year: u32) -> i16 { - if year <= 9_999 { - year as i16 - } else { - const BASE: u32 = 9_600; - (BASE + ((year - BASE) % 400)) as i16 - } -} - -fn resolve_rule_offset_for_extended( - tz: &jiff::tz::TimeZone, - dt: &ExtendedDateTime, -) -> Result { - let surrogate_year = surrogate_year_for_rules(dt.year); - let surrogate_dt = civil::DateTime::new( - surrogate_year, - dt.month as i8, - dt.day as i8, - dt.hour as i8, - dt.minute as i8, - dt.second as i8, - dt.nanosecond as i32, - )?; - let zoned = tz.to_ambiguous_zoned(surrogate_dt).compatible()?; - Ok(zoned.offset().seconds()) -} - -fn weekday_monday0(day: weekday::Day) -> u8 { - match day { - weekday::Day::Monday => 0, - weekday::Day::Tuesday => 1, - weekday::Day::Wednesday => 2, - weekday::Day::Thursday => 3, - weekday::Day::Friday => 4, - weekday::Day::Saturday => 5, - weekday::Day::Sunday => 6, + Ok(dt) } } diff --git a/src/items/date.rs b/src/items/date.rs index 97cfdef..5de3351 100644 --- a/src/items/date.rs +++ b/src/items/date.rs @@ -44,11 +44,11 @@ use super::{ pub(crate) struct Date { pub(crate) day: u8, pub(crate) month: u8, - pub(crate) year: Option, + pub(crate) year: Option, } impl Date { - pub(super) fn with_year(self, year: u32) -> Self { + pub(super) fn with_year(self, year: u16) -> Self { Date { day: self.day, month: self.month, @@ -118,12 +118,12 @@ impl TryFrom for jiff::civil::Date { type Error = &'static str; fn try_from(date: Date) -> Result { - let year = date.year.unwrap_or(0); - let year: i16 = year - .try_into() - .map_err(|_| "date year is outside the supported range")?; - jiff::civil::Date::new(year, date.month as i8, date.day as i8) - .map_err(|_| "date is not valid") + jiff::civil::Date::new( + date.year.unwrap_or(0) as i16, + date.month as i8, + date.day as i8, + ) + .map_err(|_| "date is not valid") } } diff --git a/src/items/epoch.rs b/src/items/epoch.rs index 6661fd0..60edc8f 100644 --- a/src/items/epoch.rs +++ b/src/items/epoch.rs @@ -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, Clone, Copy)] +#[derive(Debug, PartialEq)] pub(super) struct Timestamp { second: i64, nanosecond: u32, diff --git a/src/items/mod.rs b/src/items/mod.rs index 6064d84..deb790c 100644 --- a/src/items/mod.rs +++ b/src/items/mod.rs @@ -48,7 +48,6 @@ mod primitive; pub(crate) mod error; -use crate::ParsedDateTime; use jiff::Zoned; use primitive::space; use winnow::{ @@ -76,10 +75,7 @@ enum Item { /// 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 + Clone>( - base: Zoned, - input: S, -) -> Result { +pub(crate) fn parse_at_date + Clone>(base: Zoned, input: S) -> Result { match parse(&mut input.as_ref()) { Ok(builder) => builder.set_base(base).build(), Err(e) => Err(e.into()), @@ -88,7 +84,7 @@ pub(crate) fn parse_at_date + Clone>( /// 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 + Clone>(input: S) -> Result { +pub(crate) fn parse_at_local + Clone>(input: S) -> Result { 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()), @@ -281,13 +277,12 @@ fn expect_error(input: &mut &str, reason: &'static str) -> ErrMode #[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().expect_in_range() + builder.set_base(base).build().unwrap() } fn at_utc(builder: DateTimeBuilder) -> Zoned { @@ -413,14 +408,13 @@ mod tests { let result = parse(&mut "2025-05-19 @1690466034"); assert!(result.is_err()); - // Pure number as year (large years are accepted). + // Pure number as year (too large). let result = parse(&mut "jul 18 12:30 10000"); - assert!(result.is_ok()); - let built = result.unwrap().build().unwrap(); - match built { - ParsedDateTime::Extended(dt) => assert_eq!(dt.year, 10000), - ParsedDateTime::InRange(_) => panic!("expected an extended datetime"), - } + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("year must be no greater than 9999")); // Pure number as time (too long). let result = parse(&mut "01:02 12345"); @@ -569,15 +563,11 @@ 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().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().unwrap(), ), ] { assert_eq!(parse_build(input), expected, "{input}"); diff --git a/src/items/offset.rs b/src/items/offset.rs index 91fce80..fe92b0f 100644 --- a/src/items/offset.rs +++ b/src/items/offset.rs @@ -108,15 +108,6 @@ 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 { diff --git a/src/items/year.rs b/src/items/year.rs index aaeff5b..564d12d 100644 --- a/src/items/year.rs +++ b/src/items/year.rs @@ -12,15 +12,13 @@ use winnow::{stream::AsChar, token::take_while, ModalResult, Parser}; -use crate::GNU_MAX_YEAR; - use super::primitive::s; // TODO: Leverage `TryFrom` trait. -pub(super) fn year_from_str(year_str: &str) -> Result { +pub(super) fn year_from_str(year_str: &str) -> Result { let mut year = year_str - .parse::() - .map_err(|_| "year must be a valid u32 number")?; + .parse::() + .map_err(|_| "year must be a valid u16 number")?; // If year is 68 or smaller, then 2000 is added to it; otherwise, if year // is less than 100, then 1900 is added to it. @@ -36,8 +34,13 @@ pub(super) fn year_from_str(year_str: &str) -> Result { } } - if year > GNU_MAX_YEAR { - return Err("year must be no greater than 2147485547"); + // 2147485547 is the maximum value accepted by GNU, but chrono only + // behaves like GNU for years in the range: [0, 9999], so we keep in the + // range [0, 9999]. + // + // See discussion in https://github.com/uutils/parse_datetime/issues/160. + if year > 9999 { + return Err("year must be no greater than 9999"); } Ok(year) @@ -54,20 +57,18 @@ mod tests { #[test] fn test_year() { // 2-characters are converted to 19XX/20XX - assert_eq!(year_from_str("10").unwrap(), 2010u32); - assert_eq!(year_from_str("68").unwrap(), 2068u32); - assert_eq!(year_from_str("69").unwrap(), 1969u32); - assert_eq!(year_from_str("99").unwrap(), 1999u32); + assert_eq!(year_from_str("10").unwrap(), 2010u16); + assert_eq!(year_from_str("68").unwrap(), 2068u16); + assert_eq!(year_from_str("69").unwrap(), 1969u16); + assert_eq!(year_from_str("99").unwrap(), 1999u16); // 3,4-characters are converted verbatim - assert_eq!(year_from_str("468").unwrap(), 468u32); - assert_eq!(year_from_str("469").unwrap(), 469u32); - assert_eq!(year_from_str("1568").unwrap(), 1568u32); - assert_eq!(year_from_str("1569").unwrap(), 1569u32); + assert_eq!(year_from_str("468").unwrap(), 468u16); + assert_eq!(year_from_str("469").unwrap(), 469u16); + assert_eq!(year_from_str("1568").unwrap(), 1568u16); + assert_eq!(year_from_str("1569").unwrap(), 1569u16); - // very large years are accepted up to GNU's upper bound - assert_eq!(year_from_str("10000").unwrap(), 10000u32); - assert_eq!(year_from_str("2147485547").unwrap(), 2_147_485_547u32); - assert!(year_from_str("2147485548").is_err()); + // years greater than 9999 are not accepted + assert!(year_from_str("10000").is_err()); } } diff --git a/src/lib.rs b/src/lib.rs index 92388c3..d24dbd9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,6 @@ // 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. +//! A Rust crate for parsing human-readable relative time strings and human-readable datetime strings and converting them to a `DateTime`. //! The function supports the following formats for time: //! //! * ISO formats @@ -11,69 +10,11 @@ //! use std::error::Error; use std::fmt::{self, Display}; -use std::ops::Deref; use jiff::Zoned; -mod extended; mod items; -pub use extended::ExtendedDateTime; - -/// 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 { - 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 Deref for ParsedDateTime { - type Target = Zoned; - - fn deref(&self) -> &Self::Target { - self.as_zoned() - .expect("ParsedDateTime is not representable as jiff::Zoned") - } -} - -impl PartialEq 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, @@ -100,8 +41,8 @@ impl From for ParseDateTimeError { } } -/// Parses a time string and returns a [`ParsedDateTime`] representing the -/// absolute time of the string. +/// Parses a time string and returns a `Zoned` object representing the absolute +/// time of the string. /// /// # Arguments /// @@ -110,21 +51,17 @@ impl From for ParseDateTimeError { /// # Examples /// /// ``` -/// use parse_datetime::{parse_datetime, ParsedDateTime}; +/// use jiff::Zoned; +/// use parse_datetime::parse_datetime; /// /// let time = parse_datetime("2023-06-03 12:00:01Z").unwrap(); -/// 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"), -/// } +/// assert_eq!(time.strftime("%F %T").to_string(), "2023-06-03 12:00:01"); /// ``` /// /// /// # Returns /// -/// * `Ok(ParsedDateTime)` - If the input string can be parsed as a time +/// * `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 /// @@ -132,13 +69,11 @@ impl From for ParseDateTimeError { /// /// This function will return `Err(ParseDateTimeError::InvalidInput)` if the /// input string cannot be parsed as a relative time. -pub fn parse_datetime + Clone>( - input: S, -) -> Result { +pub fn parse_datetime + Clone>(input: S) -> Result { items::parse_at_local(input).map_err(|e| e.into()) } -/// Parses a time string at a specific date and returns a [`ParsedDateTime`] +/// Parses a time string at a specific date and returns a `Zoned` object /// representing the absolute time of the string. /// /// # Arguments @@ -150,20 +85,20 @@ pub fn parse_datetime + Clone>( /// /// ``` /// use jiff::Zoned; -/// use parse_datetime::{parse_datetime_at_date, ParsedDateTime}; +/// use parse_datetime::parse_datetime_at_date; /// /// let now = Zoned::now(); /// let after = parse_datetime_at_date(now, "2024-09-13UTC +3 days").unwrap(); /// -/// match after { -/// ParsedDateTime::InRange(z) => assert_eq!("2024-09-16", z.strftime("%F").to_string()), -/// ParsedDateTime::Extended(_) => unreachable!("unexpected for this input"), -/// } +/// assert_eq!( +/// "2024-09-16", +/// after.strftime("%F").to_string() +/// ); /// ``` /// /// # Returns /// -/// * `Ok(ParsedDateTime)` - If the input string can be parsed as a time +/// * `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 /// @@ -174,7 +109,7 @@ pub fn parse_datetime + Clone>( pub fn parse_datetime_at_date + Clone>( date: Zoned, input: S, -) -> Result { +) -> Result { items::parse_at_date(date, input).map_err(|e| e.into()) } @@ -280,14 +215,14 @@ mod tests { .build() .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); + 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()); } } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 1348e68..9173dc6 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -4,31 +4,7 @@ use std::env; use jiff::Zoned; -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) - ), - } -} +use parse_datetime::{parse_datetime, parse_datetime_at_date}; pub fn check_absolute(input: &str, expected: &str) { env::set_var("TZ", "UTC0"); @@ -38,7 +14,11 @@ pub fn check_absolute(input: &str, expected: &str) { Err(e) => panic!("Failed to parse date from value '{input}': {e}"), }; - assert_eq!(format_for_assert(parsed), expected, "Input value: {input}"); + assert_eq!( + parsed.strftime("%Y-%m-%d %H:%M:%S%:z").to_string(), + expected, + "Input value: {input}" + ); } pub fn check_relative(now: Zoned, input: &str, expected: &str) { @@ -49,5 +29,9 @@ pub fn check_relative(now: Zoned, input: &str, expected: &str) { Err(e) => panic!("Failed to parse date from value '{input}': {e}"), }; - assert_eq!(format_for_assert(parsed), expected, "Input value: {input}"); + assert_eq!( + parsed.strftime("%Y-%m-%d %H:%M:%S%:z").to_string(), + expected, + "Input value: {input}" + ); } diff --git a/tests/date.rs b/tests/date.rs index 1ade427..0f12cc0 100644 --- a/tests/date.rs +++ b/tests/date.rs @@ -29,11 +29,13 @@ use common::{check_absolute, check_relative}; #[case::year_100("100-11-14", "0100-11-14 00:00:00+00:00")] #[case::year_999("999-11-14", "0999-11-14 00:00:00+00:00")] #[case::year_9999("9999-11-14", "9999-11-14 00:00:00+00:00")] +/** TODO: https://github.com/uutils/parse_datetime/issues/160 #[case::year_10000("10000-12-31", "10000-12-31 00:00:00+00:00")] #[case::year_100000("100000-12-31", "100000-12-31 00:00:00+00:00")] #[case::year_1000000("1000000-12-31", "1000000-12-31 00:00:00+00:00")] #[case::year_10000000("10000000-12-31", "10000000-12-31 00:00:00+00:00")] #[case::max_date("2147485547-12-31", "2147485547-12-31 00:00:00+00:00")] +**/ #[case::long_month_in_the_middle("14 November 2022", "2022-11-14 00:00:00+00:00")] #[case::long_month_in_the_middle_lowercase("14 november 2022", "2022-11-14 00:00:00+00:00")] #[case::long_month_in_the_middle_uppercase("14 NOVEMBER 2022", "2022-11-14 00:00:00+00:00")] @@ -73,29 +75,6 @@ fn test_absolute_date_numeric(#[case] input: &str, #[case] expected: &str) { check_absolute(input, expected); } -#[test] -fn test_out_of_range_large_year_is_rejected() { - assert!(parse_datetime::parse_datetime("2147485548-01-01").is_err()); -} - -#[test] -fn test_large_year_relative_adjustments() { - check_absolute("10000-01-31 +1 month", "10000-03-02 00:00:00+00:00"); - check_absolute("10000-01-01 +3 days", "10000-01-04 00:00:00+00:00"); -} - -#[test] -fn test_large_year_tz_rule() { - check_absolute( - r#"TZ="UTC+5" 10000-01-01 00:00"#, - "10000-01-01 00:00:00-05:00", - ); - check_absolute( - r#"TZ="America/New_York" 10000-07-01 00:00"#, - "10000-07-01 00:00:00-04:00", - ); -} - #[rstest] #[case::us_style("11/14", 2022, "2022-11-14 00:00:00+00:00")] #[case::alphabetical_full_month_in_front("november 14", 2022, "2022-11-14 00:00:00+00:00")] From 22976b89b820a824ec7b6909dc21bee3132a7942 Mon Sep 17 00:00:00 2001 From: abhishekpradhan Date: Sat, 14 Feb 2026 13:33:25 -0500 Subject: [PATCH 3/3] items: widen year parsing groundwork for GNU max year --- src/items/builder.rs | 2 +- src/items/date.rs | 16 ++++++++-------- src/items/mod.rs | 8 ++------ src/items/year.rs | 39 +++++++++++++++++++-------------------- 4 files changed, 30 insertions(+), 35 deletions(-) diff --git a/src/items/builder.rs b/src/items/builder.rs index 5fe841c..de44f3d 100644 --- a/src/items/builder.rs +++ b/src/items/builder.rs @@ -221,7 +221,7 @@ impl DateTimeBuilder { let d: civil::Date = if date.year.is_some() { date.try_into()? } else { - date.with_year(dt.date().year() as u16).try_into()? + date.with_year(dt.date().year() as u32).try_into()? }; dt = dt.with().date(d).build()?; } diff --git a/src/items/date.rs b/src/items/date.rs index 5de3351..97cfdef 100644 --- a/src/items/date.rs +++ b/src/items/date.rs @@ -44,11 +44,11 @@ use super::{ pub(crate) struct Date { pub(crate) day: u8, pub(crate) month: u8, - pub(crate) year: Option, + pub(crate) year: Option, } impl Date { - pub(super) fn with_year(self, year: u16) -> Self { + pub(super) fn with_year(self, year: u32) -> Self { Date { day: self.day, month: self.month, @@ -118,12 +118,12 @@ impl TryFrom for jiff::civil::Date { type Error = &'static str; fn try_from(date: Date) -> Result { - jiff::civil::Date::new( - date.year.unwrap_or(0) as i16, - date.month as i8, - date.day as i8, - ) - .map_err(|_| "date is not valid") + let year = date.year.unwrap_or(0); + let year: i16 = year + .try_into() + .map_err(|_| "date year is outside the supported range")?; + jiff::civil::Date::new(year, date.month as i8, date.day as i8) + .map_err(|_| "date is not valid") } } diff --git a/src/items/mod.rs b/src/items/mod.rs index deb790c..9646dc6 100644 --- a/src/items/mod.rs +++ b/src/items/mod.rs @@ -408,13 +408,9 @@ mod tests { let result = parse(&mut "2025-05-19 @1690466034"); assert!(result.is_err()); - // Pure number as year (too large). + // Pure number as year (large years are parsed successfully). let result = parse(&mut "jul 18 12:30 10000"); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("year must be no greater than 9999")); + assert!(result.is_ok()); // Pure number as time (too long). let result = parse(&mut "01:02 12345"); diff --git a/src/items/year.rs b/src/items/year.rs index 564d12d..489b0a7 100644 --- a/src/items/year.rs +++ b/src/items/year.rs @@ -14,11 +14,13 @@ use winnow::{stream::AsChar, token::take_while, ModalResult, Parser}; use super::primitive::s; +const GNU_MAX_YEAR: u32 = 2_147_485_547; + // TODO: Leverage `TryFrom` trait. -pub(super) fn year_from_str(year_str: &str) -> Result { +pub(super) fn year_from_str(year_str: &str) -> Result { let mut year = year_str - .parse::() - .map_err(|_| "year must be a valid u16 number")?; + .parse::() + .map_err(|_| "year must be a valid u32 number")?; // If year is 68 or smaller, then 2000 is added to it; otherwise, if year // is less than 100, then 1900 is added to it. @@ -34,13 +36,8 @@ pub(super) fn year_from_str(year_str: &str) -> Result { } } - // 2147485547 is the maximum value accepted by GNU, but chrono only - // behaves like GNU for years in the range: [0, 9999], so we keep in the - // range [0, 9999]. - // - // See discussion in https://github.com/uutils/parse_datetime/issues/160. - if year > 9999 { - return Err("year must be no greater than 9999"); + if year > GNU_MAX_YEAR { + return Err("year must be no greater than 2147485547"); } Ok(year) @@ -57,18 +54,20 @@ mod tests { #[test] fn test_year() { // 2-characters are converted to 19XX/20XX - assert_eq!(year_from_str("10").unwrap(), 2010u16); - assert_eq!(year_from_str("68").unwrap(), 2068u16); - assert_eq!(year_from_str("69").unwrap(), 1969u16); - assert_eq!(year_from_str("99").unwrap(), 1999u16); + assert_eq!(year_from_str("10").unwrap(), 2010u32); + assert_eq!(year_from_str("68").unwrap(), 2068u32); + assert_eq!(year_from_str("69").unwrap(), 1969u32); + assert_eq!(year_from_str("99").unwrap(), 1999u32); // 3,4-characters are converted verbatim - assert_eq!(year_from_str("468").unwrap(), 468u16); - assert_eq!(year_from_str("469").unwrap(), 469u16); - assert_eq!(year_from_str("1568").unwrap(), 1568u16); - assert_eq!(year_from_str("1569").unwrap(), 1569u16); + assert_eq!(year_from_str("468").unwrap(), 468u32); + assert_eq!(year_from_str("469").unwrap(), 469u32); + assert_eq!(year_from_str("1568").unwrap(), 1568u32); + assert_eq!(year_from_str("1569").unwrap(), 1569u32); - // years greater than 9999 are not accepted - assert!(year_from_str("10000").is_err()); + // very large years are accepted up to GNU's upper bound + assert_eq!(year_from_str("10000").unwrap(), 10000u32); + assert_eq!(year_from_str("2147485547").unwrap(), 2_147_485_547u32); + assert!(year_from_str("2147485548").is_err()); } }