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;