Skip to main content

gnss_time/
leap.rs

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