Skip to main content

leap_sec/
table.rs

1//! The leap-second table and all step-based conversions.
2
3use crate::convert::{gpst_to_tai, tai_to_gpst};
4use crate::error::Error;
5use crate::types::{GpstNanos, GpstSeconds, TaiNanos, TaiSeconds, UtcUnixNanos, UtcUnixSeconds};
6
7/// A single entry in the leap-second table.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9#[allow(unreachable_pub)]
10pub struct LeapEntry {
11    /// UTC Unix timestamp at which this offset takes effect.
12    pub utc_unix: i64,
13    /// Cumulative TAI−UTC offset (in seconds) from this point forward.
14    pub tai_minus_utc: i32,
15}
16
17/// Storage for the leap-second entries.
18#[derive(Debug, Clone)]
19enum Storage {
20    /// A static (compile-time) slice — used by `known()`.
21    Static(&'static [LeapEntry]),
22    /// A heap-allocated vector — used by the builder.
23    #[cfg(feature = "std")]
24    Owned(Vec<LeapEntry>),
25}
26
27impl Storage {
28    fn entries(&self) -> &[LeapEntry] {
29        match self {
30            Self::Static(s) => s,
31            #[cfg(feature = "std")]
32            Self::Owned(v) => v,
33        }
34    }
35}
36
37/// An immutable leap-second schedule.
38///
39/// Use [`LeapSeconds::known()`] to get the built-in table with all historical
40/// leap seconds through 2017-01-01. Works in `no_std`, no allocation, deterministic.
41///
42/// For custom tables (testing, simulation), use
43/// [`LeapSeconds::builder()`](Self::builder) (requires `std` feature).
44///
45/// `LeapSeconds` is `Send + Sync` — safe to share across threads.
46/// The `known()` table returns `&'static LeapSeconds`, so it can be used
47/// from any thread without cloning.
48#[derive(Debug, Clone)]
49pub struct LeapSeconds {
50    storage: Storage,
51    expires_at: Option<i64>,
52}
53
54// ---------------------------------------------------------------------------
55// The 28 historical leap-second entries
56// ---------------------------------------------------------------------------
57
58const KNOWN_TABLE: [LeapEntry; 28] = [
59    LeapEntry {
60        utc_unix: 63_072_000,
61        tai_minus_utc: 10,
62    }, // 1972-01-01
63    LeapEntry {
64        utc_unix: 78_796_800,
65        tai_minus_utc: 11,
66    }, // 1972-07-01
67    LeapEntry {
68        utc_unix: 94_694_400,
69        tai_minus_utc: 12,
70    }, // 1973-01-01
71    LeapEntry {
72        utc_unix: 126_230_400,
73        tai_minus_utc: 13,
74    }, // 1974-01-01
75    LeapEntry {
76        utc_unix: 157_766_400,
77        tai_minus_utc: 14,
78    }, // 1975-01-01
79    LeapEntry {
80        utc_unix: 189_302_400,
81        tai_minus_utc: 15,
82    }, // 1976-01-01
83    LeapEntry {
84        utc_unix: 220_924_800,
85        tai_minus_utc: 16,
86    }, // 1977-01-01
87    LeapEntry {
88        utc_unix: 252_460_800,
89        tai_minus_utc: 17,
90    }, // 1978-01-01
91    LeapEntry {
92        utc_unix: 283_996_800,
93        tai_minus_utc: 18,
94    }, // 1979-01-01
95    LeapEntry {
96        utc_unix: 315_532_800,
97        tai_minus_utc: 19,
98    }, // 1980-01-01
99    LeapEntry {
100        utc_unix: 362_793_600,
101        tai_minus_utc: 20,
102    }, // 1981-07-01
103    LeapEntry {
104        utc_unix: 394_329_600,
105        tai_minus_utc: 21,
106    }, // 1982-07-01
107    LeapEntry {
108        utc_unix: 425_865_600,
109        tai_minus_utc: 22,
110    }, // 1983-07-01
111    LeapEntry {
112        utc_unix: 489_024_000,
113        tai_minus_utc: 23,
114    }, // 1985-07-01
115    LeapEntry {
116        utc_unix: 567_993_600,
117        tai_minus_utc: 24,
118    }, // 1988-01-01
119    LeapEntry {
120        utc_unix: 631_152_000,
121        tai_minus_utc: 25,
122    }, // 1990-01-01
123    LeapEntry {
124        utc_unix: 662_688_000,
125        tai_minus_utc: 26,
126    }, // 1991-01-01
127    LeapEntry {
128        utc_unix: 709_948_800,
129        tai_minus_utc: 27,
130    }, // 1992-07-01
131    LeapEntry {
132        utc_unix: 741_484_800,
133        tai_minus_utc: 28,
134    }, // 1993-07-01
135    LeapEntry {
136        utc_unix: 773_020_800,
137        tai_minus_utc: 29,
138    }, // 1994-07-01
139    LeapEntry {
140        utc_unix: 820_454_400,
141        tai_minus_utc: 30,
142    }, // 1996-01-01
143    LeapEntry {
144        utc_unix: 867_715_200,
145        tai_minus_utc: 31,
146    }, // 1997-07-01
147    LeapEntry {
148        utc_unix: 915_148_800,
149        tai_minus_utc: 32,
150    }, // 1999-01-01
151    LeapEntry {
152        utc_unix: 1_136_073_600,
153        tai_minus_utc: 33,
154    }, // 2006-01-01
155    LeapEntry {
156        utc_unix: 1_230_768_000,
157        tai_minus_utc: 34,
158    }, // 2009-01-01
159    LeapEntry {
160        utc_unix: 1_341_100_800,
161        tai_minus_utc: 35,
162    }, // 2012-07-01
163    LeapEntry {
164        utc_unix: 1_435_708_800,
165        tai_minus_utc: 36,
166    }, // 2015-07-01
167    // The last leap second was inserted on 2016-12-31 at 23:59:60 UTC.
168    // The new offset (37) takes effect at 2017-01-01 00:00:00 UTC.
169    LeapEntry {
170        utc_unix: 1_483_228_800,
171        tai_minus_utc: 37,
172    }, // 2017-01-01
173];
174
175static KNOWN: LeapSeconds = LeapSeconds {
176    storage: Storage::Static(&KNOWN_TABLE),
177    expires_at: None,
178};
179
180const NANOS_PER_SECOND: i128 = 1_000_000_000;
181
182impl LeapSeconds {
183    /// Returns the built-in table with all historical leap seconds through 2017-01-01.
184    ///
185    /// Works in `no_std`, requires no allocation, and is fully deterministic.
186    /// Timestamps after 2017-01-01 use the last known offset (37) because
187    /// no new leap seconds have been inserted since then.
188    ///
189    /// For custom tables, see [`LeapSeconds::builder()`](Self::builder).
190    ///
191    /// # Example
192    ///
193    /// ```
194    /// use leap_sec::prelude::*;
195    ///
196    /// let leaps = LeapSeconds::known();
197    /// let tai = leaps.utc_to_tai(UtcUnixSeconds(1_700_000_000)).unwrap();
198    /// assert_eq!(tai, TaiSeconds(1_700_000_037));
199    /// ```
200    pub fn known() -> &'static Self {
201        &KNOWN
202    }
203
204    /// Create a `LeapSeconds` from owned entries (used by the builder).
205    #[cfg(feature = "std")]
206    pub(crate) const fn from_owned(entries: Vec<LeapEntry>, expires_at: Option<i64>) -> Self {
207        Self {
208            storage: Storage::Owned(entries),
209            expires_at,
210        }
211    }
212
213    fn entries(&self) -> &[LeapEntry] {
214        self.storage.entries()
215    }
216
217    // -----------------------------------------------------------------------
218    // UTC → TAI
219    // -----------------------------------------------------------------------
220
221    /// Convert a UTC Unix timestamp to TAI seconds.
222    ///
223    /// # Errors
224    ///
225    /// Returns [`Error::OutOfRange`] if `utc` is before the first entry in the table.
226    ///
227    /// # Example
228    ///
229    /// ```
230    /// use leap_sec::prelude::*;
231    ///
232    /// let leaps = LeapSeconds::known();
233    /// let tai = leaps.utc_to_tai(UtcUnixSeconds(1_700_000_000)).unwrap();
234    /// assert_eq!(tai, TaiSeconds(1_700_000_037));
235    /// ```
236    pub fn utc_to_tai(&self, utc: UtcUnixSeconds) -> Result<TaiSeconds, Error> {
237        let offset = self.lookup_utc(utc.0)?;
238        Ok(TaiSeconds(utc.0 + i64::from(offset)))
239    }
240
241    /// Convert UTC Unix nanoseconds to TAI nanoseconds.
242    ///
243    /// The offset is applied in whole seconds; the sub-second fraction is preserved exactly.
244    ///
245    /// # Errors
246    ///
247    /// Returns [`Error::OutOfRange`] if the timestamp is before 1972-01-01.
248    ///
249    /// # Example
250    ///
251    /// ```
252    /// use leap_sec::prelude::*;
253    ///
254    /// let leaps = LeapSeconds::known();
255    /// let tai = leaps.utc_to_tai_nanos(UtcUnixNanos(1_700_000_000_500_000_000)).unwrap();
256    /// assert_eq!(tai, TaiNanos(1_700_000_037_500_000_000));
257    /// ```
258    pub fn utc_to_tai_nanos(&self, utc: UtcUnixNanos) -> Result<TaiNanos, Error> {
259        let sec = utc.to_seconds_floor();
260        let offset = self.lookup_utc(sec.0)?;
261        Ok(TaiNanos(utc.0 + i128::from(offset) * NANOS_PER_SECOND))
262    }
263
264    // -----------------------------------------------------------------------
265    // TAI → UTC
266    // -----------------------------------------------------------------------
267
268    /// Convert TAI seconds to a UTC Unix timestamp.
269    ///
270    /// # Errors
271    ///
272    /// Returns [`Error::OutOfRange`] if `tai` is before the first entry in the table.
273    ///
274    /// # Example
275    ///
276    /// ```
277    /// use leap_sec::prelude::*;
278    ///
279    /// let leaps = LeapSeconds::known();
280    /// let utc = leaps.tai_to_utc(TaiSeconds(1_700_000_037)).unwrap();
281    /// assert_eq!(utc, UtcUnixSeconds(1_700_000_000));
282    /// ```
283    pub fn tai_to_utc(&self, tai: TaiSeconds) -> Result<UtcUnixSeconds, Error> {
284        let offset = self.lookup_tai(tai.0)?;
285        Ok(UtcUnixSeconds(tai.0 - i64::from(offset)))
286    }
287
288    /// Convert TAI nanoseconds to UTC Unix nanoseconds.
289    ///
290    /// # Errors
291    ///
292    /// Returns [`Error::OutOfRange`] if the timestamp is before the table's TAI range.
293    ///
294    /// # Example
295    ///
296    /// ```
297    /// use leap_sec::prelude::*;
298    ///
299    /// let leaps = LeapSeconds::known();
300    /// let utc = leaps.tai_to_utc_nanos(TaiNanos(1_700_000_037_500_000_000)).unwrap();
301    /// assert_eq!(utc, UtcUnixNanos(1_700_000_000_500_000_000));
302    /// ```
303    pub fn tai_to_utc_nanos(&self, tai: TaiNanos) -> Result<UtcUnixNanos, Error> {
304        let sec = tai.to_seconds_floor();
305        let offset = self.lookup_tai(sec.0)?;
306        Ok(UtcUnixNanos(tai.0 - i128::from(offset) * NANOS_PER_SECOND))
307    }
308
309    // -----------------------------------------------------------------------
310    // UTC → GPST (composed via TAI)
311    // -----------------------------------------------------------------------
312
313    /// Convert a UTC Unix timestamp to GPS Time.
314    ///
315    /// Composes `utc_to_tai` then `tai_to_gpst` (TAI − 19).
316    ///
317    /// # Errors
318    ///
319    /// Returns [`Error::OutOfRange`] if the timestamp is before 1972-01-01.
320    ///
321    /// # Example
322    ///
323    /// ```
324    /// use leap_sec::prelude::*;
325    ///
326    /// let leaps = LeapSeconds::known();
327    /// let gpst = leaps.utc_to_gpst(UtcUnixSeconds(1_700_000_000)).unwrap();
328    /// assert_eq!(gpst, GpstSeconds(1_700_000_018));
329    /// ```
330    pub fn utc_to_gpst(&self, utc: UtcUnixSeconds) -> Result<GpstSeconds, Error> {
331        self.utc_to_tai(utc).map(tai_to_gpst)
332    }
333
334    /// Convert UTC Unix nanoseconds to GPST nanoseconds.
335    ///
336    /// # Errors
337    ///
338    /// Returns [`Error::OutOfRange`] if the timestamp is before 1972-01-01.
339    ///
340    /// # Example
341    ///
342    /// ```
343    /// use leap_sec::prelude::*;
344    ///
345    /// let leaps = LeapSeconds::known();
346    /// let gpst = leaps.utc_to_gpst_nanos(UtcUnixNanos(1_700_000_000_500_000_000)).unwrap();
347    /// assert_eq!(gpst, GpstNanos(1_700_000_018_500_000_000));
348    /// ```
349    pub fn utc_to_gpst_nanos(&self, utc: UtcUnixNanos) -> Result<GpstNanos, Error> {
350        self.utc_to_tai_nanos(utc)
351            .map(crate::convert::tai_to_gpst_nanos)
352    }
353
354    // -----------------------------------------------------------------------
355    // GPST → UTC (composed via TAI)
356    // -----------------------------------------------------------------------
357
358    /// Convert GPS Time to a UTC Unix timestamp.
359    ///
360    /// Composes `gpst_to_tai` (GPST + 19) then `tai_to_utc`.
361    ///
362    /// # Errors
363    ///
364    /// Returns [`Error::OutOfRange`] if the resulting TAI is before the table's range.
365    ///
366    /// # Example
367    ///
368    /// ```
369    /// use leap_sec::prelude::*;
370    ///
371    /// let leaps = LeapSeconds::known();
372    /// let utc = leaps.gpst_to_utc(GpstSeconds(1_700_000_018)).unwrap();
373    /// assert_eq!(utc, UtcUnixSeconds(1_700_000_000));
374    /// ```
375    pub fn gpst_to_utc(&self, gpst: GpstSeconds) -> Result<UtcUnixSeconds, Error> {
376        self.tai_to_utc(gpst_to_tai(gpst))
377    }
378
379    /// Convert GPST nanoseconds to UTC Unix nanoseconds.
380    ///
381    /// # Errors
382    ///
383    /// Returns [`Error::OutOfRange`] if the resulting TAI is before the table's range.
384    ///
385    /// # Example
386    ///
387    /// ```
388    /// use leap_sec::prelude::*;
389    ///
390    /// let leaps = LeapSeconds::known();
391    /// let utc = leaps.gpst_to_utc_nanos(GpstNanos(1_700_000_018_500_000_000)).unwrap();
392    /// assert_eq!(utc, UtcUnixNanos(1_700_000_000_500_000_000));
393    /// ```
394    pub fn gpst_to_utc_nanos(&self, gpst: GpstNanos) -> Result<UtcUnixNanos, Error> {
395        self.tai_to_utc_nanos(crate::convert::gpst_to_tai_nanos(gpst))
396    }
397
398    // -----------------------------------------------------------------------
399    // Offset queries
400    // -----------------------------------------------------------------------
401
402    /// Get the TAI−UTC offset at a given UTC instant.
403    ///
404    /// # Errors
405    ///
406    /// Returns [`Error::OutOfRange`] if the timestamp is before 1972-01-01.
407    ///
408    /// # Example
409    ///
410    /// ```
411    /// use leap_sec::prelude::*;
412    ///
413    /// let leaps = LeapSeconds::known();
414    /// assert_eq!(leaps.tai_utc_offset(UtcUnixSeconds(1_700_000_000)).unwrap(), 37);
415    /// assert_eq!(leaps.tai_utc_offset(UtcUnixSeconds(63_072_000)).unwrap(), 10);
416    /// ```
417    pub fn tai_utc_offset(&self, utc: UtcUnixSeconds) -> Result<i32, Error> {
418        self.lookup_utc(utc.0)
419    }
420
421    /// Get the TAI−UTC offset at a given TAI instant.
422    ///
423    /// # Errors
424    ///
425    /// Returns [`Error::OutOfRange`] if the TAI timestamp is before the table's range.
426    ///
427    /// # Example
428    ///
429    /// ```
430    /// use leap_sec::prelude::*;
431    ///
432    /// let leaps = LeapSeconds::known();
433    /// assert_eq!(leaps.tai_utc_offset_at_tai(TaiSeconds(1_700_000_037)).unwrap(), 37);
434    /// ```
435    pub fn tai_utc_offset_at_tai(&self, tai: TaiSeconds) -> Result<i32, Error> {
436        self.lookup_tai(tai.0)
437    }
438
439    // -----------------------------------------------------------------------
440    // Leap-second detection
441    // -----------------------------------------------------------------------
442
443    /// Returns `true` if `utc` falls exactly on a positive leap-second insertion.
444    ///
445    /// At such an instant the POSIX timestamp is ambiguous: the UTC wall clock
446    /// reads `23:59:60` but POSIX folds it to the same value as `00:00:00`.
447    ///
448    /// Returns `false` for:
449    /// - The initial 1972-01-01 epoch (offset = 10) — not an insertion, it is
450    ///   the starting offset of the modern UTC system.
451    /// - Negative leap seconds (where the offset *decreases*) — there is no
452    ///   extra second, so no ambiguity. No negative leap second has ever been
453    ///   applied, but the table format supports them.
454    ///
455    /// # Example
456    ///
457    /// ```
458    /// use leap_sec::prelude::*;
459    ///
460    /// let leaps = LeapSeconds::known();
461    ///
462    /// // 2017-01-01 — the last leap second insertion (2016-12-31 23:59:60)
463    /// assert!(leaps.is_during_leap_second(UtcUnixSeconds(1_483_228_800)));
464    ///
465    /// // A normal timestamp — not during a leap second
466    /// assert!(!leaps.is_during_leap_second(UtcUnixSeconds(1_700_000_000)));
467    /// ```
468    pub fn is_during_leap_second(&self, utc: UtcUnixSeconds) -> bool {
469        let entries = self.entries();
470
471        // Binary search for the timestamp.
472        let Ok(idx) = entries.binary_search_by_key(&utc.0, |e| e.utc_unix) else {
473            return false; // Not an exact entry boundary.
474        };
475
476        // Skip the first entry (the initial epoch, not an insertion).
477        // Check that the offset *increased* (positive leap second only).
478        idx > 0 && entries[idx].tai_minus_utc > entries[idx - 1].tai_minus_utc
479    }
480
481    // -----------------------------------------------------------------------
482    // Table inspection
483    // -----------------------------------------------------------------------
484
485    /// Returns the valid range of this table as `(first_entry, last_entry)`.
486    ///
487    /// # Example
488    ///
489    /// ```
490    /// use leap_sec::prelude::*;
491    ///
492    /// let leaps = LeapSeconds::known();
493    /// let (start, end) = leaps.valid_range();
494    /// assert_eq!(start, UtcUnixSeconds(63_072_000));    // 1972-01-01
495    /// assert_eq!(end, UtcUnixSeconds(1_483_228_800));   // 2017-01-01
496    /// ```
497    pub fn valid_range(&self) -> (UtcUnixSeconds, UtcUnixSeconds) {
498        let entries = self.entries();
499        (
500            UtcUnixSeconds(entries[0].utc_unix),
501            UtcUnixSeconds(entries[entries.len() - 1].utc_unix),
502        )
503    }
504
505    /// Returns `false` for the built-in `known()` table.
506    ///
507    /// For tables with an expiration timestamp, this would return `true`
508    /// if the table has expired. In v0.1 there is no clock access — this
509    /// simply checks whether an expiration is set.
510    pub const fn is_expired(&self) -> bool {
511        false
512    }
513
514    /// Returns the expiration timestamp, if one was set.
515    ///
516    /// The built-in `known()` table returns `None` (it has no expiration concept).
517    /// Tables constructed via the builder may have an expiration set.
518    ///
519    /// # Example
520    ///
521    /// ```
522    /// use leap_sec::prelude::*;
523    ///
524    /// assert_eq!(LeapSeconds::known().expires_at(), None);
525    /// ```
526    pub fn expires_at(&self) -> Option<UtcUnixSeconds> {
527        self.expires_at.map(UtcUnixSeconds)
528    }
529
530    /// Returns the most recent leap-second entry: `(effective_utc, tai_minus_utc)`.
531    ///
532    /// For the built-in table this is `(2017-01-01, 37)`.
533    ///
534    /// # Example
535    ///
536    /// ```
537    /// use leap_sec::prelude::*;
538    ///
539    /// let leaps = LeapSeconds::known();
540    /// let (date, offset) = leaps.latest_entry();
541    /// assert_eq!(date, UtcUnixSeconds(1_483_228_800));
542    /// assert_eq!(offset, 37);
543    /// ```
544    pub fn latest_entry(&self) -> (UtcUnixSeconds, i32) {
545        let entries = self.entries();
546        let last = entries[entries.len() - 1];
547        (UtcUnixSeconds(last.utc_unix), last.tai_minus_utc)
548    }
549
550    // -----------------------------------------------------------------------
551    // Internal lookup helpers
552    // -----------------------------------------------------------------------
553
554    /// Binary search for the TAI−UTC offset at a UTC Unix timestamp.
555    fn lookup_utc(&self, utc: i64) -> Result<i32, Error> {
556        let entries = self.entries();
557
558        if utc < entries[0].utc_unix {
559            return Err(Error::OutOfRange {
560                requested: utc,
561                valid_start: entries[0].utc_unix,
562                valid_end: entries[entries.len() - 1].utc_unix,
563            });
564        }
565
566        // Binary search: find the last entry whose utc_unix <= utc.
567        let idx = match entries.binary_search_by_key(&utc, |e| e.utc_unix) {
568            Ok(i) => i,
569            Err(i) => i - 1, // i > 0 because we checked utc >= entries[0] above
570        };
571
572        Ok(entries[idx].tai_minus_utc)
573    }
574
575    /// Binary search for the TAI−UTC offset at a TAI timestamp.
576    ///
577    /// Each entry's TAI boundary is `utc_unix + tai_minus_utc`.
578    fn lookup_tai(&self, tai: i64) -> Result<i32, Error> {
579        let entries = self.entries();
580
581        let first_tai = entries[0].utc_unix + i64::from(entries[0].tai_minus_utc);
582        if tai < first_tai {
583            return Err(Error::OutOfRange {
584                requested: tai,
585                valid_start: first_tai,
586                valid_end: entries[entries.len() - 1].utc_unix
587                    + i64::from(entries[entries.len() - 1].tai_minus_utc),
588            });
589        }
590
591        // Binary search in TAI space: entry boundary is utc_unix + tai_minus_utc.
592        let mut lo = 0;
593        let mut hi = entries.len();
594        while lo < hi {
595            let mid = lo + (hi - lo) / 2;
596            let tai_boundary = entries[mid].utc_unix + i64::from(entries[mid].tai_minus_utc);
597            if tai_boundary <= tai {
598                lo = mid + 1;
599            } else {
600                hi = mid;
601            }
602        }
603
604        // lo is the first entry whose TAI boundary > tai, so we want lo - 1.
605        let idx = if lo > 0 { lo - 1 } else { 0 };
606        Ok(entries[idx].tai_minus_utc)
607    }
608}
609
610// ---------------------------------------------------------------------------
611// Builder support (re-export the entry type for builder module)
612// ---------------------------------------------------------------------------
613
614#[cfg(feature = "std")]
615#[allow(unreachable_pub)]
616pub use self::LeapEntry as LeapEntryInner;