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