diff --git a/Cargo.lock b/Cargo.lock index a2c922cd2..fed7a841a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2471,6 +2471,7 @@ dependencies = [ "bytes", "chrono", "google-cloud-wkt", + "jiff", "serde", "serde_json", "serde_with", @@ -2901,6 +2902,18 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" +[[package]] +name = "jiff" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba926fdd8e5b5e7f9700355b0831d8c416afe94b014b1023424037a187c9c582" +dependencies = [ + "log", + "portable-atomic", + "portable-atomic-util", + "serde", +] + [[package]] name = "jobserver" version = "0.1.32" @@ -3313,6 +3326,21 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +[[package]] +name = "portable-atomic" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "280dc24453071f1b63954171985a0b0d30058d287960968b9b2aca264c8d4ee6" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + [[package]] name = "powerfmt" version = "0.2.0" diff --git a/src/wkt/Cargo.toml b/src/wkt/Cargo.toml index 59a95c2a8..e226cc144 100644 --- a/src/wkt/Cargo.toml +++ b/src/wkt/Cargo.toml @@ -25,11 +25,13 @@ version = "0.1.1" [features] chrono = ["dep:chrono"] +jiff = ["dep:jiff"] time = [] [dependencies] bytes = { version = "1.10.0", features = ["serde"] } chrono = { version = "0.4.39", optional = true } +jiff = { version = "0.2.0", default-features = false, features = ["std"], optional = true } serde = { version = "1.0.217", features = ["serde_derive"] } serde_json = "1" serde_with = { version = "3.12.0", default-features = false, features = ["base64", "macros", "std"] } @@ -39,4 +41,4 @@ time = { version = "0.3.36", features = ["formatting", "parsing"] } [dev-dependencies] bytes = { version = "1.10.0", features = ["serde"] } test-case = "3.3.1" -wkt = { path = ".", package = "google-cloud-wkt", features = ["chrono", "time"] } +wkt = { path = ".", package = "google-cloud-wkt", features = ["chrono", "jiff", "time"] } diff --git a/src/wkt/src/duration.rs b/src/wkt/src/duration.rs index 961d9589d..576a7d9d4 100644 --- a/src/wkt/src/duration.rs +++ b/src/wkt/src/duration.rs @@ -302,6 +302,28 @@ impl std::convert::From for chrono::Duration { } } +/// Converts from [jiff::SignedDuration] to [Duration]. +/// +/// This conversion may fail if the [jiff::SignedDuration] value is out of range. +#[cfg(feature = "jiff")] +impl std::convert::TryFrom for Duration { + type Error = DurationError; + + fn try_from(value: jiff::SignedDuration) -> Result { + Self::new(value.as_secs(), value.subsec_nanos()) + } +} + +/// Converts from [Duration] to [jiff::SignedDuration]. +#[cfg(feature = "jiff")] +impl std::convert::From for jiff::SignedDuration { + fn from(value: Duration) -> Self { + // Safety: The range of jiff::SignedDuration is larger than Duration, + // so this will not overflow. + Self::new(value.seconds, value.nanos) + } +} + /// Implement [`serde`](::serde) serialization for [Duration]. impl serde::ser::Serialize for Duration { fn serialize(&self, serializer: S) -> Result @@ -544,4 +566,36 @@ mod test { let got = Duration::try_from(value); assert_eq!(got, Err(DurationError::OutOfRange())); } + + #[test_case(jiff::SignedDuration::default(), Duration::default() ; "default")] + #[test_case(jiff::SignedDuration::ZERO, Duration::new(0, 0).unwrap() ; "zero")] + #[test_case(jiff::SignedDuration::from_secs(10_000 * SECONDS_IN_YEAR), Duration::new(10_000 * SECONDS_IN_YEAR, 0).unwrap() ; "exactly 10,000 years")] + #[test_case(jiff::SignedDuration::from_secs(-10_000 * SECONDS_IN_YEAR), Duration::new(-10_000 * SECONDS_IN_YEAR, 0).unwrap() ; "exactly negative 10,000 years")] + #[test_case(jiff::SignedDuration::new(-5, -500_000), Duration::new(-5, -500_000).unwrap() ; "negative seconds and nanos")] + #[test_case(jiff::SignedDuration::new(5, 500_000), Duration::new(5, 500_000).unwrap() ; "positive seconds and nanos")] + fn from_jiff_time_in_range(value: jiff::SignedDuration, want: Duration) -> Result { + let got = Duration::try_from(value)?; + assert_eq!(got, want); + Ok(()) + } + + #[test_case(Duration::default(), jiff::SignedDuration::default() ; "default")] + #[test_case(Duration::new(0, 0).unwrap(), jiff::SignedDuration::ZERO ; "zero")] + #[test_case(Duration::new(10_000 * SECONDS_IN_YEAR , 0).unwrap(), jiff::SignedDuration::from_secs(10_000 * SECONDS_IN_YEAR) ; "exactly 10,000 years")] + #[test_case(Duration::new(-10_000 * SECONDS_IN_YEAR , 0).unwrap(), jiff::SignedDuration::from_secs(-10_000 * SECONDS_IN_YEAR) ; "exactly negative 10,000 years")] + #[test_case(Duration::new(-5, -500_000).unwrap(), jiff::SignedDuration::new(-5, -500_000) ; "negative seconds and nanos")] + #[test_case(Duration::new(5, 500_000).unwrap(), jiff::SignedDuration::new(5, 500_000) ; "positive seconds and nanos")] + fn to_jiff_time_in_range(value: Duration, want: jiff::SignedDuration) -> Result { + let got = jiff::SignedDuration::from(value); + assert_eq!(got, want); + Ok(()) + } + + #[test_case(jiff::SignedDuration::new(10_001 * SECONDS_IN_YEAR, 0) ; "above the range")] + #[test_case(jiff::SignedDuration::new(-10_001 * SECONDS_IN_YEAR, 0) ; "below the range")] + fn from_jiff_time_out_of_range(value: jiff::SignedDuration) -> Result { + let got = Duration::try_from(value); + assert_eq!(got, Err(DurationError::OutOfRange())); + Ok(()) + } } diff --git a/src/wkt/src/timestamp.rs b/src/wkt/src/timestamp.rs index 7c08da4a6..6acb4a8bd 100644 --- a/src/wkt/src/timestamp.rs +++ b/src/wkt/src/timestamp.rs @@ -298,8 +298,41 @@ impl TryFrom for chrono::DateTime { } } +/// Converts from [jiff::Timestamp] to [Timestamp]. +/// +/// This conversion may fail if the [jiff::Timestamp] value is out of range. +#[cfg(feature = "jiff")] +impl TryFrom for Timestamp { + type Error = TimestampError; + + fn try_from(value: jiff::Timestamp) -> std::result::Result { + let ns = value.subsec_nanosecond(); + + // Jiff nanosecond component is negative before the Unix epoch. + if ns < 0 { + Timestamp::new(value.as_second() - 1, ns + 1_000_000_000) + } else { + Timestamp::new(value.as_second(), ns) + } + } +} + +/// Converts from [Timestamp] to [jiff::Timestamp]. +/// +/// This conversion may fail if the [Timestamp] value is out of range. +#[cfg(feature = "jiff")] +impl TryFrom for jiff::Timestamp { + type Error = jiff::Error; + + fn try_from(value: Timestamp) -> std::result::Result { + jiff::Timestamp::new(value.seconds, value.nanos) + } +} + #[cfg(test)] mod test { + use std::str::FromStr; + use super::*; use serde_json::json; use test_case::test_case; @@ -391,4 +424,58 @@ mod test { assert!(msg.contains("RFC 3339"), "message={}", msg); Ok(()) } + + #[test_case(jiff::Timestamp::default(), Timestamp::default() ; "default")] + #[test_case(jiff::Timestamp::new(0, 0).unwrap(), Timestamp::new(0, 0).unwrap() ; "zero")] + #[test_case(jiff::Timestamp::MAX, Timestamp::new(253402207200, Timestamp::MAX_NANOS).unwrap() ; "maximum")] + #[test_case(jiff::Timestamp::from_str("0001-01-01T00:00:00Z").unwrap(), Timestamp::new(Timestamp::MIN_SECONDS, Timestamp::MIN_NANOS).unwrap() ; "minimum")] + #[test_case(jiff::Timestamp::new(-4, -500_000_000).unwrap(), Timestamp::new(-5, 500_000_000).unwrap() ; "negative seconds and nanos")] + #[test_case(jiff::Timestamp::new(5, 500_000_000).unwrap(), Timestamp::new(5, 500_000_000).unwrap() ; "positive seconds and nanos")] + fn from_jiff_time_in_range(value: jiff::Timestamp, want: Timestamp) -> Result { + let got = Timestamp::try_from(value)?; + assert_eq!(got, want); + + // Assert that both timestamps represent the same duration since (or before) the Epoch. + assert_eq!( + ((got.seconds as i128) * Timestamp::NS as i128) + got.nanos as i128, + value.as_duration().as_nanos() + ); + Ok(()) + } + + #[test_case(Timestamp::default(), jiff::Timestamp::default() ; "default")] + #[test_case(Timestamp::new(0, 0).unwrap(), jiff::Timestamp::new(0, 0).unwrap() ; "zero")] + #[test_case(Timestamp::new(253402207200, Timestamp::MAX_NANOS).unwrap(), jiff::Timestamp::MAX ; "maximum")] + #[test_case(Timestamp::new(Timestamp::MIN_SECONDS, Timestamp::MIN_NANOS).unwrap(), jiff::Timestamp::from_str("0001-01-01T00:00:00Z").unwrap() ; "minimum")] + #[test_case(Timestamp::new(-5, 500_000_000).unwrap(), jiff::Timestamp::new(-4, -500_000_000).unwrap() ; "negative seconds and nanos")] + #[test_case(Timestamp::new(5, 500_000_000).unwrap(), jiff::Timestamp::new(5, 500_000_000).unwrap() ; "positive seconds and nanos")] + fn to_jiff_time_in_range(value: Timestamp, want: jiff::Timestamp) -> Result { + let got = jiff::Timestamp::try_from(value.clone())?; + assert_eq!(got, want); + + // Assert that both timestamps represent the same duration since (or before) the Epoch. + assert_eq!( + ((value.seconds as i128) * Timestamp::NS as i128) + value.nanos as i128, + got.as_duration().as_nanos() + ); + Ok(()) + } + + // Jiff timestamps support years in BCE. + #[test_case(jiff::Timestamp::from_str("-000001-01-01T00:00:01Z").unwrap() ; "below the range")] + fn from_jiff_time_out_of_range(value: jiff::Timestamp) -> Result { + let got = Timestamp::try_from(value); + assert_eq!(got, Err(TimestampError::OutOfRange())); + Ok(()) + } + + // Jiff timestamps are slightly constrained in range, in order to allow + // valid civil times across all timezones, so the maximum possible value + // is a few hours shorter. + #[test_case(Timestamp::new(253402207201, 0).unwrap() ; "above the range")] + fn to_jiff_time_out_of_range(value: Timestamp) -> Result { + let got = jiff::Timestamp::try_from(value); + matches!(got, Err(_)); + Ok(()) + } }