jsonx 0.1.0

A serde-enabled implementation of the JSONX extended-JSON format: typed value constructors, unquoted keys, and trailing commas.
Documentation
//! RFC 3339 date-time support for the `datetime(...)` type, backed by
//! [`chrono`].
//!
//! [`DateTime`] is an alias for [`chrono::DateTime<chrono::FixedOffset>`], which
//! keeps the original UTC offset so values round-trip exactly (including
//! fractional seconds). Because that type lives in another crate, its built-in
//! serde support renders a bare RFC 3339 string rather than the JSONX
//! `datetime("...")` form; use the [`serialize`]/[`deserialize`] functions in
//! this module through serde's `with` attribute to select the JSONX form,
//! exactly like the [`ip`](crate::ip) / [`ipport`](crate::ipport) modules.
//!
//! ```
//! use jsonx::DateTime;
//!
//! #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
//! struct Event {
//!     #[serde(with = "jsonx::datetime")]
//!     at: DateTime,
//! }
//!
//! let event = Event { at: DateTime::parse_from_rfc3339("2024-06-01T09:00:00+02:00").unwrap() };
//! let text = jsonx::to_string(&event).unwrap();
//! assert_eq!(text, r#"{at:datetime("2024-06-01T09:00:00+02:00")}"#);
//! assert_eq!(jsonx::from_str::<Event>(&text).unwrap(), event);
//! ```

use chrono::{DateTime as ChronoDateTime, FixedOffset, SecondsFormat};
use serde::de;
use serde::{Deserializer, Serializer};

use crate::tokens::TOKEN_DATETIME;

/// A parsed RFC 3339 timestamp, preserving its original UTC offset.
///
/// This is an alias for [`chrono::DateTime<chrono::FixedOffset>`]. Parse one
/// with [`DateTime::parse_from_rfc3339`] (or [`str::parse`]).
pub type DateTime = ChronoDateTime<FixedOffset>;

/// Renders a [`DateTime`] in the canonical JSONX form: RFC 3339 with `Z` for a
/// zero offset and no fractional seconds when there are none. This is the exact
/// text `jsonx` writes inside `datetime("...")`.
pub fn to_jsonx_string(dt: &DateTime) -> String {
    dt.to_rfc3339_opts(SecondsFormat::AutoSi, true)
}

/// Parses a [`DateTime`] from an RFC 3339 timestamp such as
/// `2017-12-25T15:00:00Z` or `2006-01-02T15:04:05.5-07:00`.
pub fn parse(input: &str) -> Result<DateTime, chrono::ParseError> {
    DateTime::parse_from_rfc3339(input)
}

/// Serializes a [`DateTime`] as `datetime("...")`.
pub fn serialize<S: Serializer>(dt: &DateTime, serializer: S) -> Result<S::Ok, S::Error> {
    crate::constructor::serialize_constructor(serializer, TOKEN_DATETIME, &to_jsonx_string(dt))
}

/// Deserializes a [`DateTime`] from `datetime("...")` (or a bare RFC 3339
/// string).
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<DateTime, D::Error> {
    let arg = crate::constructor::deserialize_constructor(deserializer, TOKEN_DATETIME)?;
    parse(&arg).map_err(de::Error::custom)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn round_trip() {
        for s in [
            "2017-12-25T15:00:00Z",
            "2006-01-02T15:04:05-07:00",
            "1999-01-01T00:00:00.500Z",
        ] {
            let dt = parse(s).unwrap();
            assert_eq!(to_jsonx_string(&dt), s);
        }
    }

    #[test]
    fn zero_offset_renders_z() {
        let dt = parse("2017-12-25T15:00:00+00:00").unwrap();
        assert_eq!(to_jsonx_string(&dt), "2017-12-25T15:00:00Z");
    }

    #[test]
    fn rejects_bad() {
        assert!(parse("not a date").is_err());
        assert!(parse("2017-13-01T00:00:00Z").is_err());
        // Missing offset is not valid RFC 3339.
        assert!(parse("2017-12-25T15:00:00").is_err());
        // Trailing garbage is rejected.
        assert!(parse("2017-12-25T15:00:00Z extra").is_err());
    }
}