diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e164b8b..4bd9862 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -154,9 +154,15 @@ jobs: - name: Install `cargo-fuzz` run: cargo install cargo-fuzz --locked - uses: Swatinem/rust-cache@v2 - - name: Run from_str for XX seconds + - name: Run fuzz_parse_datetime for XX seconds shell: bash run: | ## Run it cd fuzz cargo fuzz run fuzz_parse_datetime -- -max_total_time=${{ env.RUN_FOR }} -detect_leaks=0 + - name: Run fuzz_large_year for XX seconds + shell: bash + run: | + ## Run it + cd fuzz + cargo fuzz run fuzz_large_year -- -max_total_time=${{ env.RUN_FOR }} -detect_leaks=0 diff --git a/README.md b/README.md index 6b99e97..4152487 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,22 @@ match dt.unwrap() { } ``` +For years beyond jiff's representable range (e.g., year 10000+), the result is an `ExtendedDateTime`: + +```rs +use parse_datetime::{parse_datetime, ParsedDateTime}; + +let dt = parse_datetime("12000-01-01").unwrap(); +match dt { + ParsedDateTime::Extended(ext) => { + assert_eq!(ext.year, 12000); + assert_eq!(ext.month, 1); + assert_eq!(ext.day, 1); + } + ParsedDateTime::InRange(_) => unreachable!("year 12000 is out of jiff range"), +} +``` + ### Supported Formats The `parse_datetime` and `parse_datetime_at_date` functions support absolute datetime and the following relative times: diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index db1ae3d..de2c2c7 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -7,6 +7,9 @@ name = "arbitrary" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] [[package]] name = "autocfg" @@ -32,6 +35,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -42,6 +56,8 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" name = "fuzz_parse_datetime" version = "0.2.0" dependencies = [ + "arbitrary", + "jiff", "libfuzzer-sys", "parse_datetime", ] diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 8923b11..69ebb92 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -8,6 +8,8 @@ cargo-fuzz = true [dependencies] libfuzzer-sys = "0.4.7" +arbitrary = { version = "1", features = ["derive"] } +jiff = "0.2" [dependencies.parse_datetime] path = "../" @@ -17,3 +19,9 @@ name = "fuzz_parse_datetime" path = "fuzz_targets/parse_datetime.rs" test = false doc = false + +[[bin]] +name = "fuzz_large_year" +path = "fuzz_targets/large_year.rs" +test = false +doc = false diff --git a/fuzz/fuzz_targets/large_year.rs b/fuzz/fuzz_targets/large_year.rs new file mode 100644 index 0000000..ace853f --- /dev/null +++ b/fuzz/fuzz_targets/large_year.rs @@ -0,0 +1,64 @@ +#![no_main] + +use arbitrary::Arbitrary; +use jiff::{civil::DateTime, tz::TimeZone}; +use libfuzzer_sys::fuzz_target; + +#[derive(Arbitrary, Debug)] +struct Input { + /// Year for the base date (biased toward boundary years). + base_year_selector: u8, + /// Year to embed in a constructed large-year input string. + input_year: u32, + month: u8, + day: u8, + /// Suffix appended after the constructed date (e.g. relative items). + suffix: String, + /// Whether to also call parse_datetime (no base). + try_no_base: bool, +} + +fn base_year(selector: u8) -> i16 { + match selector % 6 { + 0 => 2024, + 1 => 9998, + 2 => 9999, + 3 => 1, + 4 => 100, + _ => (selector as i16) * 40, + } +} + +fn clamp_year(y: u32) -> u32 { + // Focus on the interesting range: 9990..=100_000 and 0..=20_000 + match y % 4 { + 0 => 9990 + (y % 20), // near boundary + 1 => 10000 + (y % 90_000), // large years + 2 => y % 20_000, // general range + _ => 2_147_485_540 + (y % 10), // near GNU_MAX_YEAR + } +} + +fuzz_target!(|input: Input| { + let year = clamp_year(input.input_year); + let month = (input.month % 12) + 1; + let day = (input.day % 28) + 1; + let date_str = format!("{year:04}-{month:02}-{day:02} {}", input.suffix); + + // Test parse_datetime (uses current time as base). + if input.try_no_base { + let _ = parse_datetime::parse_datetime(&date_str); + } + + // Test parse_datetime_at_date with a controlled base. + let by = base_year(input.base_year_selector); + if let Ok(base) = DateTime::new(by, 1, 1, 0, 0, 0, 0) { + if let Ok(base) = base.to_zoned(TimeZone::UTC) { + let _ = parse_datetime::parse_datetime_at_date(base, &date_str); + } + } + + // Also try a bare large year as a pure number. + let bare = format!("{year}"); + let _ = parse_datetime::parse_datetime(&bare); +}); diff --git a/src/extended.rs b/src/extended.rs index cabf035..146d2ae 100644 --- a/src/extended.rs +++ b/src/extended.rs @@ -1,6 +1,8 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +use std::fmt; + use crate::GNU_MAX_YEAR; const SECONDS_PER_DAY: i64 = 86_400; @@ -289,6 +291,37 @@ impl ExtendedDateTime { } } +impl fmt::Display for ExtendedDateTime { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let sign = if self.offset_seconds < 0 { '-' } else { '+' }; + let abs = self.offset_seconds.unsigned_abs(); + let oh = abs / 3600; + let om = (abs % 3600) / 60; + if self.nanosecond != 0 { + write!( + f, + "{:04}-{:02}-{:02} {:02}:{:02}:{:02}.{:09}{}{:02}:{:02}", + self.year, + self.month, + self.day, + self.hour, + self.minute, + self.second, + self.nanosecond, + sign, + oh, + om, + ) + } else { + write!( + f, + "{:04}-{:02}-{:02} {:02}:{:02}:{:02}{}{:02}:{:02}", + self.year, self.month, self.day, self.hour, self.minute, self.second, sign, oh, om, + ) + } + } +} + pub fn is_leap_year(year: u32) -> bool { (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) } diff --git a/src/items/builder.rs b/src/items/builder.rs index d9a2cbc..3465178 100644 --- a/src/items/builder.rs +++ b/src/items/builder.rs @@ -520,8 +520,6 @@ 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)?, Item::Time(t) => builder.set_time(t)?, @@ -540,7 +538,7 @@ impl TryFrom> for DateTimeBuilder { #[cfg(test)] mod tests { use super::*; - use jiff::{civil::DateTime, tz::TimeZone, Zoned}; + use jiff::{civil::DateTime, tz::TimeZone}; fn timestamp() -> epoch::Timestamp { let mut input = "@1234567890"; @@ -632,13 +630,6 @@ mod tests { } } - 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 duplicate_items_error() { let test_cases = vec![ @@ -646,10 +637,6 @@ mod tests { vec![Item::TimeZone(timezone()), Item::TimeZone(timezone())], "timezone rule cannot appear more than once", ), - ( - vec![Item::Timestamp(timestamp()), Item::Timestamp(timestamp())], - "timestamp cannot appear more than once", - ), ( vec![Item::Date(date()), Item::Date(date())], "date cannot appear more than once", @@ -674,29 +661,61 @@ mod tests { } } + #[test] + fn duplicate_timestamp_error() { + let builder = DateTimeBuilder::new().set_timestamp(timestamp()).unwrap(); + assert_eq!( + builder.set_timestamp(timestamp()).unwrap_err(), + "timestamp cannot appear more than once" + ); + } + #[test] 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_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_day())], - vec![Item::Timestamp(timestamp()), Item::Offset(offset())], - vec![Item::Timestamp(timestamp()), Item::Pure("2023".to_string())], + // Setting a timestamp after other items have been set. + let builders_with_items: Vec = vec![ + DateTimeBuilder::new().set_date(date()).unwrap(), + DateTimeBuilder::new().set_time(time()).unwrap(), + DateTimeBuilder::new().set_weekday(weekday()).unwrap(), + DateTimeBuilder::new().set_offset(offset()).unwrap(), + DateTimeBuilder::new() + .push_relative(relative_day()) + .unwrap(), ]; - for items in test_cases { - let result = DateTimeBuilder::try_from(items); + for builder in builders_with_items { assert_eq!( - result.unwrap_err(), + builder.set_timestamp(timestamp()).unwrap_err(), "timestamp cannot be combined with other date/time items" ); } + + // Setting other items after a timestamp has been set. + let ts_builder = || DateTimeBuilder::new().set_timestamp(timestamp()).unwrap(); + assert_eq!( + ts_builder().set_date(date()).unwrap_err(), + "timestamp cannot be combined with other date/time items" + ); + assert_eq!( + ts_builder().set_time(time()).unwrap_err(), + "timestamp cannot be combined with other date/time items" + ); + assert_eq!( + ts_builder().set_weekday(weekday()).unwrap_err(), + "timestamp cannot be combined with other date/time items" + ); + assert_eq!( + ts_builder().push_relative(relative_day()).unwrap_err(), + "timestamp cannot be combined with other date/time items" + ); + assert_eq!( + ts_builder().set_offset(offset()).unwrap_err(), + "timestamp cannot be combined with other date/time items" + ); + assert_eq!( + ts_builder().set_pure("2023".to_string()).unwrap_err(), + "timestamp cannot be combined with other date/time items" + ); } #[test] @@ -893,7 +912,7 @@ mod tests { Item::Relative(relative_day()), ]) .unwrap(); - let z = expect_in_range_datetime(builder.set_base(base).build().unwrap()); + let z = builder.set_base(base).build().unwrap().expect_in_range(); assert_eq!(z.strftime("%Y-%m-%d").to_string(), "9999-02-01"); } @@ -927,7 +946,7 @@ mod tests { Item::Relative(relative_month()), ]) .unwrap(); - let z = expect_in_range_datetime(builder.set_base(base).build().unwrap()); + let z = builder.set_base(base).build().unwrap().expect_in_range(); assert_eq!(z.strftime("%Y-%m-%d").to_string(), "2021-03-03"); } @@ -969,7 +988,7 @@ mod tests { .unwrap(); let mut builder = DateTimeBuilder::new(); builder.base = Some(base); - let z = expect_in_range_datetime(builder.build_extended().unwrap()); + let z = builder.build_extended().unwrap().expect_in_range(); assert_eq!( z.strftime("%Y-%m-%d %H:%M:%S%.9f").to_string(), "2000-01-01 10:11:12.123456789" @@ -993,12 +1012,12 @@ mod tests { } #[test] - #[should_panic(expected = "expected in-range datetime")] - fn expect_in_range_datetime_panics_for_extended_input() { + #[should_panic(expected = "ParsedDateTime is not representable as jiff::Zoned")] + fn expect_in_range_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); + let _ = parsed.expect_in_range(); } } diff --git a/src/items/mod.rs b/src/items/mod.rs index 76eaa1f..a1529bb 100644 --- a/src/items/mod.rs +++ b/src/items/mod.rs @@ -63,8 +63,6 @@ use error::Error; #[derive(PartialEq, Debug)] enum Item { - #[cfg(test)] - Timestamp(epoch::Timestamp), DateTime(combined::DateTime), Date(date::Date), Time(time::Time), @@ -302,33 +300,13 @@ 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}"); - } - } + let parsed = parse_at_date(base, input).unwrap(); + assert!( + matches!(parsed, ParsedDateTime::Extended(_)), + "expected extended datetime, got in-range for: {input}" + ); + assert_eq!(parsed.to_string(), expected, "{input}"); } fn expect_extended_datetime(parsed: ParsedDateTime) -> crate::ExtendedDateTime { @@ -338,13 +316,6 @@ mod tests { } } - 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!( @@ -602,7 +573,7 @@ mod tests { .to_zoned(TimeZone::UTC) .unwrap(); let result = parse_at_date(base, "10000-01-01 -1000 years").unwrap(); - let z = expect_in_range_datetime(result); + let z = result.expect_in_range(); assert_eq!( z.strftime("%Y-%m-%d %H:%M:%S%:z").to_string(), "9000-01-01 00:00:00+00:00" @@ -617,7 +588,7 @@ mod tests { .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); + let z = result.expect_in_range(); assert_eq!( z.strftime("%Y-%m-%d %H:%M:%S%:z").to_string(), "9000-01-01 00:00:00+01:00" @@ -661,15 +632,15 @@ mod tests { } #[test] - #[should_panic(expected = "expected in-range datetime")] - fn expect_in_range_datetime_panics_for_extended_input() { + #[should_panic(expected = "ParsedDateTime is not representable as jiff::Zoned")] + fn expect_in_range_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); + let _ = parsed.expect_in_range(); } #[test] diff --git a/src/lib.rs b/src/lib.rs index 92e205f..b9ce5fa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -49,12 +49,34 @@ impl ParsedDateTime { } } + /// Unwraps the `InRange` variant, panicking if this is an `Extended` value. + /// + /// This is a convenience for contexts where the caller is certain the result + /// is in range (e.g., tests). Prefer [`into_zoned`](Self::into_zoned) or + /// pattern matching in production code. pub fn expect_in_range(self) -> Zoned { self.into_zoned() .expect("ParsedDateTime is not representable as jiff::Zoned") } } +impl fmt::Display for ParsedDateTime { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ParsedDateTime::InRange(z) => { + let ns = z.datetime().time().subsec_nanosecond(); + if ns != 0 { + write!(f, "{}", z.strftime("%Y-%m-%d %H:%M:%S%.9f%:z")) + } else { + write!(f, "{}", z.strftime("%Y-%m-%d %H:%M:%S%:z")) + } + } + ParsedDateTime::Extended(dt) => write!(f, "{dt}"), + } + } +} + +/// An `Extended` value never compares equal to a `Zoned`. impl PartialEq for ParsedDateTime { fn eq(&self, other: &Zoned) -> bool { match self { @@ -789,4 +811,80 @@ mod tests { ); } } + + #[cfg(test)] + mod display { + use jiff::{civil::DateTime, tz::TimeZone}; + + use crate::{parse_datetime_at_date, ExtendedDateTime, ParsedDateTime}; + + #[test] + fn in_range_without_nanoseconds() { + let base = "2024-06-15 08:30:00" + .parse::() + .unwrap() + .to_zoned(TimeZone::UTC) + .unwrap(); + let parsed = parse_datetime_at_date(base, "2024-06-15 08:30:00").unwrap(); + assert_eq!(parsed.to_string(), "2024-06-15 08:30:00+00:00"); + } + + #[test] + fn in_range_with_nanoseconds() { + let base = "2024-06-15 08:30:00.123456789" + .parse::() + .unwrap() + .to_zoned(TimeZone::UTC) + .unwrap(); + // Parsing "now" at a base with subsecond precision preserves it. + let parsed = parse_datetime_at_date(base, "now").unwrap(); + assert_eq!(parsed.to_string(), "2024-06-15 08:30:00.123456789+00:00"); + } + + #[test] + fn extended_without_nanoseconds() { + let dt = ExtendedDateTime::new( + crate::DateParts { + year: 12000, + month: 3, + day: 15, + }, + crate::TimeParts { + hour: 10, + minute: 30, + second: 45, + nanosecond: 0, + }, + 3600, + ) + .unwrap(); + assert_eq!( + ParsedDateTime::Extended(dt).to_string(), + "12000-03-15 10:30:45+01:00" + ); + } + + #[test] + fn extended_with_nanoseconds() { + let dt = ExtendedDateTime::new( + crate::DateParts { + year: 12000, + month: 3, + day: 15, + }, + crate::TimeParts { + hour: 10, + minute: 30, + second: 45, + nanosecond: 123456789, + }, + -18000, + ) + .unwrap(); + assert_eq!( + ParsedDateTime::Extended(dt).to_string(), + "12000-03-15 10:30:45.123456789-05:00" + ); + } + } } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 1348e68..5980fd3 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,7 @@ 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.to_string(), expected, "Input value: {input}"); } pub fn check_relative(now: Zoned, input: &str, expected: &str) { @@ -49,5 +25,5 @@ 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.to_string(), expected, "Input value: {input}"); }