Skip to main content

tempoch_core/format/
iso.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (C) 2026 Vallés Puig, Ramon
3
4//! ISO 8601 / RFC 3339 / RFC 2822 parsing and formatting for `Time<UTC>`
5//! and (via scale conversion) `Time<TAI>`.
6//!
7//! The civil layer is `chrono`-backed today (chrono is a hard dependency
8//! of `tempoch-core`); this module wraps the conversion to provide:
9//!
10//! * Subsecond precision configurable from 0..9 digits.
11//! * `FormatPrecision::{Truncate, RoundHalfToEven}` rounding policy.
12//! * Leap-second-aware formatting: `23:59:60[.x]` is emitted *iff* the
13//!   instant lands during an announced positive leap second, and accepted on
14//!   parse.
15//! * A small `FormatOptions` value type so callers can opt into different
16//!   subsecond/leap-second/timezone formatting policies without affecting
17//!   the existing `chrono` bridge.
18//!
19//! The conversion goes through `Time<UTC, J2000s>` storage, so the
20//! resulting instants are usable on any scale via the unified
21//! `to::<Scale>()` / `to_with::<Scale>()` API.
22//!
23//! # Examples
24//!
25//! ```
26//! use tempoch_core::format::iso::FormatOptions;
27//! use tempoch_core::{Time, UTC};
28//!
29//! let t = Time::<UTC>::parse_rfc3339("2024-06-15T12:34:56.789Z").unwrap();
30//! let s = t.format_rfc3339(FormatOptions::milliseconds());
31//! assert!(s.starts_with("2024-06-15T12:34:56.789"));
32//! ```
33
34use chrono::{DateTime, NaiveDateTime, Utc};
35
36use crate::data::runtime_data::time_data_tai_seconds_is_in_leap_window;
37use crate::earth::context::TimeContext;
38use crate::foundation::error::ConversionError;
39use crate::model::scale::UTC;
40use crate::model::time::Time;
41
42/// Subsecond rounding policy used by the formatter.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum FormatPrecision {
45    /// Round half-to-even at the requested subsecond digit (default).
46    RoundHalfToEven,
47    /// Truncate toward zero at the requested subsecond digit.
48    Truncate,
49}
50
51/// Format options for ISO 8601 / RFC 3339 output.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct FormatOptions {
54    /// Number of subsecond digits to emit (0..=9). Values above 9 are
55    /// clamped to 9 because tempoch's exact storage is 1 ns.
56    pub subsecond_digits: u8,
57    /// Rounding policy when truncating below the requested precision.
58    pub precision: FormatPrecision,
59    /// When true, emit the trailing `Z` (RFC 3339); when false, emit no
60    /// timezone suffix (bare ISO 8601 naive datetime). UTC offsets other
61    /// than `Z` are not supported because the underlying scale is UTC.
62    pub include_zulu: bool,
63}
64
65impl FormatOptions {
66    /// Default RFC 3339 form with seconds resolution and `Z` suffix.
67    pub const SECONDS: Self = Self {
68        subsecond_digits: 0,
69        precision: FormatPrecision::Truncate,
70        include_zulu: true,
71    };
72
73    /// Milliseconds resolution (3 fractional digits).
74    pub const fn milliseconds() -> Self {
75        Self {
76            subsecond_digits: 3,
77            precision: FormatPrecision::RoundHalfToEven,
78            include_zulu: true,
79        }
80    }
81
82    /// Microseconds resolution (6 fractional digits).
83    pub const fn microseconds() -> Self {
84        Self {
85            subsecond_digits: 6,
86            precision: FormatPrecision::RoundHalfToEven,
87            include_zulu: true,
88        }
89    }
90
91    /// Nanoseconds resolution (9 fractional digits).
92    pub const fn nanoseconds() -> Self {
93        Self {
94            subsecond_digits: 9,
95            precision: FormatPrecision::RoundHalfToEven,
96            include_zulu: true,
97        }
98    }
99}
100
101impl Default for FormatOptions {
102    fn default() -> Self {
103        Self::nanoseconds()
104    }
105}
106
107/// Parse an RFC 3339 timestamp into the canonical UTC `J2000s` storage.
108///
109/// Accepts the leap-second form `23:59:60[.x]` during announced positive
110/// leap seconds; rejects it otherwise.
111#[inline]
112pub fn parse_rfc3339_utc(s: &str) -> Result<Time<UTC>, ConversionError> {
113    parse_rfc3339_utc_with(s, &TimeContext::new())
114}
115
116/// Like [`parse_rfc3339_utc`], but uses an explicit [`TimeContext`].
117pub fn parse_rfc3339_utc_with(s: &str, ctx: &TimeContext) -> Result<Time<UTC>, ConversionError> {
118    // Pre-validate: reject more than 9 fractional digits before passing to chrono.
119    if let Some(after_dot) = s.find('.') {
120        // Find the end of the fractional part (Z, +, or -)
121        let frac_start = after_dot + 1;
122        if let Some(zone_pos) = s[frac_start..].find(['Z', '+', '-']) {
123            let frac_len = zone_pos;
124            if frac_len == 0 {
125                return Err(ConversionError::OutOfRange);
126            }
127            if frac_len > 9 {
128                return Err(ConversionError::OutOfRange);
129            }
130        }
131    }
132
133    // Try `chrono::DateTime::parse_from_rfc3339` first; it accepts a wide range of valid forms.
134    if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
135        let utc = dt.with_timezone(&Utc);
136        return Time::<UTC>::try_from_chrono_with(utc, ctx);
137    }
138
139    // chrono rejects ":60" in the seconds field except via try_parse paths;
140    // fall back to a manual leap-second-aware parser for the standard form
141    //   YYYY-MM-DDTHH:MM:SS[.fraction](Z|±HH:MM)
142    parse_rfc3339_manual(s, ctx)
143}
144
145fn parse_rfc3339_manual(s: &str, ctx: &TimeContext) -> Result<Time<UTC>, ConversionError> {
146    // Minimum length: "YYYY-MM-DDTHH:MM:SSZ" = 20 chars.
147    if s.len() < 20 {
148        return Err(ConversionError::OutOfRange);
149    }
150    let bytes = s.as_bytes();
151    if bytes[4] != b'-'
152        || bytes[7] != b'-'
153        || (bytes[10] != b'T' && bytes[10] != b' ')
154        || bytes[13] != b':'
155        || bytes[16] != b':'
156    {
157        return Err(ConversionError::OutOfRange);
158    }
159    let year: i32 = s[..4].parse().map_err(|_| ConversionError::OutOfRange)?;
160    let month: u32 = s[5..7].parse().map_err(|_| ConversionError::OutOfRange)?;
161    let day: u32 = s[8..10].parse().map_err(|_| ConversionError::OutOfRange)?;
162    let hour: u32 = s[11..13].parse().map_err(|_| ConversionError::OutOfRange)?;
163    let minute: u32 = s[14..16].parse().map_err(|_| ConversionError::OutOfRange)?;
164    let second_str = &s[17..19];
165    let second: u32 = second_str
166        .parse()
167        .map_err(|_| ConversionError::OutOfRange)?;
168
169    // Trailing portion may be: [.fraction][Z|±HH:MM]
170    let tail = &s[19..];
171    let (frac_str, zone_str) = split_fraction_and_zone(tail)?;
172    let frac_nanos = parse_fraction_nanos(frac_str)?;
173
174    if zone_str != "Z" {
175        // Only Z is supported in this path (the chrono fast path covers the
176        // general timezone case).
177        return Err(ConversionError::OutOfRange);
178    }
179
180    // Handle leap-second labelling: second == 60 must occur during an
181    // announced positive leap second on this UTC date.
182    if second == 60 {
183        // Construct the instant at HH:59:59.999999999 and add 1s−frac.
184        let base = NaiveDateTime::parse_from_str(
185            &format!("{year:04}-{month:02}-{day:02}T{hour:02}:59:59.999999999"),
186            "%Y-%m-%dT%H:%M:%S%.9f",
187        )
188        .map_err(|_| ConversionError::OutOfRange)?
189        .and_utc();
190        let utc_almost = Time::<UTC>::try_from_chrono_with(base, ctx)?;
191        // 1ns nudge → instant equivalent to HH:60:00, leap-second second; then add `frac_nanos` ns.
192        let shifted =
193            utc_almost.add_exact(crate::ExactDuration::from_nanos(1 + frac_nanos as i128));
194        // Validate that this date/time actually had an announced positive leap second.
195        if !time_data_tai_seconds_is_in_leap_window(
196            ctx.time_data(),
197            shifted.to_j2000s().total_seconds(),
198        ) {
199            return Err(ConversionError::InvalidLeapSecond);
200        }
201        return Ok(shifted);
202    }
203
204    if second >= 60 || minute >= 60 || hour >= 24 {
205        return Err(ConversionError::OutOfRange);
206    }
207
208    let naive_str = if frac_nanos == 0 {
209        format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}")
210    } else {
211        format!(
212            "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{:09}",
213            frac_nanos
214        )
215    };
216    let parsed = if frac_nanos == 0 {
217        NaiveDateTime::parse_from_str(&naive_str, "%Y-%m-%dT%H:%M:%S")
218    } else {
219        NaiveDateTime::parse_from_str(&naive_str, "%Y-%m-%dT%H:%M:%S%.9f")
220    }
221    .map_err(|_| ConversionError::OutOfRange)?
222    .and_utc();
223    Time::<UTC>::try_from_chrono_with(parsed, ctx)
224}
225
226fn split_fraction_and_zone(tail: &str) -> Result<(&str, &str), ConversionError> {
227    if let Some(stripped) = tail.strip_prefix('.') {
228        // fraction up to the timezone delimiter (Z, +, -)
229        let zone_pos = stripped
230            .find(['Z', '+', '-'])
231            .ok_or(ConversionError::OutOfRange)?;
232        let frac = &stripped[..zone_pos];
233        // Reject empty fraction: "2024-06-15T12:34:56.Z" is not valid RFC 3339.
234        if frac.is_empty() {
235            return Err(ConversionError::OutOfRange);
236        }
237        let zone = &stripped[zone_pos..];
238        Ok((frac, zone))
239    } else {
240        Ok(("", tail))
241    }
242}
243
244fn parse_fraction_nanos(s: &str) -> Result<u32, ConversionError> {
245    if s.is_empty() {
246        return Ok(0);
247    }
248    // Reject more than 9 fractional digits; tempoch's resolution is 1 ns.
249    if s.len() > 9 {
250        return Err(ConversionError::OutOfRange);
251    }
252    let mut padded = [b'0'; 9];
253    padded[..s.len()].copy_from_slice(s.as_bytes());
254    core::str::from_utf8(&padded)
255        .ok()
256        .and_then(|p| p.parse::<u32>().ok())
257        .ok_or(ConversionError::OutOfRange)
258}
259
260impl Time<UTC> {
261    /// Parse an RFC 3339 / ISO 8601 timestamp (UTC, `Z` suffix or named
262    /// offset). Accepts the leap-second form `23:59:60[.x]` during
263    /// announced positive leap seconds.
264    #[inline]
265    pub fn parse_rfc3339(s: &str) -> Result<Self, ConversionError> {
266        parse_rfc3339_utc(s)
267    }
268
269    /// Like [`parse_rfc3339`](Self::parse_rfc3339), with an explicit
270    /// [`TimeContext`].
271    #[inline]
272    pub fn parse_rfc3339_with(s: &str, ctx: &TimeContext) -> Result<Self, ConversionError> {
273        parse_rfc3339_utc_with(s, ctx)
274    }
275
276    /// Format this UTC instant as RFC 3339 with the given options.
277    ///
278    /// Emits `23:59:60[.fraction]Z` when the instant lies during an announced
279    /// positive leap second according to the default [`TimeContext`].
280    pub fn format_rfc3339(&self, opts: FormatOptions) -> String {
281        self.format_rfc3339_with(opts, &TimeContext::new())
282    }
283
284    /// Like [`format_rfc3339`](Self::format_rfc3339), with an explicit
285    /// [`TimeContext`].
286    ///
287    /// Returns `"<invalid>"` if the instant cannot be converted to civil UTC.
288    /// Use [`try_format_rfc3339_with`](Self::try_format_rfc3339_with) to
289    /// handle that case explicitly.
290    pub fn format_rfc3339_with(&self, opts: FormatOptions, ctx: &TimeContext) -> String {
291        match self.try_format_rfc3339_with(opts, ctx) {
292            Ok(s) => s,
293            Err(_) => "<invalid>".to_string(),
294        }
295    }
296
297    /// Fallible variant of [`format_rfc3339_with`](Self::format_rfc3339_with).
298    ///
299    /// Returns [`ConversionError`] if the underlying UTC↔chrono conversion
300    /// fails (e.g. out-of-range dates).
301    pub fn try_format_rfc3339_with(
302        &self,
303        opts: FormatOptions,
304        ctx: &TimeContext,
305    ) -> Result<String, ConversionError> {
306        // Use the explicit table/context-driven check as the authoritative source
307        // for leap-second detection. This is independent of how the chrono bridge
308        // internally represents subsecond nanoseconds.
309        let is_leap = self.is_leap_second_with(ctx);
310        let dt = self.try_to_chrono_with(ctx)?;
311        format_utc_datetime_rfc3339(dt, is_leap, opts)
312    }
313}
314
315/// Apply rounding/truncation to `nanos` (0..1_000_000_000) and return
316/// `(fractional_value_at_digits, carry_into_next_second)`.
317fn round_subsecond(nanos: u32, digits: usize, precision: FormatPrecision) -> (u32, bool) {
318    debug_assert!(digits <= 9);
319    if digits == 9 {
320        return (nanos, false);
321    }
322    let scale = 10_u32.pow(9 - digits as u32);
323    let truncated = nanos / scale;
324    let rem = nanos % scale;
325    let mut result = truncated;
326    if matches!(precision, FormatPrecision::RoundHalfToEven) {
327        let half = scale / 2;
328        if rem > half || (rem == half && truncated % 2 == 1) {
329            result = result.saturating_add(1);
330        }
331    }
332    let threshold = 10_u32.pow(digits as u32);
333    let carry = result >= threshold;
334    if carry {
335        result -= threshold;
336    }
337    (result, carry)
338}
339
340/// Format a `DateTime<Utc>` as RFC 3339, applying uniform rounding and
341/// emitting `23:59:60` for leap-second instants.
342///
343/// The `is_leap` flag is the authoritative signal: it must be supplied by the
344/// caller via `Time<UTC>::is_leap_second_with(ctx)`, which consults the compiled
345/// UTC–TAI table. Chrono must represent the leap-second instant with
346/// `timestamp_subsec_nanos() >= 1_000_000_000`; if it does not, the function
347/// returns `Err(ConversionError::InvalidLeapSecond)` to avoid silently producing
348/// an incorrect fractional value.
349fn format_utc_datetime_rfc3339(
350    dt: DateTime<Utc>,
351    is_leap: bool,
352    opts: FormatOptions,
353) -> Result<String, crate::foundation::error::ConversionError> {
354    use crate::foundation::error::ConversionError;
355    let digits = opts.subsecond_digits.min(9) as usize;
356    let raw_nanos = dt.timestamp_subsec_nanos();
357
358    if is_leap {
359        // chrono encodes leap-second instants with timestamp_subsec_nanos() ≥ 1_000_000_000.
360        // If that invariant is violated, the fractional position within the leap second
361        // cannot be reliably derived; return an error rather than silently producing
362        // a wrong value.
363        if raw_nanos < 1_000_000_000 {
364            return Err(ConversionError::InvalidLeapSecond);
365        }
366        let leap_nanos = raw_nanos - 1_000_000_000;
367        let (frac, carry) = round_subsecond(leap_nanos, digits, opts.precision);
368        if carry {
369            // Rounding caused the leap second itself to overflow into next second
370            // (i.e. 23:59:60.999999500 rounded up to 23:59:61 → 2017-01-01T00:00:00).
371            let next = dt + chrono::TimeDelta::try_seconds(1).unwrap_or_default();
372            return Ok(format_normal_dt(next, 0, digits, opts));
373        }
374        let date = dt.format("%Y-%m-%d");
375        if digits == 0 {
376            let zulu = if opts.include_zulu { "Z" } else { "" };
377            Ok(format!("{date}T23:59:60{zulu}"))
378        } else {
379            let zulu = if opts.include_zulu { "Z" } else { "" };
380            Ok(format!(
381                "{date}T23:59:60.{:0width$}{zulu}",
382                frac,
383                width = digits
384            ))
385        }
386    } else {
387        let (frac, carry) = round_subsecond(raw_nanos, digits, opts.precision);
388        let effective_dt = if carry {
389            dt + chrono::TimeDelta::try_seconds(1).unwrap_or_default()
390        } else {
391            dt
392        };
393        Ok(format_normal_dt(effective_dt, frac, digits, opts))
394    }
395}
396
397fn format_normal_dt(dt: DateTime<Utc>, frac: u32, digits: usize, opts: FormatOptions) -> String {
398    let base = dt.format("%Y-%m-%dT%H:%M:%S");
399    if digits == 0 {
400        let zulu = if opts.include_zulu { "Z" } else { "" };
401        format!("{base}{zulu}")
402    } else {
403        let zulu = if opts.include_zulu { "Z" } else { "" };
404        format!("{base}.{:0width$}{zulu}", frac, width = digits)
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn parse_basic_z() {
414        let t = Time::<UTC>::parse_rfc3339("2000-01-01T12:00:00Z").unwrap();
415        let s = t.format_rfc3339(FormatOptions::SECONDS);
416        assert_eq!(s, "2000-01-01T12:00:00Z");
417    }
418
419    #[test]
420    fn parse_with_milliseconds() {
421        let t = Time::<UTC>::parse_rfc3339("2024-06-15T12:34:56.789Z").unwrap();
422        let s = t.format_rfc3339(FormatOptions::milliseconds());
423        assert_eq!(s, "2024-06-15T12:34:56.789Z");
424    }
425
426    #[test]
427    fn parse_with_microseconds_within_chrono_bridge_precision() {
428        // The chrono bridge collapses split storage to f64 J2000 seconds (~150 ns
429        // precision near 2024). Test that microsecond round-trip is within
430        // single-digit microseconds, which is the documented bridge tolerance.
431        let t = Time::<UTC>::parse_rfc3339("2024-06-15T12:34:56.123456Z").unwrap();
432        let s = t.format_rfc3339(FormatOptions::microseconds());
433        assert!(s.starts_with("2024-06-15T12:34:56.1234"), "got {s}");
434    }
435
436    #[test]
437    fn parse_with_nanoseconds_within_chrono_bridge_precision() {
438        // Same bridge precision caveat as above; nanosecond digits will drift by
439        // ~150 ns near 2024. The format itself supports 9 digits.
440        let t = Time::<UTC>::parse_rfc3339("2024-06-15T12:34:56.123456789Z").unwrap();
441        let s = t.format_rfc3339(FormatOptions::nanoseconds());
442        assert!(s.starts_with("2024-06-15T12:34:56.1234"), "got {s}");
443        assert_eq!(s.len(), "2024-06-15T12:34:56.123456789Z".len());
444    }
445
446    #[test]
447    fn parse_with_named_offset_normalizes_to_utc() {
448        let t = Time::<UTC>::parse_rfc3339("2024-06-15T14:34:56+02:00").unwrap();
449        let s = t.format_rfc3339(FormatOptions::SECONDS);
450        assert_eq!(s, "2024-06-15T12:34:56Z");
451    }
452
453    #[test]
454    fn format_leap_second_emits_colon_sixty() {
455        // 2016-12-31T23:59:60Z was an announced positive leap second.
456        let t = Time::<UTC>::parse_rfc3339("2016-12-31T23:59:60Z").unwrap();
457        let s = t.format_rfc3339(FormatOptions::SECONDS);
458        assert_eq!(s, "2016-12-31T23:59:60Z");
459    }
460
461    #[test]
462    fn format_leap_second_with_fraction() {
463        // The chrono bridge has ~150 ns precision near 2016; test at millisecond level.
464        let t = Time::<UTC>::parse_rfc3339("2016-12-31T23:59:60.500Z").unwrap();
465        let s = t.format_rfc3339(FormatOptions::milliseconds());
466        assert_eq!(s, "2016-12-31T23:59:60.500Z");
467    }
468
469    #[test]
470    fn reject_malformed_input() {
471        assert!(Time::<UTC>::parse_rfc3339("not a date").is_err());
472        assert!(Time::<UTC>::parse_rfc3339("2024-13-01T00:00:00Z").is_err());
473        assert!(Time::<UTC>::parse_rfc3339("2024-06-15T25:00:00Z").is_err());
474    }
475
476    #[test]
477    fn reject_empty_fraction() {
478        // "2024-06-15T12:34:56.Z" has an empty fraction field — must be rejected.
479        let result = Time::<UTC>::parse_rfc3339("2024-06-15T12:34:56.Z");
480        assert!(result.is_err(), "expected Err for empty fraction, got Ok");
481    }
482
483    #[test]
484    fn reject_more_than_nine_fractional_digits() {
485        // 10 digits — must be rejected.
486        let result = Time::<UTC>::parse_rfc3339("2024-06-15T12:34:56.1234567890Z");
487        assert!(result.is_err(), "expected Err for >9 fractional digits");
488    }
489
490    #[test]
491    fn round_trip_seconds_precision() {
492        for s in ["2000-01-01T00:00:00Z", "1999-12-31T23:59:59Z"] {
493            let t = Time::<UTC>::parse_rfc3339(s).unwrap();
494            let back = t.format_rfc3339(FormatOptions::SECONDS);
495            assert_eq!(back, s, "round trip mismatch for {s}");
496        }
497    }
498
499    #[test]
500    fn format_options_constants_are_consistent() {
501        assert_eq!(FormatOptions::SECONDS.subsecond_digits, 0);
502        assert_eq!(FormatOptions::milliseconds().subsecond_digits, 3);
503        assert_eq!(FormatOptions::microseconds().subsecond_digits, 6);
504        assert_eq!(FormatOptions::nanoseconds().subsecond_digits, 9);
505    }
506
507    #[test]
508    fn arbitrary_precision_digits_are_supported() {
509        let t = Time::<UTC>::parse_rfc3339("2024-06-15T12:34:56.123456789Z").unwrap();
510        let opts = FormatOptions {
511            subsecond_digits: 4,
512            precision: FormatPrecision::Truncate,
513            include_zulu: true,
514        };
515        let s = t.format_rfc3339(opts);
516        // 4-digit subsecond resolution survives the chrono-bridge drift (~150 ns).
517        assert!(s.starts_with("2024-06-15T12:34:56.1234"), "got {s}");
518    }
519
520    #[test]
521    fn truncate_vs_round_differs_on_5() {
522        // Use a year-2000 epoch where chrono-bridge precision is sub-ms.
523        let t = Time::<UTC>::parse_rfc3339("2000-06-15T12:34:56.55Z").unwrap();
524        let trunc = FormatOptions {
525            subsecond_digits: 1,
526            precision: FormatPrecision::Truncate,
527            include_zulu: true,
528        };
529        let round = FormatOptions {
530            subsecond_digits: 1,
531            precision: FormatPrecision::RoundHalfToEven,
532            include_zulu: true,
533        };
534        let st = t.format_rfc3339(trunc);
535        let sr = t.format_rfc3339(round);
536        assert!(st.ends_with(".5Z"), "truncate got {st}");
537        // Half-to-even: .5 with truncated = 5 (odd) rounds up to .6.
538        assert!(sr.ends_with(".6Z"), "round-half-to-even got {sr}");
539    }
540
541    #[test]
542    fn omit_zulu_suffix() {
543        let t = Time::<UTC>::parse_rfc3339("2024-06-15T12:34:56Z").unwrap();
544        let opts = FormatOptions {
545            subsecond_digits: 0,
546            precision: FormatPrecision::Truncate,
547            include_zulu: false,
548        };
549        let s = t.format_rfc3339(opts);
550        assert_eq!(s, "2024-06-15T12:34:56");
551    }
552
553    #[test]
554    fn reject_invalid_leap_second_date() {
555        // 2023-06-15 was NOT a leap-second day; :60 must be rejected.
556        let result = Time::<UTC>::parse_rfc3339("2023-06-15T23:59:60Z");
557        assert!(
558            matches!(result, Err(ConversionError::InvalidLeapSecond)),
559            "expected InvalidLeapSecond, got {result:?}"
560        );
561        // 2016-12-31 WAS a leap-second day; must parse successfully.
562        assert!(
563            Time::<UTC>::parse_rfc3339("2016-12-31T23:59:60Z").is_ok(),
564            "expected Ok for valid leap-second date"
565        );
566    }
567
568    #[test]
569    fn rounding_truncate_standard_digits() {
570        // Use a simple value well within chrono-bridge precision (J2000 era).
571        // .123Z at 0 digits truncate → no subsecond part (no carry since .123 < .5)
572        let t = Time::<UTC>::parse_rfc3339("2000-01-01T12:34:56.123Z").unwrap();
573        let opts = FormatOptions {
574            subsecond_digits: 0,
575            precision: FormatPrecision::Truncate,
576            include_zulu: true,
577        };
578        let s = t.format_rfc3339(opts);
579        assert_eq!(s, "2000-01-01T12:34:56Z");
580    }
581
582    #[test]
583    fn rounding_carry_into_next_second() {
584        // .999Z with 0 digits and RoundHalfToEven should carry into next second.
585        let t = Time::<UTC>::parse_rfc3339("2000-01-01T12:34:56.999Z").unwrap();
586        let opts = FormatOptions {
587            subsecond_digits: 0,
588            precision: FormatPrecision::RoundHalfToEven,
589            include_zulu: true,
590        };
591        let s = t.format_rfc3339(opts);
592        // .999 rounds to 1.0 → carry → 12:34:57
593        assert_eq!(s, "2000-01-01T12:34:57Z", "got {s}");
594    }
595
596    #[test]
597    fn rounding_half_even_milliseconds() {
598        // 500.000000 ms at 3 digits, RoundHalfToEven:
599        // truncated = 500, remainder = 0 (no tie) → stays .500.
600        // Using exactly 500ms avoids a bridge-precision boundary: ±150 ns near J2000
601        // cannot shift a 0-remainder to the tie point (500_000 out of 1_000_000).
602        let t = Time::<UTC>::parse_rfc3339("2000-01-01T12:00:00.500000000Z").unwrap();
603        let opts = FormatOptions {
604            subsecond_digits: 3,
605            precision: FormatPrecision::RoundHalfToEven,
606            include_zulu: true,
607        };
608        let s = t.format_rfc3339(opts);
609        assert_eq!(s, "2000-01-01T12:00:00.500Z", "got {s}");
610    }
611
612    #[test]
613    fn round_subsecond_helper_truncate() {
614        assert_eq!(
615            round_subsecond(999_999_999, 3, FormatPrecision::Truncate),
616            (999, false)
617        );
618        assert_eq!(
619            round_subsecond(500_000_000, 3, FormatPrecision::Truncate),
620            (500, false)
621        );
622        assert_eq!(round_subsecond(0, 0, FormatPrecision::Truncate), (0, false));
623    }
624
625    #[test]
626    fn round_subsecond_helper_carry() {
627        // 999_999_999 at 0 digits with RoundHalfToEven: rounds to 1 (carry).
628        let (v, carry) = round_subsecond(999_999_999, 0, FormatPrecision::RoundHalfToEven);
629        assert!(carry, "expected carry for 999_999_999 at 0 digits");
630        assert_eq!(v, 0);
631    }
632
633    #[test]
634    fn round_subsecond_helper_half_even() {
635        // Exact half: 500_000_000 at 0 digits. truncated = 0 (even) → no round up.
636        let (v, carry) = round_subsecond(500_000_000, 0, FormatPrecision::RoundHalfToEven);
637        assert!(
638            !carry,
639            "500_000_000 half-to-even at 0 digits: 0 is even, no carry"
640        );
641        assert_eq!(v, 0);
642        // 1_500_000_000 / 1e9 is not possible but at 9 digits identity is returned.
643        let (v9, carry9) = round_subsecond(999_999_999, 9, FormatPrecision::RoundHalfToEven);
644        assert!(!carry9);
645        assert_eq!(v9, 999_999_999);
646    }
647}