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 const 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 const 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 const 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 const 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 {
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 = i128::from(glo.as_nanos()) + i128::from(GLONASS_FROM_UTC_EPOCH_NS);
628
629    if utc_ns < 0 || utc_ns > i128::from(u64::MAX) {
630        return Err(GnssTimeError::Overflow);
631    }
632
633    let nanos = u64::try_from(utc_ns).map_err(|_| GnssTimeError::Overflow)?;
634
635    Ok(Time::<Utc>::from_nanos(nanos))
636}
637
638/// Converts GLONASS -> GPS via UTC.
639///
640/// Requires leap-second context (for UTC -> GPS).
641///
642/// # Errors
643///
644/// Propagates:
645/// - [`GnssTimeError::Overflow`] from [`glonass_to_utc`]
646/// - [`GnssTimeError::Overflow`] from [`utc_to_gps`]
647pub fn glonass_to_gps<P: LeapSecondsProvider>(
648    glo: Time<Glonass>,
649    ls: &P,
650) -> Result<Time<Gps>, GnssTimeError> {
651    let utc = glonass_to_utc(glo)?;
652
653    utc_to_gps(utc, ls)
654}
655
656/// Converts GLONASS -> Galileo via UTC (requires leap-second context).
657///
658/// # Errors
659///
660/// Propagates:
661/// - [`GnssTimeError::Overflow`] from [`glonass_to_utc`]
662/// - [`GnssTimeError::Overflow`] from [`utc_to_galileo`]
663pub fn glonass_to_galileo<P: LeapSecondsProvider>(
664    glo: Time<Glonass>,
665    ls: &P,
666) -> Result<Time<Galileo>, GnssTimeError> {
667    let utc = glonass_to_utc(glo)?;
668
669    utc_to_galileo(utc, ls)
670}
671
672/// Converts GLONASS -> `BeiDou` via UTC (requires leap-second context).
673///
674/// # Errors
675///
676/// Propagates:
677/// - [`GnssTimeError::Overflow`] from [`glonass_to_utc`]
678/// - [`GnssTimeError::Overflow`] from [`utc_to_beidou`]
679pub fn glonass_to_beidou<P: LeapSecondsProvider>(
680    glo: Time<Glonass>,
681    ls: &P,
682) -> Result<Time<Beidou>, GnssTimeError> {
683    let utc = glonass_to_utc(glo)?;
684
685    utc_to_beidou(utc, ls)
686}
687
688////////////////////////////////////////////////////////////////////////////////
689// GPS -> UTC, GLONASS
690////////////////////////////////////////////////////////////////////////////////
691
692/// Converts GPS -> UTC.
693///
694/// Requires an explicit [`LeapSecondsProvider`] context.
695///
696/// # Formula
697///
698/// ```text
699/// UTC_nanos_from_1972 = GPS_nanos_from_1980 - (TAI_minus_UTC - 19) * 1e9 + GPS_EPOCH_OFFSET_FROM_UTC_EPOCH_ns
700/// ```
701///
702/// # Errors
703///
704/// [`GnssTimeError::Overflow`] — the result does not fit into `u64`.
705///
706/// # Example
707///
708/// ```rust
709/// use gnss_time::{gps_to_utc, Gps, LeapSeconds, Time};
710///
711/// let ls = LeapSeconds::builtin();
712/// let gps = Time::<Gps>::from_nanos(0); // GPS epoch
713/// let utc = gps_to_utc(gps, &ls).unwrap();
714///
715/// // At the GPS epoch (1980-01-06), GPS-UTC = 0; UTC should represent the same instant
716/// assert_eq!(utc.as_nanos(), 252_892_800_000_000_000); // from 1972-01-01
717/// ```
718pub fn gps_to_utc<P: LeapSecondsProvider>(
719    gps: Time<Gps>,
720    ls: &P,
721) -> Result<Time<Utc>, GnssTimeError> {
722    let tai = gps.to_tai()?;
723    let n = ls.tai_minus_utc_at(tai);
724    // UTC_ns = GPS_ns - (n - 19) * 1e9 + epoch_offset
725    let utc_ns = i128::from(gps.as_nanos()) - (i128::from(n - 19) * 1_000_000_000_i128)
726        + i128::from(UTC_TO_GPS_EPOCH_NS);
727
728    if utc_ns < 0 || utc_ns > i128::from(u64::MAX) {
729        return Err(GnssTimeError::Overflow);
730    }
731
732    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
733    let nanos = utc_ns as u64;
734
735    Ok(Time::<Utc>::from_nanos(nanos))
736}
737
738/// Converts GPS -> GLONASS via UTC.
739///
740/// Requires leap-second context (for GPS -> UTC).
741///
742/// # Errors
743///
744/// Propagates:
745/// - [`GnssTimeError::Overflow`] from [`gps_to_utc`]
746/// - [`GnssTimeError::Overflow`] from [`utc_to_glonass`]
747pub fn gps_to_glonass<P: LeapSecondsProvider>(
748    gps: Time<Gps>,
749    ls: &P,
750) -> Result<Time<Glonass>, GnssTimeError> {
751    let utc = gps_to_utc(gps, ls)?;
752
753    utc_to_glonass(utc)
754}
755
756////////////////////////////////////////////////////////////////////////////////
757// Galileo -> UTC, GLONASS
758////////////////////////////////////////////////////////////////////////////////
759
760/// Galileo -> UTC (requires leap-second context).
761///
762/// # Errors
763///
764/// Returns `GnssTimeError` if either:
765/// - the Galileo → GPS time scale conversion fails, or
766/// - the underlying GPS → UTC conversion fails (e.g. overflow).
767pub fn galileo_to_utc<P: LeapSecondsProvider>(
768    gal: Time<Galileo>,
769    ls: &P,
770) -> Result<Time<Utc>, GnssTimeError> {
771    // Galileo and GPS share the same TAI offset, so we convert via GPS as an
772    // intermediate step.
773    let gps = gal.try_convert::<Gps>()?;
774
775    gps_to_utc(gps, ls)
776}
777
778/// Converts GLONASS -> Galileo via UTC (requires leap-second context).
779///
780/// # Errors
781///
782/// Returns [`GnssTimeError`] if either:
783/// - the Galileo → GPS → UTC conversion fails (e.g. invalid conversion or
784///   overflow)
785/// - the UTC → GLONASS conversion fails due to overflow
786pub fn galileo_to_glonass<P: LeapSecondsProvider>(
787    gal: Time<Galileo>,
788    ls: &P,
789) -> Result<Time<Glonass>, GnssTimeError> {
790    let utc = galileo_to_utc(gal, ls)?;
791
792    utc_to_glonass(utc)
793}
794
795////////////////////////////////////////////////////////////////////////////////
796// BeiDou -> UTC
797////////////////////////////////////////////////////////////////////////////////
798
799/// `BeiDou` -> UTC (requires leap-second context).
800///
801/// This conversion proceeds via GPS as an intermediate time scale.
802///
803/// # Errors
804///
805/// Returns [`GnssTimeError`] if:
806/// - the internal `BeiDou -> GPS` conversion fails (`try_convert::<Gps>`), or
807/// - the resulting GPS -> UTC conversion overflows the valid `u64` time range.
808pub fn beidou_to_utc<P: LeapSecondsProvider>(
809    bdt: Time<Beidou>,
810    ls: &P,
811) -> Result<Time<Utc>, GnssTimeError> {
812    let gps = bdt.try_convert::<Gps>()?;
813
814    gps_to_utc(gps, ls)
815}
816
817/// `BeiDou` -> GLONASS via UTC (requires leap-second context).
818///
819/// # Errors
820///
821/// This function will return an error if:
822/// - the intermediate `BeiDou -> UTC` conversion fails (see [`beidou_to_utc`])
823/// - the `UTC -> GLONASS` conversion fails due to overflow (see
824///   [`utc_to_glonass`])
825pub fn beidou_to_glonass<P: LeapSecondsProvider>(
826    bdt: Time<Beidou>,
827    ls: &P,
828) -> Result<Time<Glonass>, GnssTimeError> {
829    let utc = beidou_to_utc(bdt, ls)?;
830
831    utc_to_glonass(utc)
832}
833
834////////////////////////////////////////////////////////////////////////////////
835// UTC -> GLONASS, GPS, Galielo, BeiDou
836////////////////////////////////////////////////////////////////////////////////
837
838/// Converts UTC -> GLONASS (without leap-second context).
839///
840/// # Errors
841///
842/// [`GnssTimeError::Overflow`] — if UTC is earlier than the GLONASS epoch
843/// (1996-01-01 UTC(SU)).
844pub fn utc_to_glonass(utc: Time<Utc>) -> Result<Time<Glonass>, GnssTimeError> {
845    let glo_ns = i128::from(utc.as_nanos()) - i128::from(GLONASS_FROM_UTC_EPOCH_NS);
846
847    if glo_ns < 0 || glo_ns > i128::from(u64::MAX) {
848        return Err(GnssTimeError::Overflow);
849    }
850
851    let nanos = u64::try_from(glo_ns).map_err(|_| GnssTimeError::Overflow)?;
852
853    Ok(Time::<Glonass>::from_nanos(nanos))
854}
855
856/// Converts UTC -> GPS.
857///
858/// Requires an explicit [`LeapSecondsProvider`] context.
859///
860/// # Accuracy at leap-second insertion
861///
862/// During the 1-second leap-second insertion window, the result may be off by
863/// 1 second. For all other instants, the result is exact.
864///
865/// # Errors
866///
867/// [`GnssTimeError::Overflow`] — the result does not fit into `u64`.
868pub fn utc_to_gps<P: LeapSecondsProvider>(
869    utc: Time<Utc>,
870    ls: &P,
871) -> Result<Time<Gps>, GnssTimeError> {
872    // Two-pass computation for correct leap-second boundary handling.
873    //
874    // Pass 1: approximate TAI assuming GPS-UTC = 0.
875    let approx_tai_ns =
876        i128::from(utc.as_nanos()) - i128::from(UTC_TO_GPS_EPOCH_NS) + 19_000_000_000_i128;
877
878    let tai1 = match u64::try_from(approx_tai_ns) {
879        Ok(ns) => Time::<Tai>::from_nanos(ns),
880        Err(_) => Time::<Tai>::EPOCH,
881    };
882
883    let n1 = ls.tai_minus_utc_at(tai1);
884
885    // Pass 2: refinement using n1, resolving boundary ambiguity.
886    let refined_tai_ns = i128::from(utc.as_nanos()) - i128::from(UTC_TO_GPS_EPOCH_NS)
887        + (i128::from(n1) * 1_000_000_000_i128);
888
889    let tai2 = match u64::try_from(refined_tai_ns) {
890        Ok(ns) => Time::<Tai>::from_nanos(ns),
891        Err(_) => tai1,
892    };
893
894    let n = ls.tai_minus_utc_at(tai2);
895
896    let gps_ns = i128::from(utc.as_nanos()) + (i128::from(n - 19) * 1_000_000_000_i128)
897        - i128::from(UTC_TO_GPS_EPOCH_NS);
898
899    let gps_ns = u64::try_from(gps_ns).map_err(|_| GnssTimeError::Overflow)?;
900
901    Ok(Time::<Gps>::from_nanos(gps_ns))
902}
903
904/// Converts UTC -> Galileo (requires leap-second context).
905///
906/// # Errors
907///
908/// Returns [`GnssTimeError`] if either:
909/// - the intermediate UTC → GPS conversion fails (e.g. overflow), or
910/// - the GPS → Galileo time-scale conversion fails.
911pub fn utc_to_galileo<P: LeapSecondsProvider>(
912    utc: Time<Utc>,
913    ls: &P,
914) -> Result<Time<Galileo>, GnssTimeError> {
915    let gps = utc_to_gps(utc, ls)?;
916
917    gps.try_convert::<Galileo>()
918}
919
920/// Converts UTC -> `BeiDou` (requires leap-second context).
921///
922/// # Errors
923///
924/// This function returns [`GnssTimeError`] if:
925/// - the intermediate UTC → GPS conversion fails (overflow), or
926/// - the GPS → `BeiDou` conversion fails (`try_convert::<Beidou>`).
927pub fn utc_to_beidou<P: LeapSecondsProvider>(
928    utc: Time<Utc>,
929    ls: &P,
930) -> Result<Time<Beidou>, GnssTimeError> {
931    let gps = utc_to_gps(utc, ls)?;
932
933    gps.try_convert::<Beidou>()
934}
935
936impl core::fmt::Display for LeapExtendError {
937    fn fmt(
938        &self,
939        f: &mut core::fmt::Formatter<'_>,
940    ) -> core::fmt::Result {
941        match self {
942            LeapExtendError::NotStrictlyAscending => {
943                f.write_str("new entry tai_nanos is not strictly greater than the last entry")
944            }
945            LeapExtendError::NonUnitIncrement => {
946                f.write_str("new entry tai_minus_utc be exactly one more tham the last entry")
947            }
948            LeapExtendError::BufferFull => {
949                f.write_str("runtime leap-second buffer is full; cannot add more entries")
950            }
951        }
952    }
953}
954
955#[cfg(feature = "std")]
956impl std::error::Error for LeapExtendError {}
957
958impl Default for RuntimeLeapSeconds {
959    fn default() -> Self {
960        Self::new()
961    }
962}
963
964////////////////////////////////////////////////////////////////////////////////
965// Tests
966////////////////////////////////////////////////////////////////////////////////
967
968#[cfg(test)]
969mod tests {
970    #[allow(unused_imports)]
971    use std::string::ToString;
972
973    use super::*;
974    use crate::{scale::Gps, DurationParts};
975
976    #[test]
977    fn test_utc_to_gps_epoch_offset_is_252892800_seconds() {
978        assert_eq!(UTC_TO_GPS_EPOCH_NS / 1_000_000_000, 252_892_800);
979    }
980
981    #[test]
982    fn test_glonass_epoch_offset_is_757371600_seconds() {
983        assert_eq!(GLONASS_FROM_UTC_EPOCH_NS / 1_000_000_000, 757_371_600);
984    }
985
986    #[test]
987    fn test_builtin_table_length() {
988        assert_eq!(LeapSeconds::builtin().len(), 19);
989    }
990
991    #[test]
992    fn test_utc_to_gps_epoch_offset_is_2927_days() {
993        assert_eq!(UTC_TO_GPS_EPOCH_NS / 1_000_000_000 / 86_400, 2927);
994    }
995
996    #[test]
997    fn test_glonass_epoch_offset_from_utc_epoch_is_correct() {
998        // 757_371_600 s = 8766 days * 86400 - 3h
999        // = (days from 1972-01-01 to 1996-01-01) * 86400 - 10800
1000        assert_eq!(GLONASS_FROM_UTC_EPOCH_NS / 1_000_000_000, 757_371_600);
1001    }
1002
1003    #[test]
1004    fn test_builtin_table_is_sorted() {
1005        let entries = LeapSeconds::builtin().entries();
1006
1007        for w in entries.windows(2) {
1008            assert!(w[0].tai_nanos < w[1].tai_nanos, "table not sorted at {w:?}",);
1009        }
1010    }
1011
1012    #[test]
1013    fn test_builtin_table_starts_with_tai_minus_utc_19() {
1014        assert_eq!(LeapSeconds::builtin().entries()[0].tai_minus_utc, 19);
1015    }
1016
1017    #[test]
1018    fn test_builtin_table_ends_with_tai_minus_utc_37() {
1019        let last = *LeapSeconds::builtin().entries().last().unwrap();
1020        assert_eq!(last.tai_minus_utc, 37);
1021    }
1022
1023    #[test]
1024    fn test_builtin_table_has_monotone_increasing_tai_minus_utc() {
1025        let entries = LeapSeconds::builtin().entries();
1026
1027        for w in entries.windows(2) {
1028            assert_eq!(
1029                w[1].tai_minus_utc,
1030                w[0].tai_minus_utc + 1,
1031                "expected each entry to increment by 1"
1032            );
1033        }
1034    }
1035
1036    // Cross-reference against raw IERS Bulletin C data.
1037    //
1038    // Each TAI threshold is independently recomputed from the Unix event
1039    // timestamp using the canonical formula and compared to the compiled
1040    // table.
1041    #[test]
1042    fn test_builtin_table_matches_iers_bulletin_c() {
1043        const GPS_EPOCH_UNIX: u64 = 315_964_800;
1044
1045        // (unix_event_timestamp, expected_tai_minus_utc)
1046        let iers_events: &[(u64, i32)] = &[
1047            (362_793_600, 20),   // 1981-07-01
1048            (394_329_600, 21),   // 1982-07-01
1049            (425_865_600, 22),   // 1983-07-01
1050            (489_024_000, 23),   // 1985-07-01
1051            (567_993_600, 24),   // 1988-01-01
1052            (631_152_000, 25),   // 1990-01-01
1053            (662_688_000, 26),   // 1991-01-01
1054            (709_948_800, 27),   // 1992-07-01
1055            (741_484_800, 28),   // 1993-07-01
1056            (773_020_800, 29),   // 1994-07-01
1057            (820_454_400, 30),   // 1996-01-01
1058            (867_715_200, 31),   // 1997-07-01
1059            (915_148_800, 32),   // 1999-01-01
1060            (1_136_073_600, 33), // 2006-01-01
1061            (1_230_768_000, 34), // 2009-01-01
1062            (1_341_100_800, 35), // 2012-07-01
1063            (1_435_708_800, 36), // 2015-07-01
1064            (1_483_228_800, 37), // 2017-01-01
1065        ];
1066
1067        let entries = LeapSeconds::builtin().entries();
1068
1069        // Entry 0 is the base value at GPS epoch.
1070        assert_eq!(entries[0].tai_nanos, 0);
1071        assert_eq!(entries[0].tai_minus_utc, 19);
1072
1073        // Entries 1..18 must match the IERS events exactly.
1074        for (idx, &(unix, expected_n)) in iers_events.iter().enumerate() {
1075            let gps_s = unix - GPS_EPOCH_UNIX;
1076            let expected_threshold = (gps_s + u64::try_from(expected_n).unwrap()) * 1_000_000_000;
1077            let entry = &entries[idx + 1];
1078
1079            assert_eq!(
1080                entry.tai_nanos,
1081                expected_threshold,
1082                "threshold mismatch at IERS event {} (unix={})",
1083                idx + 1,
1084                unix
1085            );
1086            assert_eq!(
1087                entry.tai_minus_utc,
1088                expected_n,
1089                "tai_minus_utc mismatch at IERS event {} (unix={})",
1090                idx + 1,
1091                unix
1092            );
1093        }
1094    }
1095
1096    #[test]
1097    fn test_last_update_builtin_is_2017_threshold() {
1098        let last = LeapSeconds::builtin()
1099            .last_update()
1100            .expect("builtin must have last_update");
1101
1102        assert_eq!(last.as_nanos(), 1_167_264_037_000_000_000);
1103    }
1104
1105    #[test]
1106    fn test_last_update_single_entry_is_none() {
1107        static SINGLE: [LeapEntry; 1] = [LeapEntry::new(0, 37)];
1108        let ls = LeapSeconds::from_slice(&SINGLE);
1109
1110        assert!(ls.last_update().is_none());
1111    }
1112
1113    #[test]
1114    fn test_last_update_empty_is_none() {
1115        static EMPTY: [LeapEntry; 0] = [];
1116        let ls = LeapSeconds::from_slice(&EMPTY);
1117
1118        assert!(ls.last_update().is_none());
1119    }
1120
1121    #[test]
1122    fn test_current_tai_minus_utc_builtin_is_37() {
1123        assert_eq!(LeapSeconds::builtin().current_tai_minus_utc(), 37);
1124    }
1125
1126    #[test]
1127    fn test_current_tai_minus_utc_empty_is_fallback_19() {
1128        static EMPTY: [LeapEntry; 0] = [];
1129        let ls = LeapSeconds::from_slice(&EMPTY);
1130
1131        assert_eq!(ls.current_tai_minus_utc(), 19);
1132    }
1133
1134    #[test]
1135    fn test_from_slice_and_from_table_are_equivalent() {
1136        static TABLE: [LeapEntry; 2] = [LeapEntry::new(0, 19), LeapEntry::new(1_000_000, 20)];
1137
1138        let ls_slice = LeapSeconds::from_slice(&TABLE);
1139        let ls_table = LeapSeconds::from_table(&TABLE);
1140
1141        assert_eq!(ls_slice.len(), ls_table.len());
1142        assert_eq!(
1143            ls_slice.entries()[0].tai_nanos,
1144            ls_table.entries()[0].tai_nanos
1145        );
1146    }
1147
1148    #[test]
1149    fn test_lookup_at_tai_zero_returns_19() {
1150        let ls = LeapSeconds::builtin();
1151        assert_eq!(ls.tai_minus_utc_at(Time::<Tai>::EPOCH), 19);
1152    }
1153
1154    #[test]
1155    fn test_lookup_at_max_tai_returns_37() {
1156        let ls = LeapSeconds::builtin();
1157        assert_eq!(ls.tai_minus_utc_at(Time::<Tai>::MAX), 37);
1158    }
1159
1160    #[test]
1161    fn test_lookup_at_max_tai_returns_last_value() {
1162        let ls = LeapSeconds::builtin();
1163
1164        assert_eq!(ls.tai_minus_utc_at(Time::<Tai>::MAX), 37);
1165    }
1166
1167    #[test]
1168    fn test_lookup_at_exact_2017_threshold_returns_37() {
1169        let ls = LeapSeconds::builtin();
1170        // Threshold TAI value for 2017-01-01 = 1_167_264_037_000_000_000
1171        let tai = Time::<Tai>::from_nanos(1_167_264_037_000_000_000);
1172
1173        assert_eq!(ls.tai_minus_utc_at(tai), 37);
1174    }
1175
1176    #[test]
1177    fn test_lookup_one_ns_before_2017_threshold_returns_36() {
1178        let ls = LeapSeconds::builtin();
1179        let tai = Time::<Tai>::from_nanos(1_167_264_037_000_000_000 - 1);
1180
1181        assert_eq!(ls.tai_minus_utc_at(tai), 36);
1182    }
1183
1184    #[test]
1185    fn test_lookup_at_1999_threshold_returns_32() {
1186        let ls = LeapSeconds::builtin();
1187        // Threshold TAI value for 1999-01-01 = 599_184_032_000_000_000
1188        let tai = Time::<Tai>::from_nanos(599_184_032_000_000_000);
1189
1190        assert_eq!(ls.tai_minus_utc_at(tai), 32);
1191    }
1192
1193    #[test]
1194    fn test_lookup_one_ns_before_1999_threshold_returns_31() {
1195        let ls = LeapSeconds::builtin();
1196        let tai = Time::<Tai>::from_nanos(599_184_032_000_000_000 - 1);
1197
1198        assert_eq!(ls.tai_minus_utc_at(tai), 31);
1199    }
1200
1201    #[test]
1202    fn test_gps_utc_gps_roundtrip_at_gps_epoch() {
1203        let ls = LeapSeconds::builtin();
1204        let gps = Time::<Gps>::EPOCH;
1205        let utc = gps_to_utc(gps, &ls).unwrap();
1206        let back = utc_to_gps(utc, &ls).unwrap();
1207
1208        assert_eq!(gps, back);
1209    }
1210
1211    #[test]
1212    fn test_gps_utc_gps_roundtrip_at_2020() {
1213        let ls = LeapSeconds::builtin();
1214        // GPS 2020-01-01 ≈ week 2086
1215        let gps = Time::<Gps>::from_week_tow(
1216            2086,
1217            DurationParts {
1218                seconds: 0,
1219                nanos: 0,
1220            },
1221        )
1222        .unwrap();
1223        let utc = gps_to_utc(gps, &ls).unwrap();
1224        let back = utc_to_gps(utc, &ls).unwrap();
1225
1226        assert_eq!(gps, back);
1227    }
1228
1229    #[test]
1230    fn test_gps_epoch_utc_is_correct_offset_from_utc_epoch() {
1231        let ls = LeapSeconds::builtin();
1232        // At GPS epoch (1980-01-06) TAI-UTC = 19, GPS-UTC = 0
1233        // UTC nanos = GPS nanos + UTC_TO_GPS_EPOCH_NS = 0 +
1234        // 252_892_800_000_000_000
1235        let utc = gps_to_utc(Time::<Gps>::EPOCH, &ls).unwrap();
1236
1237        assert_eq!(utc.as_nanos(), 252_892_800_000_000_000);
1238    }
1239
1240    // Checking GPS-UTC = 18 at 2017-01-01 00:00:00 UTC.
1241    //
1242    // GPS at 2017-01-01 (unix=1483228800):
1243    //   GPS_s = (1483228800 - 315964800) + (37-19) = 1167264000 + 18 = 1167264018
1244    // UTC nanos from UTC_epoch = 16437 days * 86400 * 1e9 =
1245    // 1_420_156_800_000_000_000
1246    #[test]
1247    fn test_gps_minus_utc_is_18s_at_2017_01_01() {
1248        let ls = LeapSeconds::builtin();
1249        // GPS seconds for 2017-01-01 00:00:00 UTC
1250        // = (unix - GPS_EPOCH_UNIX) + (TAI-UTC - 19) = (1483228800 - 315964800) + 18
1251        let gps_s: u64 = 1_167_264_000 + 18;
1252        let gps = Time::<Gps>::from_seconds(gps_s);
1253        let utc = gps_to_utc(gps, &ls).unwrap();
1254
1255        // UTC nanos for 2017-01-01 = 16437 days * 86400 * 1e9
1256        let expected_utc_ns: u64 = 16_437 * 86_400 * 1_000_000_000;
1257
1258        assert_eq!(utc.as_nanos(), expected_utc_ns);
1259    }
1260
1261    // Check GPS-UTC = 13 on 1999-01-01 00:00:00 UTC.
1262    #[test]
1263    fn test_gps_minus_utc_is_13s_at_1999_01_01() {
1264        let ls = LeapSeconds::builtin();
1265        // GPS_s = (915148800 - 315964800) + (32 - 19) = 599184000 + 13 = 599184013
1266        let gps = Time::<Gps>::from_seconds(599_184_013);
1267        let utc = gps_to_utc(gps, &ls).unwrap();
1268
1269        // UTC from UTC epoch to 1999-01-01:
1270        // days_from_unix(1999-01-01) - days_from_unix(1972-01-01)
1271        // = 10592 - 730 = 9862 days (verified below)
1272        // UTC_s = 9862 * 86400 = 851_948_800
1273        let expected_utc_s: u64 = 9_862 * 86_400;
1274
1275        assert_eq!(utc.as_seconds(), expected_utc_s);
1276    }
1277
1278    // 1998-12-31 → 1999-01-01: TAI-UTC changes 31 → 32, GPS-UTC 12 → 13.
1279    //
1280    // GPS jumps from ...011 to ...013 (there is no ...012 in real UTC time).
1281    #[test]
1282    fn test_leap_second_transition_1999_gps_jumps_by_2s() {
1283        let ls = LeapSeconds::builtin();
1284
1285        // 1 second before transition: 1998-12-31 23:59:59 UTC
1286        // unix = 915148799, TAI-UTC = 31 (old value)
1287        // GPS_s = (915148799 - 315964800) + 12 = 599183999 + 12 = 599184011
1288        let gps_before = Time::<Gps>::from_seconds(599_184_011);
1289
1290        // Immediately after: 1999-01-01 00:00:00 UTC
1291        // unix = 915148800, TAI-UTC = 32 (new value)
1292        // GPS_s = (915148800 - 315964800) + 13 = 599184000 + 13 = 599184013
1293        let gps_after = Time::<Gps>::from_seconds(599_184_013);
1294
1295        // Both should convert correctly
1296        let utc_before = gps_to_utc(gps_before, &ls).unwrap();
1297        let utc_after = gps_to_utc(gps_after, &ls).unwrap();
1298
1299        // UTC-after - UTC-before = 1 second (leap second insertion adjusts the scale)
1300        let diff = (utc_after - utc_before).as_seconds();
1301
1302        assert_eq!(diff, 1, "GPS jumped 2s but UTC advanced 1s (leap second)");
1303    }
1304
1305    // 2016-12-31 → 2017-01-01: TAI-UTC 36 → 37, GPS-UTC 17 → 18.
1306    #[test]
1307    fn test_leap_second_transition_2017_gps_jumps_by_2s() {
1308        let ls = LeapSeconds::builtin();
1309        // 1 second before: unix = 1483228799,
1310        // GPS_s = (1483228799 - 315964800) + 17
1311        let gps_before = Time::<Gps>::from_seconds(1_167_263_999 + 17);
1312        // Immediately after: unix = 1483228800,
1313        // GPS_s = (1483228800 - 315964800) + 18
1314        let gps_after = Time::<Gps>::from_seconds(1_167_264_000 + 18);
1315        let utc_before = gps_to_utc(gps_before, &ls).unwrap();
1316        let utc_after = gps_to_utc(gps_after, &ls).unwrap();
1317        let diff = (utc_after - utc_before).as_seconds();
1318
1319        assert_eq!(diff, 1, "GPS jumped 2s but UTC advanced 1s");
1320    }
1321
1322    #[test]
1323    fn test_glonass_epoch_to_utc_gives_correct_nanos() {
1324        // GLONASS epoch = 1996-01-01 00:00:00 UTC(SU)
1325        // which corresponds to 1995-12-31 21:00:00 UTC
1326        //
1327        // UTC offset from UTC epoch:
1328        // (days to 1995-12-31) * 86400 + 21h * 3600 = ...
1329        // Verified via GLONASS_FROM_UTC_EPOCH_NS constant
1330        let utc = glonass_to_utc(Time::<Glonass>::EPOCH).unwrap();
1331
1332        assert_eq!(utc.as_nanos(), GLONASS_FROM_UTC_EPOCH_NS as u64);
1333    }
1334
1335    #[test]
1336    fn test_utc_to_glonass_epoch_gives_zero() {
1337        let utc = Time::<Utc>::from_nanos(GLONASS_FROM_UTC_EPOCH_NS as u64);
1338        let glo = utc_to_glonass(utc).unwrap();
1339
1340        assert_eq!(glo, Time::<Glonass>::EPOCH);
1341    }
1342
1343    #[test]
1344    fn test_glonass_utc_glonass_roundtrip() {
1345        let glo = Time::<Glonass>::from_day_tod(
1346            10_000,
1347            DurationParts {
1348                seconds: 43_200,
1349                nanos: 0,
1350            },
1351        )
1352        .unwrap();
1353        let utc = glonass_to_utc(glo).unwrap();
1354        let back = utc_to_glonass(utc).unwrap();
1355
1356        assert_eq!(glo, back);
1357    }
1358
1359    #[test]
1360    fn test_utc_before_glonass_epoch_returns_error() {
1361        // UTC epoch (1972-01-01) is earlier than GLONASS epoch (1996),
1362        // so conversion results in underflow/overflow
1363        let utc = Time::<Utc>::EPOCH;
1364
1365        assert!(matches!(utc_to_glonass(utc), Err(GnssTimeError::Overflow)));
1366    }
1367
1368    #[test]
1369    fn test_glonass_offset_is_exactly_3_hours_less_than_day_boundary() {
1370        // Offset = 8766 days * 86400 - 3*3600 (exactly 3 hours before midnight
1371        // 1996-01-01 UTC)
1372        let three_hours_ns: i64 = 3 * 3_600 * 1_000_000_000;
1373        let days_ns: i64 = 8766 * 86_400 * 1_000_000_000;
1374
1375        assert_eq!(GLONASS_FROM_UTC_EPOCH_NS, days_ns - three_hours_ns);
1376    }
1377
1378    #[test]
1379    fn test_gps_to_glonass_to_gps_roundtrip() {
1380        let ls = LeapSeconds::builtin();
1381        // GPS time in 2020 (after the last leap second in 2017)
1382        let gps = Time::<Gps>::from_week_tow(
1383            2100,
1384            DurationParts {
1385                seconds: 86400,
1386                nanos: 0,
1387            },
1388        )
1389        .unwrap();
1390        let glo = gps_to_glonass(gps, &ls).unwrap();
1391        let back = glonass_to_gps(glo, &ls).unwrap();
1392
1393        assert_eq!(gps, back);
1394    }
1395
1396    #[test]
1397    fn test_custom_provider_works() {
1398        struct Always37;
1399
1400        impl LeapSecondsProvider for Always37 {
1401            fn tai_minus_utc_at(
1402                &self,
1403                _: Time<Tai>,
1404            ) -> i32 {
1405                37
1406            }
1407        }
1408
1409        let gps = Time::<Gps>::from_seconds(1_000_000_000);
1410        let utc = gps_to_utc(gps, &Always37).unwrap();
1411        let back = utc_to_gps(utc, &Always37).unwrap();
1412
1413        assert_eq!(gps, back);
1414    }
1415
1416    #[test]
1417    fn test_empty_table_returns_fallback_19() {
1418        static EMPTY: [LeapEntry; 0] = [];
1419
1420        let ls = LeapSeconds::from_table(&EMPTY);
1421
1422        assert_eq!(
1423            ls.tai_minus_utc_at(Time::<Tai>::from_seconds(1_000_000)),
1424            19
1425        );
1426    }
1427
1428    #[test]
1429    fn test_runtime_from_builtin_has_19_entries() {
1430        assert_eq!(RuntimeLeapSeconds::from_builtin().len(), 19);
1431    }
1432
1433    #[test]
1434    fn test_runtime_from_builtin_current_is_37() {
1435        assert_eq!(
1436            RuntimeLeapSeconds::from_builtin().current_tai_minus_utc(),
1437            37
1438        );
1439    }
1440
1441    #[test]
1442    fn test_runtime_try_extend_valid() {
1443        let mut rt = RuntimeLeapSeconds::from_builtin();
1444        rt.try_extend(LeapEntry::new(9_999_999_999_000_000_000, 38))
1445            .unwrap();
1446
1447        assert_eq!(rt.len(), 20);
1448        assert_eq!(rt.current_tai_minus_utc(), 38);
1449    }
1450
1451    #[test]
1452    fn test_runtime_try_extend_last_update_updated() {
1453        let mut rt = RuntimeLeapSeconds::from_builtin();
1454        rt.try_extend(LeapEntry::new(9_999_999_999_000_000_000, 38))
1455            .unwrap();
1456
1457        let last = rt.last_update().unwrap();
1458        assert_eq!(last.as_nanos(), 9_999_999_999_000_000_000);
1459    }
1460
1461    #[test]
1462    fn test_runtime_try_extend_not_ascending_error() {
1463        let mut rt = RuntimeLeapSeconds::from_builtin();
1464        // Same threshold as last builtin entry — not strictly ascending.
1465        let err = rt
1466            .try_extend(LeapEntry::new(1_167_264_037_000_000_000, 38))
1467            .unwrap_err();
1468
1469        assert_eq!(err, LeapExtendError::NotStrictlyAscending);
1470    }
1471
1472    #[test]
1473    fn test_runtime_try_extend_non_unit_increment_error() {
1474        let mut rt = RuntimeLeapSeconds::from_builtin();
1475        // Skips to 39 instead of 38.
1476        let err = rt
1477            .try_extend(LeapEntry::new(9_999_999_999_000_000_000, 39))
1478            .unwrap_err();
1479
1480        assert_eq!(err, LeapExtendError::NonUnitIncrement);
1481    }
1482
1483    #[test]
1484    fn test_runtime_from_slice_too_large_returns_buffer_full() {
1485        let big: std::vec::Vec<LeapEntry> = (0..=RUNTIME_CAPACITY)
1486            .map(|i| {
1487                let i32_i = i32::try_from(i).expect("i fits in i32");
1488                LeapEntry::new(i as u64 * 1_000_000_000, 19 + i32_i)
1489            })
1490            .collect();
1491        let err = RuntimeLeapSeconds::from_slice(&big).unwrap_err();
1492
1493        assert_eq!(err, LeapExtendError::BufferFull);
1494    }
1495
1496    #[test]
1497    fn test_runtime_provider_matches_static_at_all_thresholds() {
1498        let rt = RuntimeLeapSeconds::from_builtin();
1499        let ls = LeapSeconds::builtin();
1500
1501        let test_nanos: &[u64] = &[
1502            0,
1503            46_828_820_000_000_000,
1504            599_184_032_000_000_000,
1505            1_167_264_037_000_000_000,
1506            u64::MAX,
1507        ];
1508
1509        for &nanos in test_nanos {
1510            let tai = Time::<Tai>::from_nanos(nanos);
1511            assert_eq!(
1512                rt.tai_minus_utc_at(tai),
1513                ls.tai_minus_utc_at(tai),
1514                "mismatch at tai_nanos={nanos}",
1515            );
1516        }
1517    }
1518
1519    #[test]
1520    fn test_runtime_empty_last_update_is_none() {
1521        assert!(RuntimeLeapSeconds::new().last_update().is_none());
1522    }
1523
1524    #[test]
1525    fn test_runtime_single_entry_last_update_is_none() {
1526        let mut rt = RuntimeLeapSeconds::new();
1527        rt.try_extend(LeapEntry::new(0, 19)).unwrap();
1528        assert!(rt.last_update().is_none());
1529    }
1530
1531    #[test]
1532    fn test_gps_utc_gps_roundtrip_with_runtime_table() {
1533        let rt = RuntimeLeapSeconds::from_builtin();
1534        let gps = Time::<Gps>::from_week_tow(
1535            2086,
1536            DurationParts {
1537                seconds: 0,
1538                nanos: 0,
1539            },
1540        )
1541        .unwrap();
1542        let utc = gps_to_utc(gps, &rt).unwrap();
1543        let back = utc_to_gps(utc, &rt).unwrap();
1544
1545        assert_eq!(gps, back);
1546    }
1547
1548    #[test]
1549    fn test_gps_utc_roundtrip_extended_table() {
1550        let mut rt = RuntimeLeapSeconds::from_builtin();
1551        rt.try_extend(LeapEntry::new(9_999_999_999_000_000_000, 38))
1552            .unwrap();
1553
1554        let gps = Time::<Gps>::from_week_tow(
1555            2086,
1556            DurationParts {
1557                seconds: 0,
1558                nanos: 0,
1559            },
1560        )
1561        .unwrap();
1562        let utc = gps_to_utc(gps, &rt).unwrap();
1563        let back = utc_to_gps(utc, &rt).unwrap();
1564
1565        assert_eq!(gps, back);
1566    }
1567
1568    #[test]
1569    fn test_gps_epoch_utc_is_correct() {
1570        let ls = LeapSeconds::builtin();
1571        let utc = gps_to_utc(Time::<Gps>::EPOCH, &ls).unwrap();
1572
1573        assert_eq!(utc.as_nanos(), 252_892_800_000_000_000);
1574    }
1575
1576    #[test]
1577    fn test_custom_provider_roundtrip() {
1578        struct Always37;
1579        impl LeapSecondsProvider for Always37 {
1580            fn tai_minus_utc_at(
1581                &self,
1582                _: Time<Tai>,
1583            ) -> i32 {
1584                37
1585            }
1586        }
1587
1588        let gps = Time::<Gps>::from_seconds(1_000_000_000);
1589        let utc = gps_to_utc(gps, &Always37).unwrap();
1590        let back = utc_to_gps(utc, &Always37).unwrap();
1591
1592        assert_eq!(gps, back);
1593    }
1594}