From 0fea732ca5fc91f71863c3b96ec30c415fc40676 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Tue, 31 Mar 2026 09:13:56 +0200 Subject: [PATCH 01/10] Add Display impls for ExtendedDateTime and ParsedDateTime Provide a standard formatting path so callers (and tests) can use `.to_string()` instead of hand-rolling offset formatting everywhere. --- src/extended.rs | 16 ++++++++++++++++ src/lib.rs | 9 +++++++++ 2 files changed, 25 insertions(+) diff --git a/src/extended.rs b/src/extended.rs index cabf035..fd00777 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,20 @@ 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; + 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/lib.rs b/src/lib.rs index 92e205f..74e376e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -55,6 +55,15 @@ impl ParsedDateTime { } } +impl fmt::Display for ParsedDateTime { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ParsedDateTime::InRange(z) => write!(f, "{}", z.strftime("%Y-%m-%d %H:%M:%S%:z")), + ParsedDateTime::Extended(dt) => write!(f, "{dt}"), + } + } +} + impl PartialEq for ParsedDateTime { fn eq(&self, other: &Zoned) -> bool { match self { From 5373049a1740693b7112ad9c468bf4cd5d7a0906 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Tue, 31 Mar 2026 09:14:23 +0200 Subject: [PATCH 02/10] Add doc comments on expect_in_range and PartialEq Document that expect_in_range is intended for tests/trusted contexts and that Extended values never compare equal to a Zoned. --- src/lib.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 74e376e..b4b3d80 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -49,6 +49,11 @@ 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") @@ -64,6 +69,7 @@ impl fmt::Display for ParsedDateTime { } } +/// An `Extended` value never compares equal to a `Zoned`. impl PartialEq for ParsedDateTime { fn eq(&self, other: &Zoned) -> bool { match self { From db42f559613848357e1d696cb5ef5a1c36308b67 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Tue, 31 Mar 2026 09:15:30 +0200 Subject: [PATCH 03/10] Remove Item::Timestamp enum variant The Timestamp variant was #[cfg(test)]-gated dead code outside tests. The parse_timestamp function already calls set_timestamp on the builder directly, bypassing the TryFrom> path. Remove the variant and refactor builder tests to exercise set_timestamp directly instead. --- src/items/builder.rs | 68 ++++++++++++++++++++++++++++++-------------- src/items/mod.rs | 2 -- 2 files changed, 47 insertions(+), 23 deletions(-) diff --git a/src/items/builder.rs b/src/items/builder.rs index d9a2cbc..107d317 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)?, @@ -646,10 +644,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 +668,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] diff --git a/src/items/mod.rs b/src/items/mod.rs index 76eaa1f..1f4c446 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), From 5b19a0a87709d6f8615c2d912a3208a2081c9a9f Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Tue, 31 Mar 2026 09:17:07 +0200 Subject: [PATCH 04/10] Deduplicate test helpers using Display and expect_in_range Replace hand-rolled format_offset_colon / format_for_assert helpers with the new Display impls. Replace local expect_in_range_datetime test helpers with ParsedDateTime::expect_in_range(). --- src/items/builder.rs | 21 +++++++------------ src/items/mod.rs | 49 ++++++++++---------------------------------- tests/common/mod.rs | 30 +++------------------------ 3 files changed, 21 insertions(+), 79 deletions(-) diff --git a/src/items/builder.rs b/src/items/builder.rs index 107d317..3465178 100644 --- a/src/items/builder.rs +++ b/src/items/builder.rs @@ -538,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"; @@ -630,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![ @@ -919,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"); } @@ -953,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"); } @@ -995,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" @@ -1019,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 1f4c446..a1529bb 100644 --- a/src/items/mod.rs +++ b/src/items/mod.rs @@ -300,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 { @@ -336,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!( @@ -600,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" @@ -615,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" @@ -659,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/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}"); } From 9e5b18911816ad5fdc668e7d25cd8c6a170116d2 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Tue, 31 Mar 2026 09:32:40 +0200 Subject: [PATCH 05/10] Add large-year coverage to fuzz target Add a dedicated fuzz_large_year target with structured inputs biased toward large years (near 9999 boundary, up to GNU_MAX_YEAR). Keep the original fuzz_parse_datetime target unchanged for raw-bytes fuzzing. --- fuzz/Cargo.lock | 16 +++++++++ fuzz/Cargo.toml | 8 +++++ fuzz/fuzz_targets/large_year.rs | 64 +++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 fuzz/fuzz_targets/large_year.rs 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); +}); From d72070f660e99d5e00dfeeed4de13bc532e735b3 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Tue, 31 Mar 2026 10:23:17 +0200 Subject: [PATCH 06/10] docs: add ExtendedDateTime usage example to README --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index 6b99e97..a69de16 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,21 @@ 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.to_string(), "12000-01-01 00:00:00+00:00"); + } + 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: From d38c5d50dcfbed67d893fe1524eb98b907d45602 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Tue, 31 Mar 2026 10:23:20 +0200 Subject: [PATCH 07/10] ci: add fuzz_large_year target to CI workflow --- .github/workflows/ci.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 From 041505a8f50c8fa1acd3e0b4dada4ee98c3f5c86 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Tue, 31 Mar 2026 23:32:43 +0200 Subject: [PATCH 08/10] Display: preserve fractional seconds when present When nanoseconds are non-zero, include them in Display output as `.NNNNNNNNN` between seconds and the offset. This avoids silently dropping sub-second precision from the formatted representation. --- src/extended.rs | 27 ++++++++++++++++++++++----- src/lib.rs | 9 ++++++++- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/extended.rs b/src/extended.rs index fd00777..146d2ae 100644 --- a/src/extended.rs +++ b/src/extended.rs @@ -297,11 +297,28 @@ impl fmt::Display for ExtendedDateTime { let abs = self.offset_seconds.unsigned_abs(); let oh = abs / 3600; let om = (abs % 3600) / 60; - 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, - ) + 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, + ) + } } } diff --git a/src/lib.rs b/src/lib.rs index b4b3d80..3597ffd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -63,7 +63,14 @@ impl ParsedDateTime { impl fmt::Display for ParsedDateTime { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - ParsedDateTime::InRange(z) => write!(f, "{}", z.strftime("%Y-%m-%d %H:%M:%S%:z")), + 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}"), } } From c7202407250a11927f53b8a6cce616e511b47d78 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Tue, 31 Mar 2026 23:34:07 +0200 Subject: [PATCH 09/10] tests: cover Display fractional-second formatting --- src/lib.rs | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 3597ffd..b9ce5fa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -811,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" + ); + } + } } From 6b557321e60a4471363fda9e239d7f6acf7640bd Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Wed, 1 Apr 2026 10:01:47 +0200 Subject: [PATCH 10/10] Add assertions for month and day in date parsing test --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a69de16..4152487 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,8 @@ let dt = parse_datetime("12000-01-01").unwrap(); match dt { ParsedDateTime::Extended(ext) => { assert_eq!(ext.year, 12000); - assert_eq!(ext.to_string(), "12000-01-01 00:00:00+00:00"); + assert_eq!(ext.month, 1); + assert_eq!(ext.day, 1); } ParsedDateTime::InRange(_) => unreachable!("year 12000 is out of jiff range"), }