From 1842ba91e5f06df06705058191c4240713232d22 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Mon, 15 Jun 2026 15:46:29 +0200 Subject: [PATCH 1/5] Replace chrono feature with jiff --- CHANGELOG.md | 4 ++ Cargo.lock | 54 +++++++++++++-------- Cargo.toml | 4 +- src/chrono.rs | 108 ------------------------------------------ src/jiff.rs | 128 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 8 ++-- src/serde.rs | 7 +-- 7 files changed, 177 insertions(+), 136 deletions(-) delete mode 100644 src/chrono.rs create mode 100644 src/jiff.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 433a25c..2478ddb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- [**breaking**] Replace the optional `chrono` feature with a `jiff` feature. + ## [0.15.0](https://github.com/moia-oss/tinytime.rs/compare/v0.14.4...v0.15.0) - 2026-03-09 ### Other diff --git a/Cargo.lock b/Cargo.lock index 5752e76..7da74a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,12 +8,6 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" -[[package]] -name = "autocfg" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" - [[package]] name = "bitflags" version = "2.9.0" @@ -37,15 +31,6 @@ dependencies = [ "rand_core", ] -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "num-traits", -] - [[package]] name = "cpufeatures" version = "0.3.0" @@ -126,6 +111,29 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jiff" +version = "0.2.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" +dependencies = [ + "jiff-static", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -151,12 +159,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] -name = "num-traits" -version = "0.2.19" +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" dependencies = [ - "autocfg", + "portable-atomic", ] [[package]] @@ -274,7 +288,7 @@ dependencies = [ name = "tinytime" version = "0.15.0" dependencies = [ - "chrono", + "jiff", "rand", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 358aa84..abd50a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,12 +12,12 @@ keywords = ["time", "tiny", "low-overhead"] categories = ["date-and-time"] [features] -chrono = ["dep:chrono"] +jiff = ["dep:jiff"] rand = ["dep:rand"] serde = ["dep:serde"] [dependencies] -chrono = { version = "0.4.45", features = ["alloc"], default-features = false, optional = true } +jiff = { version = "0.2.28", features = ["alloc"], default-features = false, optional = true } rand = { version = "0.10.1", features = ["thread_rng"], default-features = false, optional = true } serde = { version = "1.0.228", features = ["derive"], default-features = false, optional = true } diff --git a/src/chrono.rs b/src/chrono.rs deleted file mode 100644 index dc91a60..0000000 --- a/src/chrono.rs +++ /dev/null @@ -1,108 +0,0 @@ -use core::fmt; -use std::fmt::Display; -use std::fmt::Formatter; - -use chrono::DateTime; -use chrono::TimeDelta; -use chrono::Utc; -use chrono::format::DelayedFormat; -use chrono::format::StrftimeItems; - -use crate::Duration; -use crate::Time; -use crate::TimeWindow; -impl Time { - /// The function format string is forwarded to - /// [`chrono::NaiveDateTime::format()`] - /// - /// Values above ~240148-08-31, such as `Time::MAX` are formatted as "∞" - /// - /// # Example - /// - /// ``` - /// use tinytime::Time; - /// assert_eq!("∞", Time::MAX.format("whatever").to_string()); - /// ``` - #[must_use] - pub fn format<'a>(&self, fmt: &'a str) -> DelayedFormat> { - let secs = self.0 / 1000; - let nanos = (self.0 % 1000) * 1_000_000; - #[expect( - clippy::cast_possible_truncation, - reason = "casting to u32 is safe here because it is guaranteed that the value is in 0..1_000_000_000" - )] - let nanos = if nanos.is_negative() { - 1_000_000_000 - nanos.unsigned_abs() - } else { - nanos.unsigned_abs() - } as u32; - - let t = DateTime::from_timestamp(secs, nanos); - match t { - None => DelayedFormat::new(None, None, StrftimeItems::new("∞")), - Some(v) => v.format(fmt), - } - } - - /// Parses an RFC 3339 date and time string into a [Time] instance. - /// - /// The parsing is forwarded to [`chrono::DateTime::parse_from_rfc3339()`]. - /// Note that any time smaller than milliseconds is truncated. - /// - /// For using this with `serde`, see [`Time::deserialize_rfc3339()`]. - /// - /// ## Example - /// ``` - /// use tinytime::Duration; - /// use tinytime::Time; - /// assert_eq!( - /// Ok(Time::hours(2) + Duration::minutes(51) + Duration::seconds(7) + Duration::millis(123)), - /// Time::parse_from_rfc3339("1970-01-01T02:51:07.123999Z") - /// ); - /// ``` - pub fn parse_from_rfc3339(s: &str) -> Result { - DateTime::parse_from_rfc3339(s) - .map(|chrono_datetime| Time::millis(chrono_datetime.timestamp_millis())) - } - - /// Returns an RFC 3339 and ISO 8601 date and time string such as - /// 1996-12-19T16:39:57+00:00. - /// - /// Values above ~240148-08-31, such as `Time::MAX` are formatted as "∞" - /// - /// # Example - /// - /// ``` - /// use tinytime::Time; - /// assert_eq!("∞", Time::MAX.to_rfc3339()); - /// ``` - #[must_use] - pub fn to_rfc3339(self) -> String { - self.format("%Y-%m-%dT%H:%M:%S+00:00").to_string() - } -} - -impl Display for Time { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - let rfc3339_string = self.to_rfc3339(); - write!(f, "{rfc3339_string}") - } -} - -impl Display for TimeWindow { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - write!(f, "[{}, {}]", self.start, self.end) - } -} - -impl From> for Time { - fn from(dt: DateTime) -> Self { - Time::millis(dt.timestamp_millis()) - } -} - -impl From for Duration { - fn from(td: TimeDelta) -> Self { - Duration::millis(td.num_milliseconds()) - } -} diff --git a/src/jiff.rs b/src/jiff.rs new file mode 100644 index 0000000..08b302f --- /dev/null +++ b/src/jiff.rs @@ -0,0 +1,128 @@ +use core::fmt; +use std::fmt::Display; +use std::fmt::Formatter; + +use ::jiff::SignedDuration; +use ::jiff::Timestamp; +use ::jiff::fmt::strtime; + +use crate::Duration; +use crate::Time; +use crate::TimeWindow; + +/// A displayable formatted [`Time`]. +pub struct FormattedTime<'a> { + inner: FormattedTimeInner<'a>, +} + +enum FormattedTimeInner<'a> { + Finite(strtime::Display<'a>), + Infinity, +} + +impl fmt::Debug for FormattedTime<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + Display::fmt(self, f) + } +} + +impl Display for FormattedTime<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match &self.inner { + FormattedTimeInner::Finite(value) => Display::fmt(value, f), + FormattedTimeInner::Infinity => f.write_str("∞"), + } + } +} + +impl Time { + /// The function format string is forwarded to + /// [`jiff::Timestamp::strftime()`]. + /// + /// Values outside jiff's timestamp range, such as `Time::MAX`, are + /// formatted as "∞". + /// + /// # Example + /// + /// ``` + /// use tinytime::Time; + /// assert_eq!("∞", Time::MAX.format("whatever").to_string()); + /// ``` + #[must_use] + pub fn format<'a>(&self, fmt: &'a str) -> FormattedTime<'a> { + let inner = match Timestamp::from_millisecond(self.0) { + Ok(timestamp) => FormattedTimeInner::Finite(timestamp.strftime(fmt)), + Err(_) => FormattedTimeInner::Infinity, + }; + + FormattedTime { inner } + } + + /// Parses an RFC 3339 date and time string into a [Time] instance. + /// + /// The parsing is forwarded to [`jiff::Timestamp`]. + /// Note that any time smaller than milliseconds is truncated. + /// + /// For using this with `serde`, see [`Time::deserialize_rfc3339()`]. + /// + /// ## Example + /// ``` + /// use tinytime::Duration; + /// use tinytime::Time; + /// assert_eq!( + /// Time::hours(2) + Duration::minutes(51) + Duration::seconds(7) + Duration::millis(123), + /// Time::parse_from_rfc3339("1970-01-01T02:51:07.123999Z").unwrap() + /// ); + /// ``` + pub fn parse_from_rfc3339(s: &str) -> Result { + s.parse::() + .map(|timestamp| Time::millis(timestamp.as_millisecond())) + } + + /// Returns an RFC 3339 and ISO 8601 date and time string such as + /// 1996-12-19T16:39:57+00:00. + /// + /// Values outside jiff's timestamp range, such as `Time::MAX`, are + /// formatted as "∞". + /// + /// # Example + /// + /// ``` + /// use tinytime::Time; + /// assert_eq!("∞", Time::MAX.to_rfc3339()); + /// ``` + #[must_use] + pub fn to_rfc3339(self) -> String { + self.format("%Y-%m-%dT%H:%M:%S+00:00").to_string() + } +} + +impl Display for Time { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + let rfc3339_string = self.to_rfc3339(); + write!(f, "{rfc3339_string}") + } +} + +impl Display for TimeWindow { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "[{}, {}]", self.start, self.end) + } +} + +impl From for Time { + fn from(timestamp: Timestamp) -> Self { + Time::millis(timestamp.as_millisecond()) + } +} + +impl From for Duration { + fn from(duration: SignedDuration) -> Self { + debug_assert!( + i64::try_from(duration.as_millis()).is_ok(), + "Input jiff::SignedDuration ({duration:?}) is too large to be converted to tinytime::Duration" + ); + #[expect(clippy::cast_possible_truncation, reason = "expected behavior")] + Duration::millis(duration.as_millis() as i64) + } +} diff --git a/src/lib.rs b/src/lib.rs index 7a350a6..7331ff0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,8 +34,8 @@ //! ``` -#[cfg(feature = "chrono")] -pub mod chrono; +#[cfg(feature = "jiff")] +pub mod jiff; #[cfg(feature = "rand")] pub mod rand; #[cfg(feature = "serde")] @@ -1487,6 +1487,7 @@ mod time_test { use crate::Duration; use crate::Time; + #[cfg(feature = "jiff")] #[test] fn test_display() { struct TestCase { @@ -1518,7 +1519,7 @@ mod time_test { TestCase { name: "very large", input: Time::seconds(i64::from(i32::MAX) * 3500), - expected: "+240148-08-31T19:28:20+00:00".to_string(), + expected: "∞".to_string(), }, TestCase { name: "MAX", @@ -1661,6 +1662,7 @@ mod duration_test { assert_eq!("-1m1s", Duration::seconds(-61).to_string()); } + #[cfg(feature = "jiff")] #[test] fn test_time_window_display() { assert_eq!( diff --git a/src/serde.rs b/src/serde.rs index 6c4ab13..0e5fcdd 100644 --- a/src/serde.rs +++ b/src/serde.rs @@ -6,9 +6,10 @@ use serde::de::Error; use serde::de::Visitor; use crate::Duration; +#[cfg(feature = "jiff")] use crate::Time; -#[cfg(feature = "chrono")] +#[cfg(feature = "jiff")] impl Time { /// Helper method for deserializing an RFC 3339 string with `serde`. /// @@ -38,10 +39,10 @@ impl Time { } } -#[cfg(feature = "chrono")] +#[cfg(feature = "jiff")] struct TimeVisitor; -#[cfg(feature = "chrono")] +#[cfg(feature = "jiff")] impl Visitor<'_> for TimeVisitor { type Value = Time; From 9eebaa1b23f988a9429d039da7a4e423fa098530 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Mon, 15 Jun 2026 18:17:06 +0200 Subject: [PATCH 2/5] add -infty --- src/jiff.rs | 29 ++++++++++++++++++++--------- src/lib.rs | 2 +- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/jiff.rs b/src/jiff.rs index 08b302f..b128fba 100644 --- a/src/jiff.rs +++ b/src/jiff.rs @@ -17,7 +17,8 @@ pub struct FormattedTime<'a> { enum FormattedTimeInner<'a> { Finite(strtime::Display<'a>), - Infinity, + PlusInfinity, + MinusInfinity, } impl fmt::Debug for FormattedTime<'_> { @@ -30,7 +31,8 @@ impl Display for FormattedTime<'_> { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { match &self.inner { FormattedTimeInner::Finite(value) => Display::fmt(value, f), - FormattedTimeInner::Infinity => f.write_str("∞"), + FormattedTimeInner::PlusInfinity => f.write_str("∞"), + FormattedTimeInner::MinusInfinity => f.write_str("-∞"), } } } @@ -40,19 +42,26 @@ impl Time { /// [`jiff::Timestamp::strftime()`]. /// /// Values outside jiff's timestamp range, such as `Time::MAX`, are - /// formatted as "∞". + /// formatted as "∞" or "-∞". /// /// # Example /// /// ``` /// use tinytime::Time; /// assert_eq!("∞", Time::MAX.format("whatever").to_string()); + /// assert_eq!("-∞", Time::millis(i64::MIN).format("whatever").to_string()); /// ``` #[must_use] pub fn format<'a>(&self, fmt: &'a str) -> FormattedTime<'a> { let inner = match Timestamp::from_millisecond(self.0) { Ok(timestamp) => FormattedTimeInner::Finite(timestamp.strftime(fmt)), - Err(_) => FormattedTimeInner::Infinity, + Err(_) => { + if self.0.is_positive() { + FormattedTimeInner::PlusInfinity + } else { + FormattedTimeInner::MinusInfinity + } + } }; FormattedTime { inner } @@ -74,7 +83,7 @@ impl Time { /// Time::parse_from_rfc3339("1970-01-01T02:51:07.123999Z").unwrap() /// ); /// ``` - pub fn parse_from_rfc3339(s: &str) -> Result { + pub fn parse_from_rfc3339(s: &str) -> Result { s.parse::() .map(|timestamp| Time::millis(timestamp.as_millisecond())) } @@ -83,13 +92,14 @@ impl Time { /// 1996-12-19T16:39:57+00:00. /// /// Values outside jiff's timestamp range, such as `Time::MAX`, are - /// formatted as "∞". + /// formatted as "∞" or "-∞". /// /// # Example /// /// ``` /// use tinytime::Time; /// assert_eq!("∞", Time::MAX.to_rfc3339()); + /// assert_eq!("-∞", Time::millis(i64::MIN).to_rfc3339()); /// ``` #[must_use] pub fn to_rfc3339(self) -> String { @@ -118,11 +128,12 @@ impl From for Time { impl From for Duration { fn from(duration: SignedDuration) -> Self { + let millis = duration.as_millis(); + let millis_conv_result = i64::try_from(millis); debug_assert!( - i64::try_from(duration.as_millis()).is_ok(), + millis_conv_result.is_ok(), "Input jiff::SignedDuration ({duration:?}) is too large to be converted to tinytime::Duration" ); - #[expect(clippy::cast_possible_truncation, reason = "expected behavior")] - Duration::millis(duration.as_millis() as i64) + Duration::millis(millis_conv_result.unwrap_or(if millis < 0 { i64::MIN } else { i64::MAX })) } } diff --git a/src/lib.rs b/src/lib.rs index 7331ff0..8bb6fa9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1534,7 +1534,7 @@ mod time_test { TestCase { name: "i64::MIN", input: Time::millis(i64::MIN), - expected: "∞".to_string(), + expected: "-∞".to_string(), }, ]; for test in tests { From a88e5a25dd3e2714a4581ddace14498d05be11a4 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Mon, 15 Jun 2026 18:35:57 +0200 Subject: [PATCH 3/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2478ddb..897e547 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - [**breaking**] Replace the optional `chrono` feature with a `jiff` feature. - +- [**breaking**] When the `jiff` feature is enabled, formatting `Time` values outside jiff's supported timestamp range now yields "∞" / "-∞" (instead of a formatted date or always "∞"). ## [0.15.0](https://github.com/moia-oss/tinytime.rs/compare/v0.14.4...v0.15.0) - 2026-03-09 ### Other From bb15446f1ced4f7e851d939566d742a09f41b80e Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Mon, 15 Jun 2026 19:58:22 +0200 Subject: [PATCH 4/5] Avoid allocation in Time display --- src/jiff.rs | 7 ++++--- src/lib.rs | 6 ++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/jiff.rs b/src/jiff.rs index b128fba..c2775ff 100644 --- a/src/jiff.rs +++ b/src/jiff.rs @@ -10,6 +10,8 @@ use crate::Duration; use crate::Time; use crate::TimeWindow; +const RFC3339_FORMAT: &str = "%Y-%m-%dT%H:%M:%S+00:00"; + /// A displayable formatted [`Time`]. pub struct FormattedTime<'a> { inner: FormattedTimeInner<'a>, @@ -103,14 +105,13 @@ impl Time { /// ``` #[must_use] pub fn to_rfc3339(self) -> String { - self.format("%Y-%m-%dT%H:%M:%S+00:00").to_string() + self.format(RFC3339_FORMAT).to_string() } } impl Display for Time { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - let rfc3339_string = self.to_rfc3339(); - write!(f, "{rfc3339_string}") + Display::fmt(&self.format(RFC3339_FORMAT), f) } } diff --git a/src/lib.rs b/src/lib.rs index 8bb6fa9..fb1c040 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1550,6 +1550,12 @@ mod time_test { "format failed for test '{}'", test.name ); + assert_eq!( + test.expected, + test.input.to_string(), + "Display failed for test '{}'", + test.name + ); } } From 4cfd9212ddcb52751d8a733f9e6aa08413f41d79 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Mon, 15 Jun 2026 20:06:45 +0200 Subject: [PATCH 5/5] Add RFC3339 parsing coverage --- src/jiff.rs | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/jiff.rs b/src/jiff.rs index c2775ff..83a25a4 100644 --- a/src/jiff.rs +++ b/src/jiff.rs @@ -138,3 +138,58 @@ impl From for Duration { Duration::millis(millis_conv_result.unwrap_or(if millis < 0 { i64::MIN } else { i64::MAX })) } } + +#[cfg(test)] +mod tests { + use crate::Duration; + use crate::Time; + + #[test] + fn test_parse_from_rfc3339() { + struct TestCase { + name: &'static str, + input: &'static str, + expected: Time, + } + + let tests = vec![ + TestCase { + name: "EPOCH Z", + input: "1970-01-01T00:00:00Z", + expected: Time::EPOCH, + }, + TestCase { + name: "EPOCH UTC offset", + input: "1970-01-01T00:00:00+00:00", + expected: Time::EPOCH, + }, + TestCase { + name: "positive offset", + input: "1970-01-01T01:00:00+01:00", + expected: Time::EPOCH, + }, + TestCase { + name: "negative offset", + input: "1969-12-31T23:00:00-01:00", + expected: Time::EPOCH, + }, + TestCase { + name: "sub-millisecond truncation", + input: "1970-01-01T02:51:07.123999Z", + expected: Time::hours(2) + + Duration::minutes(51) + + Duration::seconds(7) + + Duration::millis(123), + }, + ]; + + for test in tests { + assert_eq!( + Ok(test.expected), + Time::parse_from_rfc3339(test.input).map_err(|error| error.to_string()), + "parse_from_rfc3339 failed for test '{}'", + test.name + ); + } + } +}