//! Serde adaptors for timestamp fields: `i64` unix-epoch seconds in //! Rust, RFC 3339 UTC strings (`2026-07-02T18:30:00Z`) in JSON. //! //! Rust code keeps doing plain integer arithmetic on these fields — //! only the serialized representation changes, so the dashboard (and //! any other JSON consumer) can feed the value straight into //! `new Date(s)` without the `* 1000` epoch dance. //! //! Deserialization is lenient: both the RFC 3339 string form and the //! legacy bare-integer form are accepted. That keeps a rolling deploy //! safe (an old peer emitting epoch ints into a new reader) and lets //! previously persisted JSON blobs re-load unchanged. //! //! Usage: `#[serde(with = "crate::wire_time::iso")]` on `i64` fields, //! `#[serde(with = "crate::wire_time::iso_opt")]` on `Option` //! (keep the usual `default` + `skip_serializing_if` attributes). use chrono::{DateTime, SecondsFormat, Utc}; use serde::Deserialize; /// Format unix-epoch seconds as an RFC 3339 UTC string with a `Z` /// suffix. Out-of-range values (never produced by our clocks) clamp to /// the epoch rather than erroring — serialization must not fail. #[must_use] pub fn to_iso(secs: i64) -> String { DateTime::::from_timestamp(secs, 0) .unwrap_or_default() .to_rfc3339_opts(SecondsFormat::Secs, true) } /// Parse an RFC 3339 string back to unix-epoch seconds. Any UTC offset /// is accepted and normalized. pub fn from_iso(s: &str) -> Result { Ok(DateTime::parse_from_rfc3339(s)?.timestamp()) } /// Lenient wire form: either the legacy epoch integer or the RFC 3339 /// string. `untagged` tries the integer first (cheap), then the string. #[derive(Deserialize)] #[serde(untagged)] enum EpochOrIso { Epoch(i64), Iso(String), } impl EpochOrIso { fn into_secs(self) -> Result { match self { Self::Epoch(secs) => Ok(secs), Self::Iso(s) => from_iso(&s).map_err(E::custom), } } } /// Adaptor for required `i64` timestamp fields. pub mod iso { use serde::{Deserializer, Serializer}; use super::{Deserialize, EpochOrIso}; pub fn serialize(secs: &i64, ser: S) -> Result { ser.serialize_str(&super::to_iso(*secs)) } pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result { EpochOrIso::deserialize(de)?.into_secs() } } /// Adaptor for `Option` timestamp fields. pub mod iso_opt { use serde::{Deserializer, Serializer}; use super::{Deserialize, EpochOrIso}; pub fn serialize(secs: &Option, ser: S) -> Result { match secs { Some(secs) => ser.serialize_str(&super::to_iso(*secs)), None => ser.serialize_none(), } } pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result, D::Error> { Option::::deserialize(de)? .map(EpochOrIso::into_secs) .transpose() } } #[cfg(test)] mod tests { use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, PartialEq, Debug)] struct Row { #[serde(with = "crate::wire_time::iso")] at: i64, #[serde( default, skip_serializing_if = "Option::is_none", with = "crate::wire_time::iso_opt" )] maybe_at: Option, } #[test] fn serializes_epoch_as_rfc3339_z() { let json = serde_json::to_string(&Row { at: 1_751_480_000, maybe_at: None, }) .unwrap(); assert_eq!(json, r#"{"at":"2025-07-02T18:13:20Z"}"#); } #[test] fn round_trips_and_serializes_some() { let row = Row { at: 0, maybe_at: Some(1_751_480_000), }; let json = serde_json::to_string(&row).unwrap(); assert_eq!( json, r#"{"at":"1970-01-01T00:00:00Z","maybe_at":"2025-07-02T18:13:20Z"}"# ); assert_eq!(serde_json::from_str::(&json).unwrap(), row); } #[test] fn deserializes_legacy_epoch_ints() { // Rolling-deploy skew: an old writer still emits bare epoch // integers — the lenient reader must accept them. let row: Row = serde_json::from_str(r#"{"at":1751480000,"maybe_at":1751480000}"#).unwrap(); assert_eq!(row.at, 1_751_480_000); assert_eq!(row.maybe_at, Some(1_751_480_000)); } #[test] fn deserializes_offset_form_normalized_to_utc() { let row: Row = serde_json::from_str(r#"{"at":"2025-07-02T20:13:20+02:00"}"#).unwrap(); assert_eq!(row.at, 1_751_480_000); } }