Skip to main content

gnss_time/
leap.rs

1//! # Leap seconds — conversion context
2//!
3//! ## Why this is an explicit parameter, not global state
4//!
5//! ```text
6//! // Hidden state — bad
7//! let utc = gps.to_utc(); // where do the leap seconds come from?
8//!
9//! // Explicit context — good
10//! let utc = gps_to_utc(gps, LeapSeconds::builtin())?;
11//! ```
12//!
13//! Reasons:
14//! - `no_std` / embedded: there is no global mutable memory
15//! - Embedded GNSS receiver: the table is read from the almanac and updated at
16//!   runtime
17//! - Testing: easy to inject the desired state without mocks
18//! - Determinism: compiled code does not depend on future IERS updates
19//!
20//! ## Supported conversions
21//!
22//! | Function            | Leap-second context?        |
23//! |--------------------|-----------------------------|
24//! | `glonass_to_utc`   | **no** (constant shift)     |
25//! | `utc_to_glonass`   | **no** (constant shift)     |
26//! | `gps_to_utc`       | yes                         |
27//! | `utc_to_gps`       | yes                         |
28//! | `gps_to_glonass`   | yes (via UTC)               |
29//! | `glonass_to_gps`   | yes (via UTC)               |
30//!
31//! ## GLONASS and leap seconds
32//!
33//! GLONASS tracks UTC(SU) = UTC + 3 hours, including leap-second insertions.
34//! Therefore GLONASS ↔ UTC conversion is a **constant shift** in nanoseconds
35//! (the difference between epochs), without any leap-second adjustments.
36//! Leap seconds are only needed when crossing into GPS/Galileo/BeiDou.
37
38use crate::{
39    tables::BUILTIN_TABLE, Beidou, CivilDate, Galileo, Glonass, GnssTimeError, Gps, Tai, Time, Utc,
40};
41
42/// Maximum number of entries in a [`RuntimeLeapSeconds`] buffer.
43///
44/// 64 entries is far beyond any plausible number of leap seconds in the
45/// foreseeable future (current count from 1972: 27 events).
46pub const RUNTIME_CAPACITY: usize = 64;
47
48static BUILTIN_LEAP_SECONDS: LeapSeconds = LeapSeconds {
49    entries: &BUILTIN_TABLE,
50};
51
52/// Nanoseconds from the UTC epoch (1972-01-01) to the GLONASS epoch
53/// (1995-12-31 21:00:00 UTC).
54///
55/// `UTC_nanos = GLO_nanos + GLONASS_FROM_UTC_EPOCH_NS`
56const GLONASS_FROM_UTC_EPOCH_NS: i64 = {
57    // от UTC-epoch до 1996-01-01 00:00:00 UTC
58    let to_1996 = CivilDate::new(1972, 1, 1).nanos_until(CivilDate::new(1996, 1, 1));
59
60    // minus 3 hours: GLONASS epoch = 3 hours earlier in UTC
61    to_1996 - 3 * 3_600 * 1_000_000_000_i64
62    // = 8766 days * 86400 * 1e9 - 10800 * 1e9
63    // = 757_382_400_000_000_000 - 10_800_000_000_000 = 757_371_600_000_000_000
64};
65
66const _VERIFY_GLONASS_OFFSET: () = {
67    let s = GLONASS_FROM_UTC_EPOCH_NS / 1_000_000_000;
68
69    assert!(
70        s == 757_371_600,
71        "GLONASS -> UTC epoch offset must be 757371600 s"
72    );
73};
74
75/// Nanoseconds from the UTC epoch (1972-01-01) to the GPS epoch (1980-01-06).
76///
77/// The GPS epoch is later, so the value is positive.
78/// `UTC_nanos_from_1972 = GPS_nanos_from_1980 - (TAI_minus_UTC - 19) * 1e9 +
79/// THIS`
80const UTC_TO_GPS_EPOCH_NS: i64 = CivilDate::new(1972, 1, 1).nanos_until(CivilDate::new(1980, 1, 6));
81// = 2927 days * 86400 * 1e9 = 252_892_800_000_000_000 ns
82
83const _VERIFY_UTC_GPS_OFFSET: () = {
84    let s = UTC_TO_GPS_EPOCH_NS / 1_000_000_000;
85
86    assert!(
87        s == 252_892_800,
88        "UTC -> GPS epoch offset must be 252892800 s (2927 days)"
89    );
90};
91
92/// Source of TAI-UTC corrections for conversions involving UTC and GLONASS.
93///
94/// This makes it possible to provide custom tables, for example values read
95/// from a GNSS receiver almanac, without changing the crate code.
96///
97/// # Example
98///
99/// ```rust
100/// use gnss_time::{LeapEntry, LeapSecondsProvider, Tai, Time};
101///
102/// struct FixedLeap(i32);
103///
104/// impl LeapSecondsProvider for FixedLeap {
105///     fn tai_minus_utc_at(
106///         &self,
107///         _tai: Time<Tai>,
108///     ) -> i32 {
109///         self.0
110///     }
111/// }
112/// ```
113pub trait LeapSecondsProvider {
114    /// Returns TAI - UTC (in seconds) for the given TAI moment.
115    fn tai_minus_utc_at(
116        &self,
117        tai: Time<Tai>,
118    ) -> i32;
119}
120
121/// Error returned by [`RuntimeLeapSeconds::try_extend`].
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
123#[must_use = "handle the extension error; ignoring it means the table was not updated"]
124#[non_exhaustive]
125pub enum LeapExtendError {
126    /// The new entry's `tai_nanos` is not strictly greater than the last
127    /// existing entry — the table would become unsorted.
128    NotStrictlyAscending,
129
130    /// The new entry's `tai_minus_utc` is not exactly one more than the last
131    /// existing entry — every leap second must increment the counter by 1.
132    NonUnitIncrement,
133
134    /// The runtime buffer is full; no more entries can be appended.
135    BufferFull,
136}
137
138/// One leap-second table entry.
139///
140/// Starting from `tai_minus_utc` (internal TAI nanoseconds), `TAI - UTC =
141/// tai_minus_utc` seconds.
142///
143/// Strict contract: the table must be sorted by `tai_nanos` in ascending order.
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
145pub struct LeapEntry {
146    /// Internal TAI nanoseconds (inclusive lower bound).
147    pub tai_nanos: u64,
148
149    /// TAI - UTC in whole seconds, valid from this moment onward.
150    pub tai_minus_utc: i32,
151}
152
153/// Static leap-second correction table.
154///
155/// The built-in table [`builtin`](LeapSeconds::builtin) covers all events from
156/// the GPS start (1980-01-06) through 2017-01-01 inclusive.
157/// For times after the last entry, the last known value is returned
158/// (the standard "assume no new leap seconds" approach).
159///
160/// # no_std
161///
162/// `LeapSeconds` stores `&'static [LeapEntry]` — there are no allocations, and
163/// it works everywhere.
164///
165/// # Examples
166///
167/// ```rust
168/// use gnss_time::{gps_to_utc, DurationParts, Gps, LeapSeconds, LeapSecondsProvider, Time};
169///
170/// // Built-in table (up to 2017)
171/// let ls = LeapSeconds::builtin();
172///
173/// let gps = Time::<Gps>::from_week_tow(
174///     1981,
175///     DurationParts {
176///         seconds: 0,
177///         nanos: 0,
178///     },
179/// )
180/// .unwrap();
181/// let utc = gps_to_utc(gps, &ls).unwrap();
182/// // GPS leads UTC by 18 seconds in this period
183/// ```
184pub struct LeapSeconds {
185    entries: &'static [LeapEntry], // (Unix seconds, TAI-UTC)
186}
187
188/// A heap-free, fixed-capacity leap-second table for embedded / receiver use.
189///
190/// Suitable for GNSS receivers that receive the current leap-second count from
191/// the GPS navigation message and need an up-to-date table without any heap
192/// allocation.
193///
194/// Start with [`from_builtin`](Self::from_builtin) to pre-populate the
195/// compile-time snapshot, then call [`try_extend`](Self::try_extend) whenever
196/// the receiver almanac reports a new event.
197///
198/// # Capacity
199///
200/// Holds up to [`RUNTIME_CAPACITY`] (64) entries.
201///
202/// # Example
203///
204/// ```rust
205/// use gnss_time::{LeapEntry, LeapSecondsProvider, RuntimeLeapSeconds, Tai, Time};
206///
207/// let mut rt = RuntimeLeapSeconds::from_builtin();
208///
209/// // Hypothetical future event (illustrative only).
210/// // rt.try_extend(LeapEntry::new(9_999_999_999_000_000_000, 38)).unwrap();
211///
212/// assert_eq!(rt.current_tai_minus_utc(), 37);
213/// ```
214#[derive(Debug)]
215pub struct RuntimeLeapSeconds {
216    buf: [LeapEntry; RUNTIME_CAPACITY],
217    len: usize,
218}
219
220impl LeapEntry {
221    /// Creates a new leap-second entry.
222    ///
223    /// # Parameters
224    /// - `tai_nanos`: threshold value in TAI nanoseconds (inclusive lower
225    ///   bound) from which this offset applies.
226    /// - `tai_minus_utc`: TAI - UTC in seconds that applies from this
227    ///   threshold.
228    #[inline]
229    #[must_use]
230    pub const fn new(
231        tai_nanos: u64,
232        tai_minus_utc: i32,
233    ) -> Self {
234        LeapEntry {
235            tai_nanos,
236            tai_minus_utc,
237        }
238    }
239}
240
241impl LeapSeconds {
242    /// Built-in table valid through 2017-01-01.
243    ///
244    /// Covers all 19 entries in the GPS era (1980-01-06 … 2017-01-01).
245    ///
246    /// **Last verified:** IERS Bulletin C 70 (December 2024) — no new leap
247    /// seconds scheduled through June 2025. Status as of May 2026: TAI−UTC =
248    /// 37, unchanged.
249    ///
250    /// Source: [IERS Bulletin C](https://www.iers.org/IERS/EN/Publications/Bulletins/bulletins.html)
251    #[inline]
252    #[must_use]
253    pub fn builtin() -> &'static LeapSeconds {
254        &BUILTIN_LEAP_SECONDS
255    }
256
257    /// Creates a table from a custom static slice.
258    ///
259    /// This is an alias for [`from_table`](Self::from_table), provided for API
260    /// symmetry with [`RuntimeLeapSeconds::from_slice`].
261    ///
262    /// # Requirements
263    ///
264    /// `entries` must be sorted by `tai_nanos` in strictly ascending order and
265    /// each consecutive `tai_minus_utc` must increment by exactly 1.
266    ///
267    /// # Example
268    ///
269    /// ```rust
270    /// use gnss_time::{LeapEntry, LeapSeconds};
271    ///
272    /// static MY_TABLE: [LeapEntry; 1] = [LeapEntry::new(0, 37)];
273    /// let ls = LeapSeconds::from_slice(&MY_TABLE);
274    ///
275    /// assert_eq!(ls.len(), 1);
276    /// ```
277    #[inline]
278    #[must_use]
279    pub const fn from_slice(entries: &'static [LeapEntry]) -> Self {
280        Self { entries }
281    }
282
283    /// Creates a table from a custom static slice (canonical name).
284    ///
285    /// # Requirements
286    ///
287    /// `entries` must be sorted by `tai_nanos` in ascending order.
288    #[inline]
289    #[must_use]
290    pub const fn from_table(entries: &'static [LeapEntry]) -> Self {
291        Self { entries }
292    }
293
294    /// Returns the number of entries in the table.
295    #[inline]
296    #[must_use]
297    pub fn len(&self) -> usize {
298        self.entries.len()
299    }
300
301    /// Returns `true` if the table is empty.
302    #[inline]
303    #[must_use]
304    pub fn is_empty(&self) -> bool {
305        self.entries.is_empty()
306    }
307
308    /// Returns all table entries (for inspection / serialization).
309    #[inline]
310    #[must_use]
311    pub fn entries(&self) -> &[LeapEntry] {
312        self.entries
313    }
314
315    /// Returns the TAI timestamp of the most recent leap-second event.
316    ///
317    /// Returns `None` when the table contains only the base entry (threshold
318    /// = 0) or is empty — in those cases there is no recorded event timestamp.
319    ///
320    /// Useful for diagnostics: compare against the current time to detect
321    /// whether the table may be stale.
322    ///
323    /// # Example
324    ///
325    /// ```rust
326    /// use gnss_time::LeapSeconds;
327    ///
328    /// let ls = LeapSeconds::builtin();
329    /// let last = ls.last_update().expect("builtin table is non-empty");
330    ///
331    /// // 2017-01-01 TAI threshold
332    /// assert_eq!(last.as_nanos(), 1_167_264_037_000_000_000);
333    /// ```
334    #[inline]
335    #[must_use]
336    pub const fn last_update(&self) -> Option<Time<Tai>> {
337        if self.entries.len() <= 1 {
338            return None;
339        }
340
341        let last = &self.entries[self.entries.len() - 1];
342
343        Some(Time::<Tai>::from_nanos(last.tai_nanos))
344    }
345
346    /// Returns the current TAI − UTC value (the `tai_minus_utc` of the last
347    /// entry), or 19 for an empty table.
348    ///
349    /// Equivalent to `tai_minus_utc_at(Time::<Tai>::MAX)`.
350    ///
351    /// # Example
352    ///
353    /// ```rust
354    /// use gnss_time::LeapSeconds;
355    ///
356    /// assert_eq!(LeapSeconds::builtin().current_tai_minus_utc(), 37);
357    /// ```
358    #[inline]
359    #[must_use]
360    pub const fn current_tai_minus_utc(&self) -> i32 {
361        if self.entries.is_empty() {
362            return 19;
363        }
364
365        self.entries[self.entries.len() - 1].tai_minus_utc
366    }
367}
368
369impl RuntimeLeapSeconds {
370    /// Creates an empty runtime table.
371    ///
372    /// Call [`try_extend`](Self::try_extend) or use
373    /// [`from_builtin`](Self::from_builtin) before performing conversions.
374    #[inline]
375    #[must_use]
376    pub fn new() -> Self {
377        Self {
378            buf: [LeapEntry::new(0, 0); RUNTIME_CAPACITY],
379            len: 0,
380        }
381    }
382
383    /// Creates a runtime table pre-populated from built-in static table.
384    ///
385    /// This is the recommended starting point for receivers: begin with the
386    /// compile-time snapshot and extend when the almanac reports new data.
387    ///
388    /// # Panics
389    ///
390    /// Panics if `BUILTIN_YABLE.len() > RUNTIME_CAPACITY` (cannot happen with
391    /// current constants, but asserted for correctness).
392    #[must_use]
393    pub fn from_builtin() -> Self {
394        assert!(
395            BUILTIN_TABLE.len() <= RUNTIME_CAPACITY,
396            "BUILTIN_TABLE exceeds RUNTIME_CAPACITY"
397        );
398
399        let mut rt = Self::new();
400
401        for &entry in BUILTIN_TABLE.iter() {
402            rt.buf[rt.len] = entry;
403            rt.len += 1;
404        }
405
406        rt
407    }
408
409    /// Creates a runtime table from a slice of entries.
410    ///
411    /// Mirrors [`LeapSeconds::from_slice`] for contexts where a mutable /
412    /// extendable table is needed.
413    ///
414    /// # Errors
415    ///
416    /// Returns [`LeapExtendError::BufferFull`] if `entries.len() >
417    /// RUNTIME_CAPACITY`.
418    #[inline]
419    pub fn from_slice(entries: &[LeapEntry]) -> Result<Self, LeapExtendError> {
420        if entries.len() > RUNTIME_CAPACITY {
421            return Err(LeapExtendError::BufferFull);
422        }
423
424        let mut rt = Self::new();
425
426        for &entry in entries {
427            rt.buf[rt.len] = entry;
428            rt.len += 1;
429        }
430
431        Ok(rt)
432    }
433
434    /// Appends a new leap-second event to the runtime table.
435    ///
436    /// Internally, the table is treated as a strictly ordered sequence of
437    /// leap-second transitions. Each new entry must extend the sequence
438    /// without breaking its monotonic structure.
439    ///
440    /// # Validation
441    ///
442    /// The new entry must satisfy:
443    /// - `entry.tai_nanos > last().tai_nanos` — strictly ascending order
444    /// - `entry.tai_minus_utc == last().tai_minus_utc + 1` — unit increment
445    ///
446    /// # Errors
447    ///
448    /// - [`LeapExtendError::NotStrictlyAscending`] — threshold not increasing
449    /// - [`LeapExtendError::NonUnitIncrement`] — value does not increment by 1
450    /// - [`LeapExtendError::BufferFull`] — capacity exhausted
451    ///
452    /// # Notes
453    ///
454    /// This method does not attempt to validate whether the provided entry
455    /// corresponds to a *real* leap second published by official sources.
456    /// It only enforces internal consistency of the sequence.
457    ///
458    /// # Example
459    ///
460    /// ```rust
461    /// use gnss_time::{LeapEntry, RuntimeLeapSeconds};
462    ///
463    /// let mut rt = RuntimeLeapSeconds::from_builtin();
464    ///
465    /// // Hypothetical future leap second (not a real event).
466    /// rt.try_extend(LeapEntry::new(9_999_999_999_000_000_000, 38))
467    ///     .unwrap();
468    ///
469    /// assert_eq!(rt.current_tai_minus_utc(), 38);
470    /// assert_eq!(rt.len(), 20);
471    /// ```
472    pub fn try_extend(
473        &mut self,
474        entry: LeapEntry,
475    ) -> Result<(), LeapExtendError> {
476        // Prevent writing past the fixed buffer.
477        // This keeps the structure allocation-free and predictable.
478        if self.len >= RUNTIME_CAPACITY {
479            return Err(LeapExtendError::BufferFull);
480        }
481
482        // If there is at least one entry, validate against the last one.
483        if self.len > 0 {
484            let last = &self.buf[self.len - 1];
485
486            // Enforce strict monotonicity in time.
487            // Equal or smaller timestamps would break ordering assumptions.
488            if entry.tai_nanos <= last.tai_nanos {
489                return Err(LeapExtendError::NotStrictlyAscending);
490            }
491
492            // Enforce +1 step in TAI−UTC offset.
493            // Anything else would violate leap second semantics.
494            if entry.tai_minus_utc != last.tai_minus_utc + 1 {
495                return Err(LeapExtendError::NonUnitIncrement);
496            }
497        }
498
499        self.buf[self.len] = entry;
500        self.len += 1;
501
502        Ok(())
503    }
504
505    /// Returns the number of entries currently in the table.
506    #[inline]
507    #[must_use]
508    pub const fn len(&self) -> usize {
509        self.len
510    }
511
512    /// Returns `true` if the table has no entries.
513    #[inline]
514    #[must_use]
515    pub const fn is_empty(&self) -> bool {
516        self.len == 0
517    }
518
519    /// Returns all live entries as a slice.
520    #[inline]
521    #[must_use]
522    pub fn entries(&self) -> &[LeapEntry] {
523        &self.buf[..self.len]
524    }
525
526    /// Returns the TAI timestamp of the most recent event, or `None` for a
527    /// single-entry or empty table.
528    #[inline]
529    #[must_use]
530    pub const fn last_update(&self) -> Option<Time<Tai>> {
531        if self.len <= 1 {
532            return None;
533        }
534
535        Some(Time::<Tai>::from_nanos(self.buf[self.len - 1].tai_nanos))
536    }
537
538    /// Returns the current TAI - UTC value (last entry), or 19 for an empty
539    /// table.
540    #[inline]
541    #[must_use]
542    pub const fn current_tai_minus_utc(&self) -> i32 {
543        if self.len == 0 {
544            return 19;
545        }
546
547        self.buf[self.len - 1].tai_minus_utc
548    }
549}
550
551impl LeapSecondsProvider for LeapSeconds {
552    fn tai_minus_utc_at(
553        &self,
554        tai: Time<Tai>,
555    ) -> i32 {
556        let nanos = tai.as_nanos();
557        let entries = self.entries;
558
559        if entries.is_empty() {
560            return 19; // safe fallback value at GPS epoch
561        }
562
563        // Find the last entry with tai_nanos <= nanos
564        match entries.binary_search_by_key(&nanos, |e| e.tai_nanos) {
565            // Exact match: use found entry
566            Ok(i) => entries[i].tai_minus_utc,
567            // nanos is before first entry: return initial value
568            Err(0) => entries[0].tai_minus_utc,
569            // Standard case: entry before insertion point
570            Err(i) => entries[i - 1].tai_minus_utc,
571        }
572    }
573}
574
575impl LeapSecondsProvider for RuntimeLeapSeconds {
576    fn tai_minus_utc_at(
577        &self,
578        tai: Time<Tai>,
579    ) -> i32 {
580        let entries = self.entries();
581        let nanos = tai.as_nanos();
582
583        if entries.is_empty() {
584            return 19;
585        }
586
587        match entries.binary_search_by_key(&nanos, |e| e.tai_nanos) {
588            Ok(i) => entries[i].tai_minus_utc,
589            Err(0) => entries[0].tai_minus_utc,
590            Err(i) => entries[i - 1].tai_minus_utc,
591        }
592    }
593}
594
595// Generic implementation: &P automatically implements LeapSecondsProvider if P
596// does. This allows passing &LeapSeconds::builtin() directly.
597impl<P: LeapSecondsProvider> LeapSecondsProvider for &P {
598    fn tai_minus_utc_at(
599        &self,
600        tai: Time<Tai>,
601    ) -> i32 {
602        (*self).tai_minus_utc_at(tai)
603    }
604}
605
606////////////////////////////////////////////////////////////////////////////////
607// GLONASS -> UTC, GPS
608////////////////////////////////////////////////////////////////////////////////
609
610/// Converts GLONASS -> UTC (without leap-second context).
611///
612/// GLONASS tracks UTC(SU) = UTC + 3h, including leap seconds.
613/// Both scales store continuous nanoseconds, so the conversion is just a
614/// constant epoch shift.
615///
616/// # Shift
617///
618/// `UTC_ns = GLO_ns + 757_371_600_000_000_000`
619/// (= days from UTC epoch to GLONASS epoch × 86400 × 1e9)
620///
621/// # Errors
622///
623/// [`GnssTimeError::Overflow`] — if UTC < UTC epoch (1972-01-01).
624pub fn glonass_to_utc(glo: Time<Glonass>) -> Result<Time<Utc>, GnssTimeError> {
625    let utc_ns = (glo.as_nanos() as i128) + (GLONASS_FROM_UTC_EPOCH_NS as i128);
626
627    if utc_ns < 0 || utc_ns > u64::MAX as i128 {
628        return Err(GnssTimeError::Overflow);
629    }
630
631    Ok(Time::<Utc>::from_nanos(utc_ns as u64))
632}
633
634/// Converts GLONASS -> GPS via UTC.
635///
636/// Requires leap-second context (for UTC -> GPS).
637pub fn glonass_to_gps<P: LeapSecondsProvider>(
638    glo: Time<Glonass>,
639    ls: &P,
640) -> Result<Time<Gps>, GnssTimeError> {
641    let utc = glonass_to_utc(glo)?;
642
643    utc_to_gps(utc, ls)
644}
645
646/// Converts GLONASS -> Galileo via UTC (requires leap-second context).
647pub fn glonass_to_galileo<P: LeapSecondsProvider>(
648    glo: Time<Glonass>,
649    ls: &P,
650) -> Result<Time<Galileo>, GnssTimeError> {
651    let utc = glonass_to_utc(glo)?;
652
653    utc_to_galileo(utc, ls)
654}
655
656/// Converts GLONASS -> BeiDou via UTC (requires leap-second context).
657pub fn glonass_to_beidou<P: LeapSecondsProvider>(
658    glo: Time<Glonass>,
659    ls: &P,
660) -> Result<Time<Beidou>, GnssTimeError> {
661    let utc = glonass_to_utc(glo)?;
662
663    utc_to_beidou(utc, ls)
664}
665
666////////////////////////////////////////////////////////////////////////////////
667// GPS -> UTC, GLONASS
668////////////////////////////////////////////////////////////////////////////////
669
670/// Converts GPS -> UTC.
671///
672/// Requires an explicit [`LeapSecondsProvider`] context.
673///
674/// # Formula
675///
676/// ```text
677/// UTC_nanos_from_1972 = GPS_nanos_from_1980 - (TAI_minus_UTC - 19) * 1e9 + GPS_EPOCH_OFFSET_FROM_UTC_EPOCH_ns
678/// ```
679///
680/// # Errors
681///
682/// [`GnssTimeError::Overflow`] — the result does not fit into `u64`.
683///
684/// # Example
685///
686/// ```rust
687/// use gnss_time::{gps_to_utc, Gps, LeapSeconds, Time};
688///
689/// let ls = LeapSeconds::builtin();
690/// let gps = Time::<Gps>::from_nanos(0); // GPS epoch
691/// let utc = gps_to_utc(gps, &ls).unwrap();
692///
693/// // At the GPS epoch (1980-01-06), GPS-UTC = 0; UTC should represent the same instant
694/// assert_eq!(utc.as_nanos(), 252_892_800_000_000_000); // from 1972-01-01
695/// ```
696pub fn gps_to_utc<P: LeapSecondsProvider>(
697    gps: Time<Gps>,
698    ls: &P,
699) -> Result<Time<Utc>, GnssTimeError> {
700    let tai = gps.to_tai()?;
701    let n = ls.tai_minus_utc_at(tai);
702    // UTC_ns = GPS_ns - (n - 19) * 1e9 + epoch_offset
703    let utc_ns = (gps.as_nanos() as i128) - ((n - 19) as i128 * 1_000_000_000_i128)
704        + (UTC_TO_GPS_EPOCH_NS as i128);
705
706    if utc_ns < 0 || utc_ns > u64::MAX as i128 {
707        return Err(GnssTimeError::Overflow);
708    }
709
710    Ok(Time::<Utc>::from_nanos(utc_ns as u64))
711}
712
713/// Converts GPS -> GLONASS via UTC.
714///
715/// Requires leap-second context (for GPS -> UTC).
716pub fn gps_to_glonass<P: LeapSecondsProvider>(
717    gps: Time<Gps>,
718    ls: &P,
719) -> Result<Time<Glonass>, GnssTimeError> {
720    let utc = gps_to_utc(gps, ls)?;
721
722    utc_to_glonass(utc)
723}
724
725////////////////////////////////////////////////////////////////////////////////
726// Galileo -> UTC, GLONASS
727////////////////////////////////////////////////////////////////////////////////
728
729/// Galileo -> UTC (requires leap-second context).
730///
731/// Galileo and GPS have the same TAI offset (19 s), so `GAL -> UTC` is
732/// equivalent to `GPS -> UTC` (same nanoseconds, same context).
733pub fn galileo_to_utc<P: LeapSecondsProvider>(
734    gal: Time<Galileo>,
735    ls: &P,
736) -> Result<Time<Utc>, GnssTimeError> {
737    // Galileo and GPS share the same TAI offset, so we convert via GPS as an
738    // intermediate step.
739    let gps = gal.try_convert::<Gps>()?;
740
741    gps_to_utc(gps, ls)
742}
743
744/// Galileo -> GLONASS via UTC (requires leap-second context).
745pub fn galileo_to_glonass<P: LeapSecondsProvider>(
746    gal: Time<Galileo>,
747    ls: &P,
748) -> Result<Time<Glonass>, GnssTimeError> {
749    let utc = galileo_to_utc(gal, ls)?;
750
751    utc_to_glonass(utc)
752}
753
754////////////////////////////////////////////////////////////////////////////////
755// BeiDou -> UTC
756////////////////////////////////////////////////////////////////////////////////
757
758/// BeiDou -> UTC (requires leap-second context).
759///
760/// BDT = GPS − 14 s (via TAI: BDT + 33 s = TAI = GPS + 19 s).
761/// `BDT -> UTC` is converted through GPS as an intermediate step.
762pub fn beidou_to_utc<P: LeapSecondsProvider>(
763    bdt: Time<Beidou>,
764    ls: &P,
765) -> Result<Time<Utc>, GnssTimeError> {
766    let gps = bdt.try_convert::<Gps>()?;
767
768    gps_to_utc(gps, ls)
769}
770
771/// BeiDou -> GLONASS via UTC (requires leap-second context).
772pub fn beidou_to_glonass<P: LeapSecondsProvider>(
773    bdt: Time<Beidou>,
774    ls: &P,
775) -> Result<Time<Glonass>, GnssTimeError> {
776    let utc = beidou_to_utc(bdt, ls)?;
777
778    utc_to_glonass(utc)
779}
780
781////////////////////////////////////////////////////////////////////////////////
782// UTC -> GLONASS, GPS, Galielo, BeiDou
783////////////////////////////////////////////////////////////////////////////////
784
785/// Converts UTC -> GLONASS (without leap-second context).
786///
787/// # Errors
788///
789/// [`GnssTimeError::Overflow`] — if UTC is earlier than the GLONASS epoch
790/// (1996-01-01 UTC(SU)).
791pub fn utc_to_glonass(utc: Time<Utc>) -> Result<Time<Glonass>, GnssTimeError> {
792    let glo_ns = (utc.as_nanos() as i128) - (GLONASS_FROM_UTC_EPOCH_NS as i128);
793
794    if glo_ns < 0 || glo_ns > u64::MAX as i128 {
795        return Err(GnssTimeError::Overflow);
796    }
797
798    Ok(Time::<Glonass>::from_nanos(glo_ns as u64))
799}
800
801/// Converts UTC -> GPS.
802///
803/// Requires an explicit [`LeapSecondsProvider`] context.
804///
805/// # Accuracy at leap-second insertion
806///
807/// During the 1-second leap-second insertion window, the result may be off by
808/// 1 second. For all other instants, the result is exact.
809///
810/// # Errors
811///
812/// [`GnssTimeError::Overflow`] — the result does not fit into `u64`.
813pub fn utc_to_gps<P: LeapSecondsProvider>(
814    utc: Time<Utc>,
815    ls: &P,
816) -> Result<Time<Gps>, GnssTimeError> {
817    // Two-pass computation for correct leap-second boundary handling.
818    //
819    // Pass 1: approximate TAI assuming GPS-UTC = 0.
820    // This underestimates TAI by at most (current GPS-UTC) seconds
821    // near boundary conditions.
822    let approx_tai_ns =
823        (utc.as_nanos() as i128) - (UTC_TO_GPS_EPOCH_NS as i128) + 19_000_000_000_i128;
824
825    let tai1 = if approx_tai_ns >= 0 && approx_tai_ns <= u64::MAX as i128 {
826        Time::<Tai>::from_nanos(approx_tai_ns as u64)
827    } else {
828        Time::<Tai>::EPOCH
829    };
830
831    let n1 = ls.tai_minus_utc_at(tai1);
832
833    // Pass 2: refinement using n1, resolving boundary ambiguity.
834    let refined_tai_ns = (utc.as_nanos() as i128) - (UTC_TO_GPS_EPOCH_NS as i128)
835        + (n1 as i128 * 1_000_000_000_i128);
836
837    let tai2 = if refined_tai_ns >= 0 && refined_tai_ns <= u64::MAX as i128 {
838        Time::<Tai>::from_nanos(refined_tai_ns as u64)
839    } else {
840        tai1
841    };
842
843    let n = ls.tai_minus_utc_at(tai2);
844
845    let gps_ns = (utc.as_nanos() as i128) + ((n - 19) as i128 * 1_000_000_000_i128)
846        - (UTC_TO_GPS_EPOCH_NS as i128);
847    if gps_ns < 0 || gps_ns > u64::MAX as i128 {
848        return Err(GnssTimeError::Overflow);
849    }
850
851    Ok(Time::<Gps>::from_nanos(gps_ns as u64))
852}
853
854/// Converts UTC -> Galileo (requires leap-second context).
855pub fn utc_to_galileo<P: LeapSecondsProvider>(
856    utc: Time<Utc>,
857    ls: &P,
858) -> Result<Time<Galileo>, GnssTimeError> {
859    let gps = utc_to_gps(utc, ls)?;
860
861    gps.try_convert::<Galileo>()
862}
863
864/// Converts UTC -> BeiDou (requires leap-second context).
865pub fn utc_to_beidou<P: LeapSecondsProvider>(
866    utc: Time<Utc>,
867    ls: &P,
868) -> Result<Time<Beidou>, GnssTimeError> {
869    let gps = utc_to_gps(utc, ls)?;
870
871    gps.try_convert::<Beidou>()
872}
873
874impl core::fmt::Display for LeapExtendError {
875    fn fmt(
876        &self,
877        f: &mut core::fmt::Formatter<'_>,
878    ) -> core::fmt::Result {
879        match self {
880            LeapExtendError::NotStrictlyAscending => {
881                f.write_str("new entry tai_nanos is not strictly greater than the last entry")
882            }
883            LeapExtendError::NonUnitIncrement => {
884                f.write_str("new entry tai_minus_utc be exactly one more tham the last entry")
885            }
886            LeapExtendError::BufferFull => {
887                f.write_str("runtime leap-second buffer is full; cannot add more entries")
888            }
889        }
890    }
891}
892
893#[cfg(feature = "std")]
894impl std::error::Error for LeapExtendError {}
895
896impl Default for RuntimeLeapSeconds {
897    fn default() -> Self {
898        Self::new()
899    }
900}
901
902////////////////////////////////////////////////////////////////////////////////
903// Tests
904////////////////////////////////////////////////////////////////////////////////
905
906#[cfg(test)]
907mod tests {
908    #[allow(unused_imports)]
909    use std::string::ToString;
910
911    use super::*;
912    use crate::{scale::Gps, DurationParts};
913
914    #[test]
915    fn test_utc_to_gps_epoch_offset_is_252892800_seconds() {
916        assert_eq!(UTC_TO_GPS_EPOCH_NS / 1_000_000_000, 252_892_800);
917    }
918
919    #[test]
920    fn test_glonass_epoch_offset_is_757371600_seconds() {
921        assert_eq!(GLONASS_FROM_UTC_EPOCH_NS / 1_000_000_000, 757_371_600);
922    }
923
924    #[test]
925    fn test_builtin_table_length() {
926        assert_eq!(LeapSeconds::builtin().len(), 19);
927    }
928
929    #[test]
930    fn test_utc_to_gps_epoch_offset_is_2927_days() {
931        assert_eq!(UTC_TO_GPS_EPOCH_NS / 1_000_000_000 / 86_400, 2927);
932    }
933
934    #[test]
935    fn test_glonass_epoch_offset_from_utc_epoch_is_correct() {
936        // 757_371_600 s = 8766 days * 86400 - 3h
937        // = (days from 1972-01-01 to 1996-01-01) * 86400 - 10800
938        assert_eq!(GLONASS_FROM_UTC_EPOCH_NS / 1_000_000_000, 757_371_600);
939    }
940
941    #[test]
942    fn test_builtin_table_is_sorted() {
943        let entries = LeapSeconds::builtin().entries();
944
945        for w in entries.windows(2) {
946            assert!(
947                w[0].tai_nanos < w[1].tai_nanos,
948                "table not sorted at {:?}",
949                w
950            );
951        }
952    }
953
954    #[test]
955    fn test_builtin_table_starts_with_tai_minus_utc_19() {
956        assert_eq!(LeapSeconds::builtin().entries()[0].tai_minus_utc, 19);
957    }
958
959    #[test]
960    fn test_builtin_table_ends_with_tai_minus_utc_37() {
961        let last = *LeapSeconds::builtin().entries().last().unwrap();
962        assert_eq!(last.tai_minus_utc, 37);
963    }
964
965    #[test]
966    fn test_builtin_table_has_monotone_increasing_tai_minus_utc() {
967        let entries = LeapSeconds::builtin().entries();
968
969        for w in entries.windows(2) {
970            assert_eq!(
971                w[1].tai_minus_utc,
972                w[0].tai_minus_utc + 1,
973                "expected each entry to increment by 1"
974            );
975        }
976    }
977
978    // Cross-reference against raw IERS Bulletin C data.
979    //
980    // Each TAI threshold is independently recomputed from the Unix event
981    // timestamp using the canonical formula and compared to the compiled
982    // table.
983    #[test]
984    fn test_builtin_table_matches_iers_bulletin_c() {
985        const GPS_EPOCH_UNIX: u64 = 315_964_800;
986
987        // (unix_event_timestamp, expected_tai_minus_utc)
988        let iers_events: &[(u64, i32)] = &[
989            (362_793_600, 20),   // 1981-07-01
990            (394_329_600, 21),   // 1982-07-01
991            (425_865_600, 22),   // 1983-07-01
992            (489_024_000, 23),   // 1985-07-01
993            (567_993_600, 24),   // 1988-01-01
994            (631_152_000, 25),   // 1990-01-01
995            (662_688_000, 26),   // 1991-01-01
996            (709_948_800, 27),   // 1992-07-01
997            (741_484_800, 28),   // 1993-07-01
998            (773_020_800, 29),   // 1994-07-01
999            (820_454_400, 30),   // 1996-01-01
1000            (867_715_200, 31),   // 1997-07-01
1001            (915_148_800, 32),   // 1999-01-01
1002            (1_136_073_600, 33), // 2006-01-01
1003            (1_230_768_000, 34), // 2009-01-01
1004            (1_341_100_800, 35), // 2012-07-01
1005            (1_435_708_800, 36), // 2015-07-01
1006            (1_483_228_800, 37), // 2017-01-01
1007        ];
1008
1009        let entries = LeapSeconds::builtin().entries();
1010
1011        // Entry 0 is the base value at GPS epoch.
1012        assert_eq!(entries[0].tai_nanos, 0);
1013        assert_eq!(entries[0].tai_minus_utc, 19);
1014
1015        // Entries 1..18 must match the IERS events exactly.
1016        for (idx, &(unix, expected_n)) in iers_events.iter().enumerate() {
1017            let gps_s = unix - GPS_EPOCH_UNIX;
1018            let expected_threshold = (gps_s + expected_n as u64) * 1_000_000_000;
1019            let entry = &entries[idx + 1];
1020
1021            assert_eq!(
1022                entry.tai_nanos,
1023                expected_threshold,
1024                "threshold mismatch at IERS event {} (unix={})",
1025                idx + 1,
1026                unix
1027            );
1028            assert_eq!(
1029                entry.tai_minus_utc,
1030                expected_n,
1031                "tai_minus_utc mismatch at IERS event {} (unix={})",
1032                idx + 1,
1033                unix
1034            );
1035        }
1036    }
1037
1038    #[test]
1039    fn test_last_update_builtin_is_2017_threshold() {
1040        let last = LeapSeconds::builtin()
1041            .last_update()
1042            .expect("builtin must have last_update");
1043
1044        assert_eq!(last.as_nanos(), 1_167_264_037_000_000_000);
1045    }
1046
1047    #[test]
1048    fn test_last_update_single_entry_is_none() {
1049        static SINGLE: [LeapEntry; 1] = [LeapEntry::new(0, 37)];
1050        let ls = LeapSeconds::from_slice(&SINGLE);
1051
1052        assert!(ls.last_update().is_none());
1053    }
1054
1055    #[test]
1056    fn test_last_update_empty_is_none() {
1057        static EMPTY: [LeapEntry; 0] = [];
1058        let ls = LeapSeconds::from_slice(&EMPTY);
1059
1060        assert!(ls.last_update().is_none());
1061    }
1062
1063    #[test]
1064    fn test_current_tai_minus_utc_builtin_is_37() {
1065        assert_eq!(LeapSeconds::builtin().current_tai_minus_utc(), 37);
1066    }
1067
1068    #[test]
1069    fn test_current_tai_minus_utc_empty_is_fallback_19() {
1070        static EMPTY: [LeapEntry; 0] = [];
1071        let ls = LeapSeconds::from_slice(&EMPTY);
1072
1073        assert_eq!(ls.current_tai_minus_utc(), 19);
1074    }
1075
1076    #[test]
1077    fn test_from_slice_and_from_table_are_equivalent() {
1078        static TABLE: [LeapEntry; 2] = [LeapEntry::new(0, 19), LeapEntry::new(1_000_000, 20)];
1079
1080        let ls_slice = LeapSeconds::from_slice(&TABLE);
1081        let ls_table = LeapSeconds::from_table(&TABLE);
1082
1083        assert_eq!(ls_slice.len(), ls_table.len());
1084        assert_eq!(
1085            ls_slice.entries()[0].tai_nanos,
1086            ls_table.entries()[0].tai_nanos
1087        );
1088    }
1089
1090    #[test]
1091    fn test_lookup_at_tai_zero_returns_19() {
1092        let ls = LeapSeconds::builtin();
1093        assert_eq!(ls.tai_minus_utc_at(Time::<Tai>::EPOCH), 19);
1094    }
1095
1096    #[test]
1097    fn test_lookup_at_max_tai_returns_37() {
1098        let ls = LeapSeconds::builtin();
1099        assert_eq!(ls.tai_minus_utc_at(Time::<Tai>::MAX), 37);
1100    }
1101
1102    #[test]
1103    fn test_lookup_at_max_tai_returns_last_value() {
1104        let ls = LeapSeconds::builtin();
1105
1106        assert_eq!(ls.tai_minus_utc_at(Time::<Tai>::MAX), 37);
1107    }
1108
1109    #[test]
1110    fn test_lookup_at_exact_2017_threshold_returns_37() {
1111        let ls = LeapSeconds::builtin();
1112        // Threshold TAI value for 2017-01-01 = 1_167_264_037_000_000_000
1113        let tai = Time::<Tai>::from_nanos(1_167_264_037_000_000_000);
1114
1115        assert_eq!(ls.tai_minus_utc_at(tai), 37);
1116    }
1117
1118    #[test]
1119    fn test_lookup_one_ns_before_2017_threshold_returns_36() {
1120        let ls = LeapSeconds::builtin();
1121        let tai = Time::<Tai>::from_nanos(1_167_264_037_000_000_000 - 1);
1122
1123        assert_eq!(ls.tai_minus_utc_at(tai), 36);
1124    }
1125
1126    #[test]
1127    fn test_lookup_at_1999_threshold_returns_32() {
1128        let ls = LeapSeconds::builtin();
1129        // Threshold TAI value for 1999-01-01 = 599_184_032_000_000_000
1130        let tai = Time::<Tai>::from_nanos(599_184_032_000_000_000);
1131
1132        assert_eq!(ls.tai_minus_utc_at(tai), 32);
1133    }
1134
1135    #[test]
1136    fn test_lookup_one_ns_before_1999_threshold_returns_31() {
1137        let ls = LeapSeconds::builtin();
1138        let tai = Time::<Tai>::from_nanos(599_184_032_000_000_000 - 1);
1139
1140        assert_eq!(ls.tai_minus_utc_at(tai), 31);
1141    }
1142
1143    #[test]
1144    fn test_gps_utc_gps_roundtrip_at_gps_epoch() {
1145        let ls = LeapSeconds::builtin();
1146        let gps = Time::<Gps>::EPOCH;
1147        let utc = gps_to_utc(gps, &ls).unwrap();
1148        let back = utc_to_gps(utc, &ls).unwrap();
1149
1150        assert_eq!(gps, back);
1151    }
1152
1153    #[test]
1154    fn test_gps_utc_gps_roundtrip_at_2020() {
1155        let ls = LeapSeconds::builtin();
1156        // GPS 2020-01-01 ≈ week 2086
1157        let gps = Time::<Gps>::from_week_tow(
1158            2086,
1159            DurationParts {
1160                seconds: 0,
1161                nanos: 0,
1162            },
1163        )
1164        .unwrap();
1165        let utc = gps_to_utc(gps, &ls).unwrap();
1166        let back = utc_to_gps(utc, &ls).unwrap();
1167
1168        assert_eq!(gps, back);
1169    }
1170
1171    #[test]
1172    fn test_gps_epoch_utc_is_correct_offset_from_utc_epoch() {
1173        let ls = LeapSeconds::builtin();
1174        // At GPS epoch (1980-01-06) TAI-UTC = 19, GPS-UTC = 0
1175        // UTC nanos = GPS nanos + UTC_TO_GPS_EPOCH_NS = 0 +
1176        // 252_892_800_000_000_000
1177        let utc = gps_to_utc(Time::<Gps>::EPOCH, &ls).unwrap();
1178
1179        assert_eq!(utc.as_nanos(), 252_892_800_000_000_000);
1180    }
1181
1182    // Checking GPS-UTC = 18 at 2017-01-01 00:00:00 UTC.
1183    //
1184    // GPS at 2017-01-01 (unix=1483228800):
1185    //   GPS_s = (1483228800 - 315964800) + (37-19) = 1167264000 + 18 = 1167264018
1186    // UTC nanos from UTC_epoch = 16437 days * 86400 * 1e9 =
1187    // 1_420_156_800_000_000_000
1188    #[test]
1189    fn test_gps_minus_utc_is_18s_at_2017_01_01() {
1190        let ls = LeapSeconds::builtin();
1191        // GPS seconds for 2017-01-01 00:00:00 UTC
1192        // = (unix - GPS_EPOCH_UNIX) + (TAI-UTC - 19) = (1483228800 - 315964800) + 18
1193        let gps_s: u64 = 1_167_264_000 + 18;
1194        let gps = Time::<Gps>::from_seconds(gps_s);
1195        let utc = gps_to_utc(gps, &ls).unwrap();
1196
1197        // UTC nanos for 2017-01-01 = 16437 days * 86400 * 1e9
1198        let expected_utc_ns: u64 = 16_437 * 86_400 * 1_000_000_000;
1199
1200        assert_eq!(utc.as_nanos(), expected_utc_ns);
1201    }
1202
1203    // Check GPS-UTC = 13 on 1999-01-01 00:00:00 UTC.
1204    #[test]
1205    fn test_gps_minus_utc_is_13s_at_1999_01_01() {
1206        let ls = LeapSeconds::builtin();
1207        // GPS_s = (915148800 - 315964800) + (32 - 19) = 599184000 + 13 = 599184013
1208        let gps = Time::<Gps>::from_seconds(599_184_013);
1209        let utc = gps_to_utc(gps, &ls).unwrap();
1210
1211        // UTC from UTC epoch to 1999-01-01:
1212        // days_from_unix(1999-01-01) - days_from_unix(1972-01-01)
1213        // = 10592 - 730 = 9862 days (verified below)
1214        // UTC_s = 9862 * 86400 = 851_948_800
1215        let expected_utc_s: u64 = 9_862 * 86_400;
1216
1217        assert_eq!(utc.as_seconds(), expected_utc_s);
1218    }
1219
1220    // 1998-12-31 → 1999-01-01: TAI-UTC changes 31 → 32, GPS-UTC 12 → 13.
1221    //
1222    // GPS jumps from ...011 to ...013 (there is no ...012 in real UTC time).
1223    #[test]
1224    fn test_leap_second_transition_1999_gps_jumps_by_2s() {
1225        let ls = LeapSeconds::builtin();
1226
1227        // 1 second before transition: 1998-12-31 23:59:59 UTC
1228        // unix = 915148799, TAI-UTC = 31 (old value)
1229        // GPS_s = (915148799 - 315964800) + 12 = 599183999 + 12 = 599184011
1230        let gps_before = Time::<Gps>::from_seconds(599_184_011);
1231
1232        // Immediately after: 1999-01-01 00:00:00 UTC
1233        // unix = 915148800, TAI-UTC = 32 (new value)
1234        // GPS_s = (915148800 - 315964800) + 13 = 599184000 + 13 = 599184013
1235        let gps_after = Time::<Gps>::from_seconds(599_184_013);
1236
1237        // Both should convert correctly
1238        let utc_before = gps_to_utc(gps_before, &ls).unwrap();
1239        let utc_after = gps_to_utc(gps_after, &ls).unwrap();
1240
1241        // UTC-after - UTC-before = 1 second (leap second insertion adjusts the scale)
1242        let diff = (utc_after - utc_before).as_seconds();
1243
1244        assert_eq!(diff, 1, "GPS jumped 2s but UTC advanced 1s (leap second)");
1245    }
1246
1247    // 2016-12-31 → 2017-01-01: TAI-UTC 36 → 37, GPS-UTC 17 → 18.
1248    #[test]
1249    fn test_leap_second_transition_2017_gps_jumps_by_2s() {
1250        let ls = LeapSeconds::builtin();
1251        // 1 second before: unix = 1483228799,
1252        // GPS_s = (1483228799 - 315964800) + 17
1253        let gps_before = Time::<Gps>::from_seconds(1_167_263_999 + 17);
1254        // Immediately after: unix = 1483228800,
1255        // GPS_s = (1483228800 - 315964800) + 18
1256        let gps_after = Time::<Gps>::from_seconds(1_167_264_000 + 18);
1257        let utc_before = gps_to_utc(gps_before, &ls).unwrap();
1258        let utc_after = gps_to_utc(gps_after, &ls).unwrap();
1259        let diff = (utc_after - utc_before).as_seconds();
1260
1261        assert_eq!(diff, 1, "GPS jumped 2s but UTC advanced 1s");
1262    }
1263
1264    #[test]
1265    fn test_glonass_epoch_to_utc_gives_correct_nanos() {
1266        // GLONASS epoch = 1996-01-01 00:00:00 UTC(SU)
1267        // which corresponds to 1995-12-31 21:00:00 UTC
1268        //
1269        // UTC offset from UTC epoch:
1270        // (days to 1995-12-31) * 86400 + 21h * 3600 = ...
1271        // Verified via GLONASS_FROM_UTC_EPOCH_NS constant
1272        let utc = glonass_to_utc(Time::<Glonass>::EPOCH).unwrap();
1273
1274        assert_eq!(utc.as_nanos(), GLONASS_FROM_UTC_EPOCH_NS as u64);
1275    }
1276
1277    #[test]
1278    fn test_utc_to_glonass_epoch_gives_zero() {
1279        let utc = Time::<Utc>::from_nanos(GLONASS_FROM_UTC_EPOCH_NS as u64);
1280        let glo = utc_to_glonass(utc).unwrap();
1281
1282        assert_eq!(glo, Time::<Glonass>::EPOCH);
1283    }
1284
1285    #[test]
1286    fn test_glonass_utc_glonass_roundtrip() {
1287        let glo = Time::<Glonass>::from_day_tod(
1288            10_000,
1289            DurationParts {
1290                seconds: 43_200,
1291                nanos: 0,
1292            },
1293        )
1294        .unwrap();
1295        let utc = glonass_to_utc(glo).unwrap();
1296        let back = utc_to_glonass(utc).unwrap();
1297
1298        assert_eq!(glo, back);
1299    }
1300
1301    #[test]
1302    fn test_utc_before_glonass_epoch_returns_error() {
1303        // UTC epoch (1972-01-01) is earlier than GLONASS epoch (1996),
1304        // so conversion results in underflow/overflow
1305        let utc = Time::<Utc>::EPOCH;
1306
1307        assert!(matches!(utc_to_glonass(utc), Err(GnssTimeError::Overflow)));
1308    }
1309
1310    #[test]
1311    fn test_glonass_offset_is_exactly_3_hours_less_than_day_boundary() {
1312        // Offset = 8766 days * 86400 - 3*3600 (exactly 3 hours before midnight
1313        // 1996-01-01 UTC)
1314        let three_hours_ns: i64 = 3 * 3_600 * 1_000_000_000;
1315        let days_ns: i64 = 8766 * 86_400 * 1_000_000_000;
1316
1317        assert_eq!(GLONASS_FROM_UTC_EPOCH_NS, days_ns - three_hours_ns);
1318    }
1319
1320    #[test]
1321    fn test_gps_to_glonass_to_gps_roundtrip() {
1322        let ls = LeapSeconds::builtin();
1323        // GPS time in 2020 (after the last leap second in 2017)
1324        let gps = Time::<Gps>::from_week_tow(
1325            2100,
1326            DurationParts {
1327                seconds: 86400,
1328                nanos: 0,
1329            },
1330        )
1331        .unwrap();
1332        let glo = gps_to_glonass(gps, &ls).unwrap();
1333        let back = glonass_to_gps(glo, &ls).unwrap();
1334
1335        assert_eq!(gps, back);
1336    }
1337
1338    #[test]
1339    fn test_custom_provider_works() {
1340        struct Always37;
1341
1342        impl LeapSecondsProvider for Always37 {
1343            fn tai_minus_utc_at(
1344                &self,
1345                _: Time<Tai>,
1346            ) -> i32 {
1347                37
1348            }
1349        }
1350
1351        let gps = Time::<Gps>::from_seconds(1_000_000_000);
1352        let utc = gps_to_utc(gps, &Always37).unwrap();
1353        let back = utc_to_gps(utc, &Always37).unwrap();
1354
1355        assert_eq!(gps, back);
1356    }
1357
1358    #[test]
1359    fn test_empty_table_returns_fallback_19() {
1360        static EMPTY: [LeapEntry; 0] = [];
1361
1362        let ls = LeapSeconds::from_table(&EMPTY);
1363
1364        assert_eq!(
1365            ls.tai_minus_utc_at(Time::<Tai>::from_seconds(1_000_000)),
1366            19
1367        );
1368    }
1369
1370    #[test]
1371    fn test_runtime_from_builtin_has_19_entries() {
1372        assert_eq!(RuntimeLeapSeconds::from_builtin().len(), 19);
1373    }
1374
1375    #[test]
1376    fn test_runtime_from_builtin_current_is_37() {
1377        assert_eq!(
1378            RuntimeLeapSeconds::from_builtin().current_tai_minus_utc(),
1379            37
1380        );
1381    }
1382
1383    #[test]
1384    fn test_runtime_try_extend_valid() {
1385        let mut rt = RuntimeLeapSeconds::from_builtin();
1386        rt.try_extend(LeapEntry::new(9_999_999_999_000_000_000, 38))
1387            .unwrap();
1388
1389        assert_eq!(rt.len(), 20);
1390        assert_eq!(rt.current_tai_minus_utc(), 38);
1391    }
1392
1393    #[test]
1394    fn test_runtime_try_extend_last_update_updated() {
1395        let mut rt = RuntimeLeapSeconds::from_builtin();
1396        rt.try_extend(LeapEntry::new(9_999_999_999_000_000_000, 38))
1397            .unwrap();
1398
1399        let last = rt.last_update().unwrap();
1400        assert_eq!(last.as_nanos(), 9_999_999_999_000_000_000);
1401    }
1402
1403    #[test]
1404    fn test_runtime_try_extend_not_ascending_error() {
1405        let mut rt = RuntimeLeapSeconds::from_builtin();
1406        // Same threshold as last builtin entry — not strictly ascending.
1407        let err = rt
1408            .try_extend(LeapEntry::new(1_167_264_037_000_000_000, 38))
1409            .unwrap_err();
1410
1411        assert_eq!(err, LeapExtendError::NotStrictlyAscending);
1412    }
1413
1414    #[test]
1415    fn test_runtime_try_extend_non_unit_increment_error() {
1416        let mut rt = RuntimeLeapSeconds::from_builtin();
1417        // Skips to 39 instead of 38.
1418        let err = rt
1419            .try_extend(LeapEntry::new(9_999_999_999_000_000_000, 39))
1420            .unwrap_err();
1421
1422        assert_eq!(err, LeapExtendError::NonUnitIncrement);
1423    }
1424
1425    #[test]
1426    fn test_runtime_from_slice_too_large_returns_buffer_full() {
1427        let big: std::vec::Vec<LeapEntry> = (0..RUNTIME_CAPACITY + 1)
1428            .map(|i| LeapEntry::new(i as u64 * 1_000_000_000, 19 + i as i32))
1429            .collect();
1430        let err = RuntimeLeapSeconds::from_slice(&big).unwrap_err();
1431
1432        assert_eq!(err, LeapExtendError::BufferFull);
1433    }
1434
1435    #[test]
1436    fn test_runtime_provider_matches_static_at_all_thresholds() {
1437        let rt = RuntimeLeapSeconds::from_builtin();
1438        let ls = LeapSeconds::builtin();
1439
1440        let test_nanos: &[u64] = &[
1441            0,
1442            46_828_820_000_000_000,
1443            599_184_032_000_000_000,
1444            1_167_264_037_000_000_000,
1445            u64::MAX,
1446        ];
1447
1448        for &nanos in test_nanos {
1449            let tai = Time::<Tai>::from_nanos(nanos);
1450            assert_eq!(
1451                rt.tai_minus_utc_at(tai),
1452                ls.tai_minus_utc_at(tai),
1453                "mismatch at tai_nanos={}",
1454                nanos
1455            );
1456        }
1457    }
1458
1459    #[test]
1460    fn test_runtime_empty_last_update_is_none() {
1461        assert!(RuntimeLeapSeconds::new().last_update().is_none());
1462    }
1463
1464    #[test]
1465    fn test_runtime_single_entry_last_update_is_none() {
1466        let mut rt = RuntimeLeapSeconds::new();
1467        rt.try_extend(LeapEntry::new(0, 19)).unwrap();
1468        assert!(rt.last_update().is_none());
1469    }
1470
1471    #[test]
1472    fn test_gps_utc_gps_roundtrip_with_runtime_table() {
1473        let rt = RuntimeLeapSeconds::from_builtin();
1474        let gps = Time::<Gps>::from_week_tow(
1475            2086,
1476            DurationParts {
1477                seconds: 0,
1478                nanos: 0,
1479            },
1480        )
1481        .unwrap();
1482        let utc = gps_to_utc(gps, &rt).unwrap();
1483        let back = utc_to_gps(utc, &rt).unwrap();
1484
1485        assert_eq!(gps, back);
1486    }
1487
1488    #[test]
1489    fn test_gps_utc_roundtrip_extended_table() {
1490        let mut rt = RuntimeLeapSeconds::from_builtin();
1491        rt.try_extend(LeapEntry::new(9_999_999_999_000_000_000, 38))
1492            .unwrap();
1493
1494        let gps = Time::<Gps>::from_week_tow(
1495            2086,
1496            DurationParts {
1497                seconds: 0,
1498                nanos: 0,
1499            },
1500        )
1501        .unwrap();
1502        let utc = gps_to_utc(gps, &rt).unwrap();
1503        let back = utc_to_gps(utc, &rt).unwrap();
1504
1505        assert_eq!(gps, back);
1506    }
1507
1508    #[test]
1509    fn test_gps_epoch_utc_is_correct() {
1510        let ls = LeapSeconds::builtin();
1511        let utc = gps_to_utc(Time::<Gps>::EPOCH, &ls).unwrap();
1512
1513        assert_eq!(utc.as_nanos(), 252_892_800_000_000_000);
1514    }
1515
1516    #[test]
1517    fn test_custom_provider_roundtrip() {
1518        struct Always37;
1519        impl LeapSecondsProvider for Always37 {
1520            fn tai_minus_utc_at(
1521                &self,
1522                _: Time<Tai>,
1523            ) -> i32 {
1524                37
1525            }
1526        }
1527
1528        let gps = Time::<Gps>::from_seconds(1_000_000_000);
1529        let utc = gps_to_utc(gps, &Always37).unwrap();
1530        let back = utc_to_gps(utc, &Always37).unwrap();
1531
1532        assert_eq!(gps, back);
1533    }
1534}