diff --git a/Cargo.lock b/Cargo.lock index 2098b9c..1b9df51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -158,7 +158,7 @@ dependencies = [ [[package]] name = "parse_datetime" -version = "0.14.0" +version = "0.15.0" dependencies = [ "jiff", "num-traits", diff --git a/Cargo.toml b/Cargo.toml index 27f8047..4b26622 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.15.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/fuzz/Cargo.lock b/fuzz/Cargo.lock index ab9b040..db1ae3d 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -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" diff --git a/src/extended.rs b/src/extended.rs new file mode 100644 index 0000000..cabf035 --- /dev/null +++ b/src/extended.rs @@ -0,0 +1,724 @@ +// 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, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DateParts { + pub year: u32, + pub month: u8, + pub day: u8, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TimeParts { + pub hour: u8, + pub minute: u8, + pub second: u8, + pub nanosecond: u32, +} + +impl ExtendedDateTime { + pub fn new( + date: DateParts, + time: TimeParts, + offset_seconds: i32, + ) -> Result { + let DateParts { year, month, day } = date; + let TimeParts { + hour, + minute, + second, + nanosecond, + } = time; + 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( + DateParts { year, month, day }, + TimeParts { + hour, + minute, + second, + nanosecond, + }, + offset_seconds, + ) + } + + pub fn with_date(self, year: u32, month: u8, day: u8) -> Result { + Self::new( + DateParts { year, month, day }, + TimeParts { + hour: self.hour, + minute: self.minute, + second: self.second, + nanosecond: self.nanosecond, + }, + self.offset_seconds, + ) + } + + pub fn with_time( + self, + hour: u8, + minute: u8, + second: u8, + nanosecond: u32, + ) -> Result { + Self::new( + DateParts { + year: self.year, + month: self.month, + day: self.day, + }, + TimeParts { + hour, + minute, + second, + nanosecond, + }, + self.offset_seconds, + ) + } + + pub fn with_offset(self, offset_seconds: i32) -> Result { + Self::new( + DateParts { + year: self.year, + month: self.month, + day: self.day, + }, + TimeParts { + hour: self.hour, + minute: self.minute, + second: self.second, + nanosecond: 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"); + } + + let month = self.month; + let max_day = days_in_month(year, month); + let day = self.day.min(max_day); + let overflow_days = self.day.saturating_sub(day); + let next = self.with_date(year, month, day)?; + if overflow_days == 0 { + Ok(next) + } else { + next.checked_add_days(i64::from(overflow_days)) + } + } + + pub fn checked_add_months(self, months: i32) -> Result { + let base_month = i64::from(self.month) - 1; + let total = i64::from(self.year) + .checked_mul(12) + .and_then(|v| v.checked_add(base_month)) + .and_then(|v| v.checked_add(i64::from(months))) + .ok_or("month overflow")?; + if total < 0 { + return Err("year must be non-negative"); + } + + let year = u32::try_from(total / 12).map_err(|_| "year overflow")?; + if year > GNU_MAX_YEAR { + return Err("year must be no greater than 2147485547"); + } + let month = u8::try_from((total % 12) + 1).map_err(|_| "month overflow")?; + + let max_day = days_in_month(year, month); + let day = self.day.min(max_day); + let overflow_days = self.day.saturating_sub(day); + let next = self.with_date(year, month, day)?; + if overflow_days == 0 { + Ok(next) + } else { + next.checked_add_days(i64::from(overflow_days)) + } + } + + 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 crate::GNU_MAX_YEAR; + + use super::{is_leap_year, DateParts, ExtendedDateTime, TimeParts}; + + #[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( + DateParts { + year: 10000, + month: 1, + day: 1, + }, + TimeParts { + hour: 0, + minute: 0, + second: 0, + nanosecond: 0, + }, + 0, + ) + .unwrap(); + assert_eq!(dt.unix_seconds(), 253402300800); + } + + #[test] + fn unix_roundtrip() { + let dt = ExtendedDateTime::new( + DateParts { + year: 2147485547, + month: 12, + day: 31, + }, + TimeParts { + hour: 23, + minute: 59, + second: 59, + nanosecond: 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); + } + + #[test] + fn add_years_overflows_like_gnu() { + let dt = ExtendedDateTime::new( + DateParts { + year: 10000, + month: 2, + day: 29, + }, + TimeParts { + hour: 0, + minute: 0, + second: 0, + nanosecond: 0, + }, + 0, + ) + .unwrap(); + let actual = dt.checked_add_years(1).unwrap(); + assert_eq!((actual.year, actual.month, actual.day), (10001, 3, 1)); + } + + #[test] + fn add_months_overflows_like_gnu() { + let dt = ExtendedDateTime::new( + DateParts { + year: 10000, + month: 1, + day: 31, + }, + TimeParts { + hour: 0, + minute: 0, + second: 0, + nanosecond: 0, + }, + 0, + ) + .unwrap(); + + let plus_2 = dt.clone().checked_add_months(2).unwrap(); + assert_eq!((plus_2.year, plus_2.month, plus_2.day), (10000, 3, 31)); + + let plus_3 = dt.checked_add_months(3).unwrap(); + assert_eq!((plus_3.year, plus_3.month, plus_3.day), (10000, 5, 1)); + } + + #[test] + fn constructor_validation_errors() { + assert!(ExtendedDateTime::new( + DateParts { + year: GNU_MAX_YEAR + 1, + month: 1, + day: 1 + }, + TimeParts { + hour: 0, + minute: 0, + second: 0, + nanosecond: 0 + }, + 0 + ) + .is_err()); + assert!(ExtendedDateTime::new( + DateParts { + year: 10000, + month: 13, + day: 1 + }, + TimeParts { + hour: 0, + minute: 0, + second: 0, + nanosecond: 0 + }, + 0 + ) + .is_err()); + assert!(ExtendedDateTime::new( + DateParts { + year: 10000, + month: 2, + day: 30 + }, + TimeParts { + hour: 0, + minute: 0, + second: 0, + nanosecond: 0 + }, + 0 + ) + .is_err()); + assert!(ExtendedDateTime::new( + DateParts { + year: 10000, + month: 1, + day: 1 + }, + TimeParts { + hour: 24, + minute: 0, + second: 0, + nanosecond: 0 + }, + 0 + ) + .is_err()); + assert!(ExtendedDateTime::new( + DateParts { + year: 10000, + month: 1, + day: 1 + }, + TimeParts { + hour: 0, + minute: 60, + second: 0, + nanosecond: 0 + }, + 0 + ) + .is_err()); + assert!(ExtendedDateTime::new( + DateParts { + year: 10000, + month: 1, + day: 1 + }, + TimeParts { + hour: 0, + minute: 0, + second: 60, + nanosecond: 0 + }, + 0 + ) + .is_err()); + assert!(ExtendedDateTime::new( + DateParts { + year: 10000, + month: 1, + day: 1 + }, + TimeParts { + hour: 0, + minute: 0, + second: 0, + nanosecond: 1_000_000_000 + }, + 0 + ) + .is_err()); + assert!(ExtendedDateTime::new( + DateParts { + year: 10000, + month: 1, + day: 1 + }, + TimeParts { + hour: 0, + minute: 0, + second: 0, + nanosecond: 0 + }, + 86_401 + ) + .is_err()); + } + + #[test] + fn from_unix_seconds_validation() { + assert!(ExtendedDateTime::from_unix_seconds(0, 1_000_000_000, 0).is_err()); + assert!(ExtendedDateTime::from_unix_seconds(0, 0, 86_401).is_err()); + } + + #[test] + fn checked_add_seconds_carries_nanoseconds() { + let dt = ExtendedDateTime::new( + DateParts { + year: 10000, + month: 1, + day: 1, + }, + TimeParts { + hour: 0, + minute: 0, + second: 0, + nanosecond: 900_000_000, + }, + 0, + ) + .unwrap(); + let actual = dt.checked_add_seconds(0, 200_000_000).unwrap(); + assert_eq!( + ( + actual.year, + actual.month, + actual.day, + actual.hour, + actual.minute, + actual.second, + actual.nanosecond + ), + (10000, 1, 1, 0, 0, 1, 100_000_000) + ); + } + + #[test] + fn checked_add_months_validation() { + let dt = ExtendedDateTime::new( + DateParts { + year: 0, + month: 1, + day: 1, + }, + TimeParts { + hour: 0, + minute: 0, + second: 0, + nanosecond: 0, + }, + 0, + ) + .unwrap(); + assert!(dt.checked_add_months(-1).is_err()); + + let dt = ExtendedDateTime::new( + DateParts { + year: GNU_MAX_YEAR, + month: 12, + day: 31, + }, + TimeParts { + hour: 0, + minute: 0, + second: 0, + nanosecond: 0, + }, + 0, + ) + .unwrap(); + assert!(dt.checked_add_months(1).is_err()); + } + + #[test] + fn checked_add_years_validation() { + let dt = ExtendedDateTime::new( + DateParts { + year: 0, + month: 1, + day: 1, + }, + TimeParts { + hour: 0, + minute: 0, + second: 0, + nanosecond: 0, + }, + 0, + ) + .unwrap(); + assert!(dt.checked_add_years(-1).is_err()); + + let dt = ExtendedDateTime::new( + DateParts { + year: GNU_MAX_YEAR, + month: 1, + day: 1, + }, + TimeParts { + hour: 0, + minute: 0, + second: 0, + nanosecond: 0, + }, + 0, + ) + .unwrap(); + assert!(dt.checked_add_years(1).is_err()); + } + + #[test] + fn checked_add_seconds_validation() { + let dt = ExtendedDateTime::new( + DateParts { + year: 10000, + month: 1, + day: 1, + }, + TimeParts { + hour: 0, + minute: 0, + second: 0, + nanosecond: 0, + }, + 0, + ) + .unwrap(); + assert!(dt.checked_add_seconds(i64::MAX, 0).is_err()); + } + + #[test] + fn day_of_year_and_weekday_helpers() { + let dt = ExtendedDateTime::new( + DateParts { + year: 10000, + month: 12, + day: 31, + }, + TimeParts { + hour: 0, + minute: 0, + second: 0, + nanosecond: 0, + }, + 0, + ) + .unwrap(); + assert_eq!(dt.day_of_year(), 366); + assert_eq!(dt.weekday_monday0(), (dt.weekday_sunday0() + 6) % 7); + } + + #[test] + fn days_in_month_returns_zero_for_invalid_month() { + assert_eq!(super::days_in_month(10000, 0), 0); + assert_eq!(super::days_in_month(10000, 13), 0); + } +} diff --git a/src/items/builder.rs b/src/items/builder.rs index f96a638..d9a2cbc 100644 --- a/src/items/builder.rs +++ b/src/items/builder.rs @@ -4,13 +4,15 @@ use jiff::{civil, Span, ToSpan, Zoned}; use super::{date, epoch, error, offset, relative, time, weekday, year, Item}; +use crate::extended::{DateParts, TimeParts}; +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, /// but without the baseline date and time. So you normally need to set the base /// date and time using the `set_base()` method before calling `build()`, or /// leave it unset to use the current date and time as the base. -#[derive(Debug, Default)] +#[derive(Debug, Default, Clone)] pub(crate) struct DateTimeBuilder { base: Option, timestamp: Option, @@ -160,7 +162,11 @@ impl DateTimeBuilder { self.set_time(time) } - /// Build a `Zoned` object from the pieces accumulated in this builder. + /// Build a parsed datetime result from the pieces accumulated in this builder. + /// + /// Returns [`ParsedDateTime::InRange`] when the result is representable as a + /// [`jiff::Zoned`], or [`ParsedDateTime::Extended`] for large/out-of-range + /// year scenarios. /// /// Resolution order (mirrors GNU `date` semantics): /// @@ -185,7 +191,38 @@ 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(); + } + } + + if !self.should_try_extended_fallback() { + return self.build_in_range().map(ParsedDateTime::InRange); + } + + // Near the year-9999 boundary, otherwise valid inputs can fail during + // in-range resolution due to transient timestamp/offset conversions. + // Retry in extended mode for boundary-risk contexts. + match self.clone().build_in_range() { + Ok(dt) => Ok(ParsedDateTime::InRange(dt)), + Err(in_range_err) => self.build_extended().or(Err(in_range_err)), + } + } + + fn should_try_extended_fallback(&self) -> bool { + if self.timestamp.is_some() { + return false; + } + + self.date + .as_ref() + .is_some_and(|d| d.year.unwrap_or(0) >= 9999) + || self.base.as_ref().is_some_and(|b| b.year() >= 9999) + } + + fn build_in_range(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. @@ -284,10 +321,10 @@ impl DateTimeBuilder { for rel in self.relative { dt = match rel { relative::Relative::Years(_) | relative::Relative::Months(_) => { - // GNU way of calculating relative months and years - // GNU changes the month and then checks if the target month has + // GNU way of calculating relative months and years. + // GNU changes the month/year and then checks if the target month has // this day. If this day does not exist in the target month it overflows - // the difference + // the difference. let original_day_of_month = dt.day(); dt = dt.checked_add::(rel.try_into()?)?; if original_day_of_month != dt.day() { @@ -298,7 +335,7 @@ impl DateTimeBuilder { dt } _ => dt.checked_add::(rel.try_into()?)?, - } + }; } // 4e. Apply final fixed offset. @@ -310,6 +347,169 @@ impl DateTimeBuilder { Ok(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( + DateParts { + year: u32::try_from(base.year()).map_err(|_| "year must be non-negative")?, + month: base.month() as u8, + day: base.day() as u8, + }, + TimeParts { + hour: if need_midnight { 0 } else { base.hour() as u8 }, + minute: if need_midnight { + 0 + } else { + base.minute() as u8 + }, + second: if need_midnight { + 0 + } else { + base.second() as u8 + }, + nanosecond: 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) => dt.checked_add_months(months)?, + 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 { + if let (Ok(ts), Ok(offset)) = ( + jiff::Timestamp::new(dt.unix_seconds(), dt.nanosecond as i32), + jiff::tz::Offset::from_seconds(dt.offset_seconds), + ) { + return Ok(ParsedDateTime::InRange(ts.to_zoned(offset.to_time_zone()))); + } + } + + Ok(ParsedDateTime::Extended(dt)) + } +} + +fn surrogate_year_for_rules(year: u32) -> i16 { + // Keep weekday/leap-year parity by preserving `year mod 400`, but avoid + // upper-bound years that can overflow when resolving a zoned instant. + const BASE: i64 = 9_599; + let mapped = BASE + (i64::from(year) - BASE).rem_euclid(400); + mapped 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, + } } impl TryFrom> for DateTimeBuilder { @@ -320,6 +520,7 @@ impl TryFrom> for DateTimeBuilder { for item in items { builder = match item { + #[cfg(test)] Item::Timestamp(ts) => builder.set_timestamp(ts)?, Item::DateTime(dt) => builder.set_date(dt.date)?.set_time(dt.time)?, Item::Date(d) => builder.set_date(d)?, @@ -339,8 +540,8 @@ impl TryFrom> for DateTimeBuilder { #[cfg(test)] mod tests { use super::*; + use jiff::{civil::DateTime, tz::TimeZone, Zoned}; - // Helper functions to create test items by parsing fn timestamp() -> epoch::Timestamp { let mut input = "@1234567890"; epoch::parse(&mut input).unwrap() @@ -351,6 +552,10 @@ mod tests { date::parse(&mut input).unwrap() } + fn date_large(mut input: &str) -> date::Date { + date::parse(&mut input).unwrap() + } + fn time() -> time::Time { let mut input = "12:30:00"; time::parse(&mut input).unwrap() @@ -361,6 +566,11 @@ mod tests { time::parse(&mut input).unwrap() } + fn time_with_small_offset() -> time::Time { + let mut input = "12:00:00+01:00"; + time::parse(&mut input).unwrap() + } + fn weekday() -> weekday::Weekday { let mut input = "monday"; weekday::parse(&mut input).unwrap() @@ -371,17 +581,66 @@ mod tests { offset::timezone_offset(&mut input).unwrap() } - fn relative() -> relative::Relative { + fn offset_large() -> offset::Offset { + let mut input = "m+24"; + offset::parse(&mut input).unwrap() + } + + fn relative_day() -> relative::Relative { let mut input = "1 day"; relative::parse(&mut input).unwrap() } + fn relative_month() -> relative::Relative { + let mut input = "1 month"; + relative::parse(&mut input).unwrap() + } + + fn relative_hours() -> relative::Relative { + let mut input = "2 hours"; + relative::parse(&mut input).unwrap() + } + + fn relative_minutes() -> relative::Relative { + let mut input = "3 minutes"; + relative::parse(&mut input).unwrap() + } + + fn relative_seconds() -> relative::Relative { + let mut input = "4 seconds"; + relative::parse(&mut input).unwrap() + } + + fn relative_day_ago() -> relative::Relative { + let mut input = "1 day ago"; + relative::parse(&mut input).unwrap() + } + + fn weekday_next_monday() -> weekday::Weekday { + let mut input = "next monday"; + weekday::parse(&mut input).unwrap() + } + fn timezone() -> jiff::tz::TimeZone { jiff::tz::TimeZone::UTC } + fn expect_extended_datetime(parsed: ParsedDateTime) -> 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 test_duplicate_items_errors() { + fn duplicate_items_error() { let test_cases = vec![ ( vec![Item::TimeZone(timezone()), Item::TimeZone(timezone())], @@ -416,17 +675,17 @@ mod tests { } #[test] - fn test_timestamp_cannot_be_combined_with_other_items() { + fn timestamp_cannot_be_combined_with_other_items() { let test_cases = vec![ vec![Item::Date(date()), Item::Timestamp(timestamp())], vec![Item::Time(time()), Item::Timestamp(timestamp())], vec![Item::Weekday(weekday()), Item::Timestamp(timestamp())], vec![Item::Offset(offset()), Item::Timestamp(timestamp())], - vec![Item::Relative(relative()), Item::Timestamp(timestamp())], + vec![Item::Relative(relative_day()), Item::Timestamp(timestamp())], vec![Item::Timestamp(timestamp()), Item::Date(date())], vec![Item::Timestamp(timestamp()), Item::Time(time())], vec![Item::Timestamp(timestamp()), Item::Weekday(weekday())], - vec![Item::Timestamp(timestamp()), Item::Relative(relative())], + vec![Item::Timestamp(timestamp()), Item::Relative(relative_day())], vec![Item::Timestamp(timestamp()), Item::Offset(offset())], vec![Item::Timestamp(timestamp()), Item::Pure("2023".to_string())], ]; @@ -441,15 +700,13 @@ mod tests { } #[test] - fn test_time_offset_conflicts() { - // Time with offset followed by separate Offset item + fn time_offset_conflicts() { let items1 = vec![Item::Time(time_with_offset()), Item::Offset(offset())]; assert_eq!( DateTimeBuilder::try_from(items1).unwrap_err(), "time offset cannot appear more than once" ); - // Offset item followed by Time with offset let items2 = vec![Item::Offset(offset()), Item::Time(time_with_offset())]; assert_eq!( DateTimeBuilder::try_from(items2).unwrap_err(), @@ -458,23 +715,290 @@ mod tests { } #[test] - fn test_valid_combination_date_time() { - let items = vec![Item::Date(date()), Item::Time(time())]; - let result = DateTimeBuilder::try_from(items); - assert!(result.is_ok()); + fn helper_mappings() { + let mapped = surrogate_year_for_rules(2024); + assert!((9599..=9998).contains(&mapped)); + assert_eq!((mapped as i32).rem_euclid(400), (2024_i32).rem_euclid(400)); + assert_eq!(surrogate_year_for_rules(10000), 9600); + assert_eq!(weekday_monday0(weekday::Day::Monday), 0); + assert_eq!(weekday_monday0(weekday::Day::Tuesday), 1); + assert_eq!(weekday_monday0(weekday::Day::Wednesday), 2); + assert_eq!(weekday_monday0(weekday::Day::Thursday), 3); + assert_eq!(weekday_monday0(weekday::Day::Friday), 4); + assert_eq!(weekday_monday0(weekday::Day::Saturday), 5); + assert_eq!(weekday_monday0(weekday::Day::Sunday), 6); + } + + #[test] + fn build_rejects_timestamp_in_extended_path() { + let mut builder = DateTimeBuilder::new(); + builder.timestamp = Some(timestamp()); + assert!(builder.build_extended().is_err()); + } + + #[test] + fn build_falls_back_to_extended_for_9999_rollover() { + let base = "2000-01-01 00:00:00" + .parse::() + .unwrap() + .to_zoned(TimeZone::UTC) + .unwrap(); + let builder = DateTimeBuilder::try_from(vec![ + Item::Date(date_large("9999-12-31")), + Item::Relative(relative_day()), + ]) + .unwrap(); + let dt = expect_extended_datetime(builder.set_base(base).build().unwrap()); + assert_eq!((dt.year, dt.month, dt.day), (10000, 1, 1)); + } + + #[test] + fn build_extended_keeps_9999_values_when_timestamp_overflows() { + let base = "2000-01-01 00:00:00" + .parse::() + .unwrap() + .to_zoned(TimeZone::UTC) + .unwrap(); + let builder = DateTimeBuilder::try_from(vec![ + Item::Date(date_large("10000-01-01")), + Item::Relative(relative_day_ago()), + ]) + .unwrap(); + let dt = expect_extended_datetime(builder.set_base(base).build().unwrap()); + assert_eq!((dt.year, dt.month, dt.day), (9999, 12, 31)); + } + + #[test] + fn resolve_rule_offset_for_extended_uses_surrogate_year() { + let dt = ExtendedDateTime::new( + DateParts { + year: 10000, + month: 7, + day: 1, + }, + TimeParts { + hour: 0, + minute: 0, + second: 0, + nanosecond: 0, + }, + 0, + ) + .unwrap(); + let offset = resolve_rule_offset_for_extended(&jiff::tz::TimeZone::UTC, &dt).unwrap(); + assert_eq!(offset, 0); + } + + #[test] + fn resolve_rule_offset_for_extended_non_utc_rule() { + let dt = ExtendedDateTime::new( + DateParts { + year: 10000, + month: 7, + day: 1, + }, + TimeParts { + hour: 12, + minute: 0, + second: 0, + nanosecond: 0, + }, + 0, + ) + .unwrap(); + let tz = jiff::tz::TimeZone::get("Europe/Paris").unwrap(); + let _ = resolve_rule_offset_for_extended(&tz, &dt).unwrap(); + } + + #[test] + fn resolve_rule_offset_for_extended_handles_upper_bound_dates() { + let dt = ExtendedDateTime::new( + DateParts { + year: 9999, + month: 12, + day: 31, + }, + TimeParts { + hour: 0, + minute: 0, + second: 0, + nanosecond: 0, + }, + 0, + ) + .unwrap(); + assert!(resolve_rule_offset_for_extended(&jiff::tz::TimeZone::UTC, &dt).is_ok()); + } + + #[test] + fn should_try_extended_fallback_conditions() { + let mut builder = DateTimeBuilder::new(); + assert!(!builder.should_try_extended_fallback()); + + builder.relative.push(relative_day()); + builder.date = Some(date_large("9999-12-31")); + assert!(builder.should_try_extended_fallback()); + + builder.timestamp = Some(timestamp()); + assert!(!builder.should_try_extended_fallback()); + } + + #[test] + fn build_extended_applies_relative_units_and_weekday() { + let base = "2000-01-01 00:00:00" + .parse::() + .unwrap() + .to_zoned(TimeZone::UTC) + .unwrap(); + let builder = DateTimeBuilder::try_from(vec![ + Item::Date(date_large("10000-01-01")), + Item::Weekday(weekday_next_monday()), + Item::Relative(relative_day()), + Item::Relative(relative_hours()), + Item::Relative(relative_minutes()), + Item::Relative(relative_seconds()), + ]) + .unwrap(); + let dt = expect_extended_datetime(builder.set_base(base).build().unwrap()); + assert_eq!((dt.year, dt.month, dt.day), (10000, 1, 4)); + assert_eq!((dt.hour, dt.minute, dt.second), (2, 3, 4)); + } + + #[test] + fn build_extended_applies_normalized_final_offset() { + let base = "2000-01-01 00:00:00" + .parse::() + .unwrap() + .to_zoned(TimeZone::UTC) + .unwrap(); + let builder = DateTimeBuilder::try_from(vec![ + Item::Date(date_large("10000-06-01")), + Item::Time(time()), + Item::Offset(offset_large()), + ]) + .unwrap(); + let dt = expect_extended_datetime(builder.set_base(base).build().unwrap()); + assert_eq!(dt.offset_seconds, 23 * 3600); + } + + #[test] + fn build_prefers_in_range_when_fallback_succeeds() { + let base = "2000-01-01 00:00:00" + .parse::() + .unwrap() + .to_zoned(TimeZone::UTC) + .unwrap(); + let builder = DateTimeBuilder::try_from(vec![ + Item::Date(date_large("9999-01-31")), + Item::Relative(relative_day()), + ]) + .unwrap(); + let z = expect_in_range_datetime(builder.set_base(base).build().unwrap()); + assert_eq!(z.strftime("%Y-%m-%d").to_string(), "9999-02-01"); + } + + #[test] + fn build_falls_back_to_extended_for_non_relative_boundary_time_offset() { + let base = "2000-01-01 00:00:00" + .parse::() + .unwrap() + .to_zoned(TimeZone::UTC) + .unwrap(); + let builder = DateTimeBuilder::try_from(vec![ + Item::Date(date_large("9999-12-31")), + Item::Time(time_with_small_offset()), + ]) + .unwrap(); + let dt = expect_extended_datetime(builder.set_base(base).build().unwrap()); + assert_eq!((dt.year, dt.month, dt.day), (9999, 12, 31)); + assert_eq!((dt.hour, dt.minute, dt.second), (12, 0, 0)); + assert_eq!(dt.offset_seconds, 3600); + } + + #[test] + fn build_in_range_month_overflow_adjustment_path() { + let base = "2000-01-01 00:00:00" + .parse::() + .unwrap() + .to_zoned(TimeZone::UTC) + .unwrap(); + let builder = DateTimeBuilder::try_from(vec![ + Item::Date(date_large("2021-01-31")), + Item::Relative(relative_month()), + ]) + .unwrap(); + let z = expect_in_range_datetime(builder.set_base(base).build().unwrap()); + assert_eq!(z.strftime("%Y-%m-%d").to_string(), "2021-03-03"); + } + + #[test] + fn build_extended_uses_base_and_timezone_rule() { + let base = "2000-01-01 10:11:12" + .parse::() + .unwrap() + .to_zoned(TimeZone::UTC) + .unwrap(); + let tz = jiff::tz::TimeZone::get("Europe/Paris").unwrap(); + let builder = DateTimeBuilder::try_from(vec![ + Item::Date(date_large("10000-01-01")), + Item::TimeZone(tz), + ]) + .unwrap(); + let dt = expect_extended_datetime(builder.set_base(base).build().unwrap()); + assert_eq!(dt.year, 10000); + } + + #[test] + fn build_extended_supports_timezone_without_base() { + let tz = jiff::tz::TimeZone::get("Europe/Paris").unwrap(); + let builder = DateTimeBuilder::try_from(vec![ + Item::Date(date_large("10000-01-01")), + Item::TimeZone(tz), + ]) + .unwrap(); + let dt = expect_extended_datetime(builder.build().unwrap()); + assert_eq!(dt.year, 10000); + } + + #[test] + fn build_extended_preserves_base_time_when_not_truncated() { + let base = "2000-01-01 10:11:12.123456789" + .parse::() + .unwrap() + .to_zoned(TimeZone::UTC) + .unwrap(); + let mut builder = DateTimeBuilder::new(); + builder.base = Some(base); + let z = expect_in_range_datetime(builder.build_extended().unwrap()); + assert_eq!( + z.strftime("%Y-%m-%d %H:%M:%S%.9f").to_string(), + "2000-01-01 10:11:12.123456789" + ); } #[test] - fn test_valid_combination_date_weekday() { - let items = vec![Item::Date(date()), Item::Weekday(weekday())]; - let result = DateTimeBuilder::try_from(items); - assert!(result.is_ok()); + fn build_extended_applies_date_and_weekday_without_time_item() { + let base = "2000-01-01 10:11:12" + .parse::() + .unwrap() + .to_zoned(TimeZone::UTC) + .unwrap(); + let builder = DateTimeBuilder::try_from(vec![ + Item::Date(date_large("10000-01-01")), + Item::Weekday(weekday_next_monday()), + ]) + .unwrap(); + let dt = expect_extended_datetime(builder.set_base(base).build().unwrap()); + assert_eq!((dt.hour, dt.minute, dt.second), (0, 0, 0)); } #[test] - fn test_valid_timestamp_alone() { - let items = vec![Item::Timestamp(timestamp())]; - let result = DateTimeBuilder::try_from(items); - assert!(result.is_ok()); + #[should_panic(expected = "expected in-range datetime")] + fn expect_in_range_datetime_panics_for_extended_input() { + let parsed = DateTimeBuilder::try_from(vec![Item::Date(date_large("10000-01-01"))]) + .unwrap() + .build() + .unwrap(); + let _ = expect_in_range_datetime(parsed); } } 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 ba9dfaf..76eaa1f 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::{ @@ -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 + Clone>(base: Zoned, input: S) -> Result { +/// 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 + Clone>( + base: Zoned, + input: S, +) -> Result { 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 + Clone>(input: S) -> Result { +/// 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 + 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()), @@ -209,12 +214,9 @@ fn parse_timestamp(input: &mut &str) -> ModalResult { 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 #[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::() + .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::() + .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::() + .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::() + .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::() + .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::() + .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::() + .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::() + .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::() + .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::() + .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::() + .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::() + .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::() + .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..9dc42f1 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 { @@ -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); + } } diff --git a/src/items/weekday.rs b/src/items/weekday.rs index 146dd78..77d0cc9 100644 --- a/src/items/weekday.rs +++ b/src/items/weekday.rs @@ -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, diff --git a/src/lib.rs b/src/lib.rs index d24dbd9..92e205f 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 @@ -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 { + 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 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 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 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 + Clone>(input: S) -> Result { +/// input string cannot be parsed. +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,31 +134,30 @@ 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 -/// * `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 + Clone>( date: Zoned, input: S, -) -> Result { +) -> Result { 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() ); } } 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/large_year.rs b/tests/large_year.rs new file mode 100644 index 0000000..0320c45 --- /dev/null +++ b/tests/large_year.rs @@ -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::() + .unwrap() + .to_zoned(TimeZone::UTC) + .unwrap(); + check_relative(base, "10000-01-01 +1 day", "10000-01-02 00:00:00+00:00"); +} diff --git a/tests/time.rs b/tests/time.rs index 3718ce3..8c18d92 100644 --- a/tests/time.rs +++ b/tests/time.rs @@ -20,6 +20,7 @@ pub fn check_time(input: &str, expected: &str, format: &str, base: Option 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)" ); }