diff --git a/.changes/unreleased/added-20260629-144131.yaml b/.changes/unreleased/added-20260629-144131.yaml new file mode 100644 index 00000000..081dcb9a --- /dev/null +++ b/.changes/unreleased/added-20260629-144131.yaml @@ -0,0 +1,8 @@ +kind: Added +body: |- + **`jiff` feature for `buffa-types`** (#264). + Adds conversions between the well-known types and [jiff](https://docs.rs/jiff): + `Timestamp` ↔ `jiff::Timestamp` and `Duration` ↔ `jiff::SignedDuration`. Gated + behind the new `jiff` Cargo feature and `no_std`-compatible (`jiff` is pulled + with `default-features = false` + `alloc`). +time: 2026-06-29T14:41:31.006126085+08:00 diff --git a/Cargo.lock b/Cargo.lock index 9e6dbcbe..c24c7774 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,6 +50,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.10.0" @@ -153,6 +159,7 @@ dependencies = [ "bytes", "chrono", "criterion", + "jiff", "serde", "serde_json", "thiserror", @@ -352,6 +359,38 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "defmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -502,6 +541,31 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jiff" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34f877a98676d2fb664698d74cc6a51ce6c484ce8c770f05d0108ec9090aeb46" +dependencies = [ + "defmt", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0666b5ab5ecaca213fc2a85b8c0083d9004e84ee2d5f9a7e0017aaf50986f25f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "js-sys" version = "0.3.95" @@ -591,6 +655,21 @@ dependencies = [ "plotters-backend", ] +[[package]] +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 = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -601,6 +680,28 @@ dependencies = [ "syn", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -696,7 +797,7 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys", @@ -990,7 +1091,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.10.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -1088,7 +1189,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.10.0", "indexmap", "log", "serde", diff --git a/Cargo.toml b/Cargo.toml index a9f16172..e1188fe1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,6 +69,11 @@ bytes = { version = "1", default-features = false } # 0.4.31 fails to compile this crate's `chrono` feature, 0.4.34 succeeds. chrono = { version = "0.4.34", default-features = false } foldhash = { version = "0.1", default-features = false } +# `jiff` interop for `Timestamp` / `Duration` (the `buffa-types` `jiff` +# feature). Pulled with `default-features = false` + `alloc` so the conversions +# stay `no_std`; `buffa-types`' `std` feature layers `jiff/std` on top. MSRV +# 1.70 as of 0.2 — within buffa's pin. +jiff = { version = "0.2", default-features = false, features = ["alloc"] } hashbrown = { version = "0.15", default-features = false, features = ["default-hasher"] } once_cell = { version = "1", default-features = false } rustversion = { version = "1" } diff --git a/Taskfile.yml b/Taskfile.yml index 85564a78..39c470f1 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -749,6 +749,7 @@ tasks: - cargo check -p buffa --no-default-features --target thumbv7em-none-eabihf - cargo check -p buffa-types --no-default-features - cargo check -p buffa-types --no-default-features --features chrono + - cargo check -p buffa-types --no-default-features --features jiff - cargo check -p buffa-descriptor --no-default-features # ── Coverage ───────────────────────────────────────────────────────── diff --git a/buffa-test/Cargo.toml b/buffa-test/Cargo.toml index c084c710..b9072183 100644 --- a/buffa-test/Cargo.toml +++ b/buffa-test/Cargo.toml @@ -17,7 +17,7 @@ buffa = { workspace = true, features = ["std", "json", "text"] } # generated with `generate_text(true)` and the pool decodes the WKT # descriptors that need it. buffa-descriptor = { workspace = true, features = ["reflect", "json", "std"] } -buffa-types = { workspace = true, features = ["json", "chrono"] } +buffa-types = { workspace = true, features = ["json", "chrono", "jiff"] } # The SmolStr preset crate, used by the string_type fixtures and as the template # for the crate-local newtypes in `reprs`. buffa-smolstr = { workspace = true, features = ["serde"] } diff --git a/buffa-types/Cargo.toml b/buffa-types/Cargo.toml index f1d05c42..99a5662a 100644 --- a/buffa-types/Cargo.toml +++ b/buffa-types/Cargo.toml @@ -15,7 +15,7 @@ rustdoc-args = ["--cfg", "docsrs"] [features] default = ["std"] -std = ["buffa/std", "thiserror/std", "serde?/std", "serde_json?/std"] +std = ["buffa/std", "thiserror/std", "serde?/std", "serde_json?/std", "jiff?/std"] json = ["dep:serde", "dep:serde_json", "buffa/json"] arbitrary = ["dep:arbitrary", "buffa/arbitrary"] # Runtime reflection for the WKT views (`impl ReflectMessage for TimestampView`, @@ -26,6 +26,10 @@ reflect = ["dep:buffa-descriptor", "buffa-descriptor/reflect", "buffa-descriptor # Conversions between WKT `Timestamp` / `Duration` and `chrono::DateTime` / # `chrono::TimeDelta`. `no_std`-compatible. chrono = ["dep:chrono"] +# Conversions between WKT `Timestamp` / `Duration` and `jiff::Timestamp` / +# `jiff::SignedDuration`. `no_std`-compatible (`jiff` is pulled with +# `default-features = false` + `alloc`); the `std` feature adds `jiff/std`. +jiff = ["dep:jiff"] [dependencies] arbitrary = { workspace = true, optional = true } @@ -39,6 +43,7 @@ buffa-descriptor = { workspace = true, optional = true } # `::bytes::Bytes` paths, so the crate is referenced directly. bytes = { workspace = true } chrono = { workspace = true, optional = true } +jiff = { workspace = true, optional = true } serde = { workspace = true, optional = true } serde_json = { workspace = true, optional = true } thiserror = { workspace = true } diff --git a/buffa-types/src/duration_jiff.rs b/buffa-types/src/duration_jiff.rs new file mode 100644 index 00000000..bd5e02be --- /dev/null +++ b/buffa-types/src/duration_jiff.rs @@ -0,0 +1,215 @@ +//! `jiff` interop for [`google::protobuf::Duration`](crate::google::protobuf::Duration). +//! +//! Enabled with the `jiff` Cargo feature. The proto `Duration` maps to +//! [`jiff::SignedDuration`] — jiff's fixed (non-calendar) signed duration, +//! whose sign-consistent `seconds` + sub-second `nanos` representation matches +//! proto's exactly. `no_std`-compatible. +//! +//! [`jiff::Span`](jiff::Span) is deliberately *not* a conversion target: a +//! `Span` carries calendar units (years/months/days) whose length is only +//! defined relative to a reference date, whereas a proto `Duration` is an +//! absolute elapsed time. `SignedDuration` is the faithful analog. + +use crate::google::protobuf::Duration; + +/// Errors that can occur when converting a protobuf [`Duration`] to a +/// [`jiff::SignedDuration`]. +/// +/// Unlike the `chrono` conversion's +/// [`DurationChronoError`](crate::DurationChronoError), this has no `Overflow` +/// mode: a validated `nanos` (`|nanos| < 1_000_000_000`) never carries into the +/// seconds field, and both types store `seconds` as `i64`, so every well-formed +/// proto `Duration` maps into [`jiff::SignedDuration`] in range. The only +/// failure is a malformed `nanos` field. +/// +/// This enum is `#[non_exhaustive]`: `match` arms over it must include a +/// wildcard arm. +#[cfg_attr(docsrs, doc(cfg(feature = "jiff")))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum DurationJiffError { + /// The `nanos` field is outside `[-999_999_999, 999_999_999]` or its sign + /// is inconsistent with `seconds`. + #[error("nanos field has invalid value or sign mismatch with seconds")] + InvalidNanos, +} + +#[cfg_attr(docsrs, doc(cfg(feature = "jiff")))] +impl From for Duration { + /// Convert a [`jiff::SignedDuration`] to a protobuf [`Duration`]. + /// + /// Infallible: both types represent a signed duration as a sign-consistent + /// `seconds` + sub-second `nanos` pair, so this is a direct field copy. + /// + /// # Warning: proto JSON spec range + /// + /// `jiff::SignedDuration` ranges to ±`i64::MAX` seconds (~2.9e11 years), + /// while the proto spec restricts `Duration` to ±315,576,000,000 seconds + /// (~10,000 years). A `SignedDuration` beyond that converts without error + /// here — binary encoding round-trips it — but the resulting `Duration` + /// will fail JSON serialization (`json` feature), which enforces the spec + /// range. + /// + /// # Examples + /// + /// ``` + /// use buffa_types::Duration; + /// + /// let sd = jiff::SignedDuration::new(1, 500_000_000); + /// let proto: Duration = sd.into(); + /// assert_eq!(proto.seconds, 1); + /// assert_eq!(proto.nanos, 500_000_000); + /// ``` + fn from(d: jiff::SignedDuration) -> Self { + Self { + seconds: d.as_secs(), + nanos: d.subsec_nanos(), + ..Default::default() + } + } +} + +#[cfg_attr(docsrs, doc(cfg(feature = "jiff")))] +impl TryFrom for jiff::SignedDuration { + type Error = DurationJiffError; + + /// Convert a protobuf [`Duration`] to a [`jiff::SignedDuration`]. + /// + /// # Examples + /// + /// ``` + /// use buffa_types::Duration; + /// + /// let proto = Duration { + /// seconds: 2, + /// nanos: 250_000_000, + /// ..Default::default() + /// }; + /// let sd: jiff::SignedDuration = proto.try_into().unwrap(); + /// assert_eq!(sd, jiff::SignedDuration::new(2, 250_000_000)); + /// ``` + /// + /// # Errors + /// + /// Returns [`DurationJiffError::InvalidNanos`] if `nanos` is outside + /// `[-999_999_999, 999_999_999]` or if its sign is inconsistent with + /// `seconds`. Such values never come from the [`From`] + /// impl, but `seconds` and `nanos` are independent wire fields, so a decoded + /// `Duration` can carry any combination — the proto spec declares + /// sign-mismatched ones invalid, and this conversion rejects them rather + /// than letting `SignedDuration::new` silently re-normalize them. + fn try_from(d: Duration) -> Result { + if !(-999_999_999..=999_999_999).contains(&d.nanos) { + return Err(DurationJiffError::InvalidNanos); + } + let sign_mismatch = (d.seconds > 0 && d.nanos < 0) || (d.seconds < 0 && d.nanos > 0); + if sign_mismatch { + return Err(DurationJiffError::InvalidNanos); + } + + // `|nanos| < 1_000_000_000`, so `new` performs no second-carry and + // cannot overflow i64. + Ok(jiff::SignedDuration::new(d.seconds, d.nanos)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn positive_roundtrip() { + let sd = jiff::SignedDuration::new(300, 500_000_000); + let proto: Duration = sd.into(); + assert_eq!(proto.seconds, 300); + assert_eq!(proto.nanos, 500_000_000); + let back: jiff::SignedDuration = proto.try_into().unwrap(); + assert_eq!(back, sd); + } + + #[test] + fn zero_roundtrip() { + let sd = jiff::SignedDuration::ZERO; + let proto: Duration = sd.into(); + assert_eq!(proto.seconds, 0); + assert_eq!(proto.nanos, 0); + let back: jiff::SignedDuration = proto.try_into().unwrap(); + assert_eq!(back, sd); + } + + #[test] + fn negative_roundtrip() { + // -1.5 seconds: jiff keeps both components negative, matching proto. + let sd = jiff::SignedDuration::new(-1, -500_000_000); + let proto: Duration = sd.into(); + assert_eq!(proto.seconds, -1); + assert_eq!(proto.nanos, -500_000_000); + let back: jiff::SignedDuration = proto.try_into().unwrap(); + assert_eq!(back, sd); + } + + #[test] + fn sub_second_negative_roundtrip() { + let sd = jiff::SignedDuration::new(0, -500_000_000); + let proto: Duration = sd.into(); + assert_eq!(proto.seconds, 0); + assert_eq!(proto.nanos, -500_000_000); + let back: jiff::SignedDuration = proto.try_into().unwrap(); + assert_eq!(back, sd); + } + + #[test] + fn invalid_nanos_rejected() { + let bad = Duration { + seconds: 1, + nanos: 1_000_000_000, + ..Default::default() + }; + let r: Result = bad.try_into(); + assert_eq!(r, Err(DurationJiffError::InvalidNanos)); + } + + #[test] + fn nanos_i32_min_is_invalid() { + let bad = Duration { + seconds: 0, + nanos: i32::MIN, + ..Default::default() + }; + let r: Result = bad.try_into(); + assert_eq!(r, Err(DurationJiffError::InvalidNanos)); + } + + #[test] + fn sign_mismatch_rejected() { + let bad = Duration { + seconds: 5, + nanos: -1, + ..Default::default() + }; + let r: Result = bad.try_into(); + assert_eq!(r, Err(DurationJiffError::InvalidNanos)); + + let bad2 = Duration { + seconds: -5, + nanos: 1, + ..Default::default() + }; + let r2: Result = bad2.try_into(); + assert_eq!(r2, Err(DurationJiffError::InvalidNanos)); + } + + #[test] + fn signed_duration_extremes_roundtrip() { + // `SignedDuration` spans ±i64::MAX seconds — wider than proto Duration's + // spec range, but proto's `seconds` is also i64, so the binary form + // round-trips both extremes exactly (no Overflow mode). + for sd in [jiff::SignedDuration::MAX, jiff::SignedDuration::MIN] { + let proto: Duration = sd.into(); + assert_eq!(proto.seconds, sd.as_secs()); + assert_eq!(proto.nanos, sd.subsec_nanos()); + let back: jiff::SignedDuration = proto.try_into().unwrap(); + assert_eq!(back, sd); + } + } +} diff --git a/buffa-types/src/lib.rs b/buffa-types/src/lib.rs index e020a596..1feddb24 100644 --- a/buffa-types/src/lib.rs +++ b/buffa-types/src/lib.rs @@ -36,6 +36,8 @@ //! - `Timestamp` ↔ [`chrono::DateTime`] (requires `chrono` feature; any time //! zone in, `Utc` out) //! - `Duration` ↔ [`chrono::TimeDelta`] (requires `chrono` feature) +//! - `Timestamp` ↔ [`jiff::Timestamp`] (requires `jiff` feature) +//! - `Duration` ↔ [`jiff::SignedDuration`] (requires `jiff` feature) //! - `Any::pack` / `Any::unpack` helpers //! - `Value` constructors: [`Value::null`](google::protobuf::Value::null), `From`, `From`, `From`, etc. //! - Wrapper type `From`/`Into` impls @@ -49,6 +51,9 @@ //! - **`chrono`** — `Timestamp` ↔ `chrono::DateTime` and `Duration` ↔ //! `chrono::TimeDelta` conversions. `no_std`-compatible (`chrono` is pulled //! with `default-features = false`). +//! - **`jiff`** — `Timestamp` ↔ `jiff::Timestamp` and `Duration` ↔ +//! `jiff::SignedDuration` conversions. `no_std`-compatible (`jiff` is pulled +//! with `default-features = false` + `alloc`). //! - **`reflect`** — runtime reflection: the WKT view types implement //! `buffa_descriptor::reflect::ReflectMessage`, so a message that has a WKT //! field can reflect over it. This pulls a `buffa-descriptor` dependency and @@ -77,6 +82,11 @@ mod duration_chrono; #[cfg(feature = "chrono")] mod timestamp_chrono; +#[cfg(feature = "jiff")] +mod duration_jiff; +#[cfg(feature = "jiff")] +mod timestamp_jiff; + // Well-known type Rust structs — generated once by `gen_wkt_types`, checked // into src/generated/. These protos are Google-owned and frozen; regeneration // is only needed when buffa-codegen's output format changes. See the @@ -116,6 +126,10 @@ pub use timestamp_ext::TimestampError; #[cfg_attr(docsrs, doc(cfg(feature = "chrono")))] pub use duration_chrono::DurationChronoError; +#[cfg(feature = "jiff")] +#[cfg_attr(docsrs, doc(cfg(feature = "jiff")))] +pub use duration_jiff::DurationJiffError; + // Re-export the WKT registry function for `Any` JSON + text support. pub use any_ext::register_wkt_types; diff --git a/buffa-types/src/timestamp_jiff.rs b/buffa-types/src/timestamp_jiff.rs new file mode 100644 index 00000000..d960b83a --- /dev/null +++ b/buffa-types/src/timestamp_jiff.rs @@ -0,0 +1,217 @@ +//! `jiff` interop for [`google::protobuf::Timestamp`](crate::google::protobuf::Timestamp). +//! +//! Enabled with the `jiff` Cargo feature. `no_std`-compatible — `jiff` is +//! pulled in with `default-features = false` and its `alloc` feature. + +use crate::google::protobuf::Timestamp; +use crate::timestamp_ext::{TimestampError, NANOS_MAX}; + +#[cfg_attr(docsrs, doc(cfg(feature = "jiff")))] +impl From for Timestamp { + /// Convert a [`jiff::Timestamp`] to a protobuf [`Timestamp`]. + /// + /// Infallible: every [`jiff::Timestamp`] fits the *binary* proto + /// `Timestamp` range (proto allows any `i64` second; jiff spans + /// ≈ years -9999 through 9999, a strict subset). + /// + /// # Warning: proto JSON spec range + /// + /// `jiff::Timestamp` reaches back to ≈ year -9999, but the proto JSON spec + /// restricts `Timestamp` to years 0001–9999. A pre-year-1 instant converts + /// without error here and round-trips through binary encoding, but the + /// resulting `Timestamp` will fail JSON serialization (`json` feature), + /// which enforces the spec range. + /// + /// # Sign normalization + /// + /// [`jiff::Timestamp`] reports its sub-second component with the *same + /// sign* as the overall instant — a pre-epoch instant has a negative + /// [`subsec_nanosecond`](jiff::Timestamp::subsec_nanosecond) — whereas + /// proto `Timestamp.nanos` is always in `[0, 999_999_999]`. The conversion + /// re-normalizes by borrowing a second for negative sub-second components, + /// so `-1.5s` becomes `{ seconds: -2, nanos: 500_000_000 }`. + /// + /// # Examples + /// + /// ``` + /// use buffa_types::Timestamp; + /// + /// let jt = jiff::Timestamp::new(1_700_000_000, 123_456_789).unwrap(); + /// let ts: Timestamp = jt.into(); + /// assert_eq!(ts.seconds, 1_700_000_000); + /// assert_eq!(ts.nanos, 123_456_789); + /// ``` + fn from(ts: jiff::Timestamp) -> Self { + let seconds = ts.as_second(); + let nanos = ts.subsec_nanosecond(); + if nanos < 0 { + // `seconds` is >= jiff's MIN second (-377_705_023_201), so the + // borrow `seconds - 1` cannot underflow i64. + Self { + seconds: seconds - 1, + nanos: nanos + 1_000_000_000, + ..Default::default() + } + } else { + Self { + seconds, + nanos, + ..Default::default() + } + } + } +} + +#[cfg_attr(docsrs, doc(cfg(feature = "jiff")))] +impl TryFrom for jiff::Timestamp { + type Error = TimestampError; + + /// Convert a protobuf [`Timestamp`] to a [`jiff::Timestamp`]. + /// + /// # Examples + /// + /// ``` + /// use buffa_types::Timestamp; + /// + /// let ts = Timestamp { + /// seconds: 1_700_000_000, + /// nanos: 0, + /// ..Default::default() + /// }; + /// let jt: jiff::Timestamp = ts.try_into().unwrap(); + /// assert_eq!(jt.as_second(), 1_700_000_000); + /// ``` + /// + /// # Errors + /// + /// Returns [`TimestampError::InvalidNanos`] if `nanos` is outside + /// `[0, 999_999_999]`, or [`TimestampError::Overflow`] if the instant is + /// outside [`jiff::Timestamp`]'s representable range (≈ years -9999 through + /// 9999 — proto permits a far wider second range). + fn try_from(ts: Timestamp) -> Result { + if ts.nanos < 0 || ts.nanos > NANOS_MAX { + return Err(TimestampError::InvalidNanos); + } + // Nanos validated above, so the only remaining failure is an + // out-of-range second. + jiff::Timestamp::new(ts.seconds, ts.nanos).map_err(|_| TimestampError::Overflow) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn post_epoch_roundtrip() { + let jt = jiff::Timestamp::new(1_700_000_000, 123_456_789).unwrap(); + let ts: Timestamp = jt.into(); + assert_eq!(ts.seconds, 1_700_000_000); + assert_eq!(ts.nanos, 123_456_789); + let back: jiff::Timestamp = ts.try_into().unwrap(); + assert_eq!(back, jt); + } + + #[test] + fn epoch_roundtrip() { + let jt = jiff::Timestamp::new(0, 0).unwrap(); + let ts: Timestamp = jt.into(); + assert_eq!(ts.seconds, 0); + assert_eq!(ts.nanos, 0); + let back: jiff::Timestamp = ts.try_into().unwrap(); + assert_eq!(back, jt); + } + + #[test] + fn pre_epoch_borrows_second() { + // -1.5 seconds. jiff stores this sign-consistently as + // (as_second = -1, subsec_nanosecond = -500_000_000); the proto form + // must borrow a second to keep nanos non-negative. + let jt = jiff::Timestamp::new(-2, 500_000_000).unwrap(); + assert_eq!(jt.as_second(), -1); + assert_eq!(jt.subsec_nanosecond(), -500_000_000); + let ts: Timestamp = jt.into(); + assert_eq!(ts.seconds, -2); + assert_eq!(ts.nanos, 500_000_000); + let back: jiff::Timestamp = ts.try_into().unwrap(); + assert_eq!(back, jt); + } + + #[test] + fn exact_pre_epoch_second_roundtrip() { + // Whole second before the epoch: no borrow needed. + let jt = jiff::Timestamp::new(-2, 0).unwrap(); + let ts: Timestamp = jt.into(); + assert_eq!(ts.seconds, -2); + assert_eq!(ts.nanos, 0); + let back: jiff::Timestamp = ts.try_into().unwrap(); + assert_eq!(back, jt); + } + + #[test] + fn nanos_upper_boundary_roundtrip() { + let ts = Timestamp { + seconds: 5, + nanos: 999_999_999, + ..Default::default() + }; + let jt: jiff::Timestamp = ts.clone().try_into().expect("upper boundary converts"); + let back: Timestamp = jt.into(); + assert_eq!(back, ts); + } + + #[test] + fn invalid_nanos_rejected() { + let neg = Timestamp { + seconds: 0, + nanos: -1, + ..Default::default() + }; + let r: Result = neg.try_into(); + assert_eq!(r, Err(TimestampError::InvalidNanos)); + + let too_big = Timestamp { + seconds: 0, + nanos: 1_000_000_000, + ..Default::default() + }; + let r2: Result = too_big.try_into(); + assert_eq!(r2, Err(TimestampError::InvalidNanos)); + } + + #[test] + fn out_of_range_seconds_is_overflow() { + // proto Timestamp spans the full i64 second range; jiff caps at + // ≈ year 9999, so i64::MAX seconds overflows. + let huge = Timestamp { + seconds: i64::MAX, + nanos: 0, + ..Default::default() + }; + let r: Result = huge.try_into(); + assert_eq!(r, Err(TimestampError::Overflow)); + + let tiny = Timestamp { + seconds: i64::MIN, + nanos: 0, + ..Default::default() + }; + let r2: Result = tiny.try_into(); + assert_eq!(r2, Err(TimestampError::Overflow)); + } + + #[test] + fn jiff_extremes_roundtrip() { + // Both ends of jiff's representable range survive the proto roundtrip. + for jt in [jiff::Timestamp::MIN, jiff::Timestamp::MAX] { + let ts: Timestamp = jt.into(); + assert!( + (0..=NANOS_MAX).contains(&ts.nanos), + "nanos must stay within proto invariant: got {}", + ts.nanos + ); + let back: jiff::Timestamp = ts.try_into().expect("jiff extreme must convert back"); + assert_eq!(back, jt); + } + } +}