Skip to main content

kinavis_kernel/
time.rs

1//! Navigation time with the time scale in the type.
2//!
3//! Navigation uses several scales: UTC (logs, tide tables; steps with leap
4//! seconds), GPS time (receivers; continuous) and TAI (continuous, reference).
5//! They differ by whole seconds — 18 s UTC→GPS since 2017, 185 m at 20 kn — so
6//! mixing them must not compile.
7//!
8//! An [`Instant`] carries its scale as a type parameter, like the frame of a
9//! [`Direction`](crate::Direction). Instants on the same scale compare and
10//! subtract; conversion between scales is an explicit function using the
11//! leap-second table:
12//!
13//! ```compile_fail
14//! use kinavis_kernel::time::{Gps, Instant, Utc};
15//!
16//! let utc: Instant<Utc> = Instant::from_unix_seconds(1_700_000_000);
17//! let gps: Instant<Gps> = Instant::from_unix_seconds(1_700_000_000);
18//! let _ = utc.duration_since(gps); // mismatched types: `Instant<Utc>` is not `Instant<Gps>`
19//! ```
20//!
21//! The leap-second table expires (IERS announces each leap second about six
22//! months ahead), so it is a port, [`LeapSeconds`], not a constant.
23//!
24//! # Representation
25//!
26//! Every scale counts seconds and nanoseconds since `1970-01-01T00:00:00` *on
27//! that scale*. For UTC this is Unix time (a leap second has no distinct
28//! value); GPS and TAI count continuously through leap seconds, which makes
29//! them suitable for intervals. [`Instant::civil`] gives the calendar of the
30//! instant's own scale, e.g. the date a GPS receiver would display.
31
32use core::fmt;
33use core::hash::Hash;
34use core::marker::PhantomData;
35use core::time::Duration;
36
37use crate::error::{KernelError, Result};
38
39mod sealed {
40    pub trait Sealed {}
41}
42
43/// Time scale of an [`Instant`].
44///
45/// Sealed: only the scales below exist.
46pub trait TimeScale:
47    sealed::Sealed + Copy + Clone + fmt::Debug + Eq + Ord + Hash + Default + 'static
48{
49    /// Scale suffix: `"UTC"`, `"GPS"`, `"TAI"`.
50    const NAME: &'static str;
51}
52
53/// Coordinated Universal Time, with leap seconds.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
55#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
56pub struct Utc;
57
58/// GPS time: continuous, 19 s behind TAI.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
60#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
61pub struct Gps;
62
63/// International Atomic Time: continuous reference scale.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
65#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
66pub struct Tai;
67
68impl sealed::Sealed for Utc {}
69impl sealed::Sealed for Gps {}
70impl sealed::Sealed for Tai {}
71
72impl TimeScale for Utc {
73    const NAME: &'static str = "UTC";
74}
75
76impl TimeScale for Gps {
77    const NAME: &'static str = "GPS";
78}
79
80impl TimeScale for Tai {
81    const NAME: &'static str = "TAI";
82}
83
84/// Nanoseconds per second; upper bound of [`Instant::subsec_nanos`].
85const NANOS_PER_SECOND: u32 = 1_000_000_000;
86
87/// Splits nanoseconds below 2 s into a carry second and remainder. Summing two
88/// sub-second parts in `u64` cannot wrap; the remainder fits `u32` by
89/// construction.
90fn split_nanos(total: u64) -> (i64, u32) {
91    let carry = i64::from(total >= u64::from(NANOS_PER_SECOND));
92    let remainder = total % u64::from(NANOS_PER_SECOND);
93    (carry, u32::try_from(remainder).unwrap_or(0))
94}
95
96/// Subtracts sub-second parts with borrow, in a wider type and with each part
97/// reduced below one second first (a no-op for valid parts), so the compiler
98/// can prove nothing wraps.
99fn borrow_nanos(from: u32, subtract: u32) -> (i64, u32) {
100    let (from, subtract) = (
101        u64::from(from) % u64::from(NANOS_PER_SECOND),
102        u64::from(subtract) % u64::from(NANOS_PER_SECOND),
103    );
104    if from >= subtract {
105        (0, u32::try_from(from - subtract).unwrap_or(0))
106    } else {
107        (
108            1,
109            u32::try_from(from + u64::from(NANOS_PER_SECOND) - subtract).unwrap_or(0),
110        )
111    }
112}
113
114/// TAI − GPS, fixed at the GPS epoch.
115pub const TAI_MINUS_GPS: Duration = Duration::from_secs(19);
116
117/// Instant on one time scale, nanosecond resolution.
118///
119/// Comparison and subtraction only within one scale (see the [module
120/// docs](self)). Stored as seconds and nanoseconds since the scale's
121/// `1970-01-01T00:00:00`: range far beyond any calendar, and any `i64` of Unix
122/// nanoseconds fits.
123#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
124pub struct Instant<S: TimeScale> {
125    // Field order matters: derived `Ord` compares seconds first.
126    seconds: i64,
127    /// Always below `NANOS_PER_SECOND`.
128    nanos: u32,
129    scale: PhantomData<S>,
130}
131
132impl<S: TimeScale> Instant<S> {
133    /// `1970-01-01T00:00:00` on this scale.
134    pub const UNIX_EPOCH: Self = Self::from_parts(0, 0);
135
136    /// Instant from already normalised parts.
137    const fn from_parts(seconds: i64, nanos: u32) -> Self {
138        Self {
139            seconds,
140            nanos,
141            scale: PhantomData,
142        }
143    }
144
145    /// Instant from whole seconds and nanoseconds within the second.
146    ///
147    /// # Errors
148    ///
149    /// [`KernelError::OutOfRange`] if `nanos` ≥ 1 s.
150    pub fn new(seconds: i64, nanos: u32) -> Result<Self> {
151        if nanos >= NANOS_PER_SECOND {
152            return Err(KernelError::OutOfRange {
153                parameter: "nanoseconds",
154                value: f64::from(nanos),
155                min: 0.0,
156                max: f64::from(NANOS_PER_SECOND - 1),
157            });
158        }
159        Ok(Self::from_parts(seconds, nanos))
160    }
161
162    /// Instant a whole number of seconds from the epoch.
163    #[must_use]
164    pub const fn from_unix_seconds(seconds: i64) -> Self {
165        Self::from_parts(seconds, 0)
166    }
167
168    /// Instant `nanos` from the epoch (negative before it). Every `i64` is
169    /// representable.
170    #[must_use]
171    pub const fn from_unix_nanos(nanos: i64) -> Self {
172        let seconds = nanos.div_euclid(NANOS_PER_SECOND as i64);
173        // The Euclidean remainder by a positive divisor that fits `u32` is
174        // non-negative and below it.
175        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
176        let nanos = nanos.rem_euclid(NANOS_PER_SECOND as i64) as u32;
177        Self::from_parts(seconds, nanos)
178    }
179
180    /// Whole seconds since the epoch, floored.
181    #[must_use]
182    pub const fn seconds(self) -> i64 {
183        self.seconds
184    }
185
186    /// Nanoseconds past the whole second, `0..1_000_000_000`.
187    #[must_use]
188    pub const fn subsec_nanos(self) -> u32 {
189        self.nanos
190    }
191
192    /// Nanoseconds since the epoch, if within ~292 years (the `i64` range).
193    #[must_use]
194    pub const fn checked_unix_nanos(self) -> Option<i64> {
195        match self.seconds.checked_mul(NANOS_PER_SECOND as i64) {
196            Some(whole) => whole.checked_add(self.nanos as i64),
197            None => None,
198        }
199    }
200
201    /// `self + duration`; `None` on overflow.
202    #[must_use]
203    pub fn checked_add(self, duration: Duration) -> Option<Self> {
204        let seconds = i64::try_from(duration.as_secs()).ok()?;
205        // Both parts below 1 s so the sum is below 2 s; computed in a wider
206        // type so the compiler can prove it.
207        let (carry, nanos) =
208            split_nanos(u64::from(self.nanos) + u64::from(duration.subsec_nanos()));
209        let seconds = self.seconds.checked_add(seconds)?.checked_add(carry)?;
210        Some(Self::from_parts(seconds, nanos))
211    }
212
213    /// `self − duration`; `None` on underflow.
214    #[must_use]
215    pub fn checked_sub(self, duration: Duration) -> Option<Self> {
216        let seconds = i64::try_from(duration.as_secs()).ok()?;
217        let (borrow, nanos) = borrow_nanos(self.nanos, duration.subsec_nanos());
218        let seconds = self.seconds.checked_sub(seconds)?.checked_sub(borrow)?;
219        Some(Self::from_parts(seconds, nanos))
220    }
221
222    /// `self + duration`, saturating.
223    #[must_use]
224    pub fn saturating_add(self, duration: Duration) -> Self {
225        self.checked_add(duration)
226            .unwrap_or(Self::from_parts(i64::MAX, NANOS_PER_SECOND - 1))
227    }
228
229    /// `self − duration`, saturating.
230    #[must_use]
231    pub fn saturating_sub(self, duration: Duration) -> Self {
232        self.checked_sub(duration)
233            .unwrap_or(Self::from_parts(i64::MIN, 0))
234    }
235
236    /// Duration since `earlier`; `None` unless `self ≥ earlier`.
237    #[must_use]
238    pub fn checked_duration_since(self, earlier: Self) -> Option<Duration> {
239        if self < earlier {
240            return None;
241        }
242        let (borrow, nanos) = borrow_nanos(self.nanos, earlier.nanos);
243        // The difference can exceed `i64` across the full range; computed via
244        // `u64`.
245        let seconds = u64::try_from(
246            i128::from(self.seconds) - i128::from(earlier.seconds) - i128::from(borrow),
247        )
248        .ok()?;
249        Some(Duration::new(seconds, nanos))
250    }
251
252    /// Duration since `earlier`.
253    ///
254    /// # Errors
255    ///
256    /// [`KernelError::TimeReversed`] if `earlier` is later (clock stepped back
257    /// or arguments swapped), with the amount.
258    pub fn duration_since(self, earlier: Self) -> Result<Duration> {
259        match self.checked_duration_since(earlier) {
260            Some(elapsed) => Ok(elapsed),
261            None => Err(KernelError::TimeReversed {
262                // `earlier > self`, so `Some`.
263                by: earlier.checked_duration_since(self).unwrap_or_default(),
264            }),
265        }
266    }
267
268    /// Instant for a calendar reading on this scale.
269    ///
270    /// # Errors
271    ///
272    /// [`KernelError::OutOfRange`] for an invalid month, day, hour, minute,
273    /// second or nanosecond. Second `60` (leap second) is rejected: the count
274    /// has no place for it; the parser decides which side of the step it
275    /// belongs to.
276    pub fn from_civil(civil: Civil) -> Result<Self> {
277        civil.validate()?;
278        let days = days_from_civil(civil.year, civil.month, civil.day);
279        let seconds = days
280            .checked_mul(SECONDS_PER_DAY)
281            .and_then(|day_start| {
282                day_start.checked_add(
283                    i64::from(civil.hour) * 3600
284                        + i64::from(civil.minute) * 60
285                        + i64::from(civil.second),
286                )
287            })
288            .ok_or(KernelError::OutOfRange {
289                parameter: "year",
290                value: f64::from(civil.year),
291                min: f64::from(i32::MIN),
292                max: f64::from(i32::MAX),
293            })?;
294        Ok(Self::from_parts(seconds, civil.nanos))
295    }
296
297    /// Calendar reading on this instant's own scale.
298    #[must_use]
299    pub fn civil(self) -> Civil {
300        let days = self.seconds.div_euclid(SECONDS_PER_DAY);
301        let of_day = self.seconds.rem_euclid(SECONDS_PER_DAY);
302        let (year, month, day) = civil_from_days(days);
303        // Each quotient is bounded by the next divisor, so the narrowing casts
304        // cannot truncate.
305        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
306        Civil {
307            year,
308            month,
309            day,
310            hour: (of_day / 3600) as u8,
311            minute: (of_day % 3600 / 60) as u8,
312            second: (of_day % 60) as u8,
313            nanos: self.nanos,
314        }
315    }
316
317    /// Same count on another scale, for the conversions below.
318    const fn relabel<T: TimeScale>(self) -> Instant<T> {
319        Instant::from_parts(self.seconds, self.nanos)
320    }
321}
322
323impl<S: TimeScale> fmt::Debug for Instant<S> {
324    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
325        write!(f, "{self:.9}")
326    }
327}
328
329impl<S: TimeScale> fmt::Display for Instant<S> {
330    /// Formats as `2026-09-11T10:15:30.250 UTC` (ISO 8601 plus scale).
331    /// Precision: decimals of the second, default 3, max 9.
332    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
333        let civil = self.civil();
334        write!(
335            f,
336            "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
337            civil.year, civil.month, civil.day, civil.hour, civil.minute, civil.second
338        )?;
339        let precision = f.precision().unwrap_or(3).min(9);
340        if precision > 0 {
341            let mut scaled = civil.nanos;
342            for _ in precision..9 {
343                scaled /= 10;
344            }
345            write!(f, ".{scaled:0precision$}")?;
346        }
347        write!(f, " {}", S::NAME)
348    }
349}
350
351#[cfg(feature = "serde")]
352#[derive(serde::Serialize, serde::Deserialize)]
353struct RawInstant {
354    seconds: i64,
355    nanos: u32,
356}
357
358#[cfg(feature = "serde")]
359impl<S: TimeScale> serde::Serialize for Instant<S> {
360    /// Serialised as `{ "seconds", "nanos" }`; the scale is in the type.
361    fn serialize<Z: serde::Serializer>(
362        &self,
363        serializer: Z,
364    ) -> core::result::Result<Z::Ok, Z::Error> {
365        RawInstant {
366            seconds: self.seconds,
367            nanos: self.nanos,
368        }
369        .serialize(serializer)
370    }
371}
372
373#[cfg(feature = "serde")]
374impl<'de, S: TimeScale> serde::Deserialize<'de> for Instant<S> {
375    /// Deserialised through [`Instant::new`]; `nanos` ≥ 1 s is rejected.
376    fn deserialize<D: serde::Deserializer<'de>>(
377        deserializer: D,
378    ) -> core::result::Result<Self, D::Error> {
379        let raw = RawInstant::deserialize(deserializer)?;
380        Self::new(raw.seconds, raw.nanos).map_err(serde::de::Error::custom)
381    }
382}
383
384/// Seconds per day. No scale here has other day lengths: a UTC leap second has
385/// no distinct count.
386const SECONDS_PER_DAY: i64 = 86_400;
387
388/// Calendar reading.
389///
390/// Public fields so a parser can fill it and pass it to
391/// [`Instant::from_civil`], which validates. A `Civil` from [`Instant::civil`]
392/// is always valid.
393#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
394#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
395pub struct Civil {
396    /// Proleptic Gregorian year; `0` is 1 BC.
397    pub year: i32,
398    /// Month, `1..=12`.
399    pub month: u8,
400    /// Day, `1..=31` as the month allows.
401    pub day: u8,
402    /// Hour, `0..=23`.
403    pub hour: u8,
404    /// Minute, `0..=59`.
405    pub minute: u8,
406    /// Second, `0..=59`.
407    pub second: u8,
408    /// Nanoseconds, `0..1_000_000_000`.
409    pub nanos: u32,
410}
411
412impl Civil {
413    /// Midnight on a date.
414    #[must_use]
415    pub const fn date(year: i32, month: u8, day: u8) -> Self {
416        Self {
417            year,
418            month,
419            day,
420            hour: 0,
421            minute: 0,
422            second: 0,
423            nanos: 0,
424        }
425    }
426
427    /// Whether every field is valid.
428    fn validate(self) -> Result<()> {
429        check("month", u32::from(self.month), 1, 12)?;
430        check(
431            "day",
432            u32::from(self.day),
433            1,
434            u32::from(days_in_month(self.year, self.month)),
435        )?;
436        check("hour", u32::from(self.hour), 0, 23)?;
437        check("minute", u32::from(self.minute), 0, 59)?;
438        check("second", u32::from(self.second), 0, 59)?;
439        check("nanoseconds", self.nanos, 0, NANOS_PER_SECOND - 1)
440    }
441}
442
443/// Calendar field range check.
444fn check(parameter: &'static str, value: u32, min: u32, max: u32) -> Result<()> {
445    if value < min || value > max {
446        return Err(KernelError::OutOfRange {
447            parameter,
448            value: f64::from(value),
449            min: f64::from(min),
450            max: f64::from(max),
451        });
452    }
453    Ok(())
454}
455
456/// Whether a proleptic Gregorian year is a leap year.
457const fn is_leap_year(year: i32) -> bool {
458    year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
459}
460
461/// Days in a month; `0` for an invalid month, so the day check fails too.
462const fn days_in_month(year: i32, month: u8) -> u8 {
463    match month {
464        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
465        4 | 6 | 9 | 11 => 30,
466        2 if is_leap_year(year) => 29,
467        2 => 28,
468        _ => 0,
469    }
470}
471
472/// Days from `1970-01-01` to a date, negative before.
473///
474/// Hinnant's algorithm: years start on 1 March (February last) and days are
475/// counted in 400-year eras, over which the leap pattern repeats exactly. Valid
476/// for every `i32` year.
477fn days_from_civil(year: i32, month: u8, day: u8) -> i64 {
478    let year = i64::from(year) - i64::from(month <= 2);
479    let era = year.div_euclid(400);
480    let year_of_era = year.rem_euclid(400);
481    let shifted_month = i64::from(if month > 2 { month - 3 } else { month + 9 });
482    let day_of_year = (153 * shifted_month + 2) / 5 + i64::from(day) - 1;
483    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
484    era * 146_097 + day_of_era - 719_468
485}
486
487/// Inverse of [`days_from_civil`]. The year is clamped to `i32`, which no `i64`
488/// second count can exceed anyway.
489fn civil_from_days(days: i64) -> (i32, u8, u8) {
490    // Days derive from seconds / 86 400, so the era shift cannot wrap;
491    // saturating arithmetic states it.
492    let days = days.saturating_add(719_468);
493    let era = days.div_euclid(146_097);
494    let day_of_era = days.rem_euclid(146_097);
495    let year_of_era =
496        (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
497    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
498    let shifted_month = (5 * day_of_year + 2) / 153;
499    let day = day_of_year - (153 * shifted_month + 2) / 5 + 1;
500    let month = if shifted_month < 10 {
501        shifted_month + 3
502    } else {
503        shifted_month - 9
504    };
505    let year = year_of_era + era * 400 + i64::from(month <= 2);
506    // Month `1..=12`, day `1..=31` by construction; the year is clamped, not
507    // truncated.
508    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
509    (
510        year.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32,
511        month as u8,
512        day as u8,
513    )
514}
515
516/// Leap-second table: TAI − UTC at a given instant.
517///
518/// A port because any table expires (IERS announces about six months ahead);
519/// past its validity an implementation returns [`KernelError::OutsideValidity`]
520/// rather than the last known value. Implementations: compiled-in table with
521/// expiry, file, or GNSS receiver-reported offset.
522pub trait LeapSeconds {
523    /// `TAI − UTC` at a UTC instant: 37 s since 2017 until the next leap
524    /// second.
525    ///
526    /// # Errors
527    ///
528    /// [`KernelError::OutsideValidity`] outside the table's validity.
529    fn tai_minus_utc(&self, at: Instant<Utc>) -> Result<Duration>;
530}
531
532/// UTC → TAI.
533///
534/// # Errors
535///
536/// As [`LeapSeconds::tai_minus_utc`].
537pub fn utc_to_tai(at: Instant<Utc>, table: &impl LeapSeconds) -> Result<Instant<Tai>> {
538    let offset = table.tai_minus_utc(at)?;
539    Ok(at.saturating_add(offset).relabel())
540}
541
542/// TAI → UTC.
543///
544/// The table is indexed by UTC (the unknown), so it is read at a first guess
545/// and again at the result. Exact except during a leap second, where UTC is
546/// ambiguous and the later reading is returned.
547///
548/// # Errors
549///
550/// As [`LeapSeconds::tai_minus_utc`].
551pub fn tai_to_utc(at: Instant<Tai>, table: &impl LeapSeconds) -> Result<Instant<Utc>> {
552    let guess: Instant<Utc> = at.relabel();
553    let first = table.tai_minus_utc(guess)?;
554    let utc = at.saturating_sub(first).relabel::<Utc>();
555    let second = table.tai_minus_utc(utc)?;
556    Ok(at.saturating_sub(second).relabel())
557}
558
559/// TAI → GPS: fixed 19 s.
560#[must_use]
561pub fn tai_to_gps(at: Instant<Tai>) -> Instant<Gps> {
562    at.saturating_sub(TAI_MINUS_GPS).relabel()
563}
564
565/// GPS → TAI: fixed 19 s.
566#[must_use]
567pub fn gps_to_tai(at: Instant<Gps>) -> Instant<Tai> {
568    at.saturating_add(TAI_MINUS_GPS).relabel()
569}
570
571/// UTC → GPS.
572///
573/// # Errors
574///
575/// As [`LeapSeconds::tai_minus_utc`].
576pub fn utc_to_gps(at: Instant<Utc>, table: &impl LeapSeconds) -> Result<Instant<Gps>> {
577    utc_to_tai(at, table).map(tai_to_gps)
578}
579
580/// GPS → UTC.
581///
582/// # Errors
583///
584/// As [`LeapSeconds::tai_minus_utc`].
585pub fn gps_to_utc(at: Instant<Gps>, table: &impl LeapSeconds) -> Result<Instant<Utc>> {
586    tai_to_utc(gps_to_tai(at), table)
587}
588
589#[cfg(test)]
590#[allow(clippy::unwrap_used)]
591mod tests {
592    use super::*;
593    use alloc::format;
594
595    /// Table since 2017: 37 s, no expiry.
596    struct SinceTwentySeventeen;
597
598    impl LeapSeconds for SinceTwentySeventeen {
599        fn tai_minus_utc(&self, _at: Instant<Utc>) -> Result<Duration> {
600            Ok(Duration::from_secs(37))
601        }
602    }
603
604    #[test]
605    fn nanoseconds_split_correctly_either_side_of_the_epoch() {
606        let after = Instant::<Utc>::from_unix_nanos(1_500_000_000);
607        assert_eq!((after.seconds(), after.subsec_nanos()), (1, 500_000_000));
608        let before = Instant::<Utc>::from_unix_nanos(-1);
609        assert_eq!((before.seconds(), before.subsec_nanos()), (-1, 999_999_999));
610        assert_eq!(after.checked_unix_nanos(), Some(1_500_000_000));
611        assert_eq!(before.checked_unix_nanos(), Some(-1));
612        assert_eq!(
613            Instant::<Utc>::from_unix_seconds(i64::MAX).checked_unix_nanos(),
614            None
615        );
616    }
617
618    #[test]
619    fn a_full_second_of_nanoseconds_is_rejected() {
620        assert!(Instant::<Utc>::new(0, NANOS_PER_SECOND).is_err());
621        assert!(Instant::<Utc>::new(0, NANOS_PER_SECOND - 1).is_ok());
622    }
623
624    #[test]
625    fn arithmetic_carries_and_borrows() {
626        let start = Instant::<Gps>::new(10, 900_000_000).unwrap();
627        let later = start.checked_add(Duration::from_millis(250)).unwrap();
628        assert_eq!((later.seconds(), later.subsec_nanos()), (11, 150_000_000));
629        assert_eq!(later.checked_sub(Duration::from_millis(250)), Some(start));
630        assert_eq!(
631            later.duration_since(start).unwrap(),
632            Duration::from_millis(250)
633        );
634        assert_eq!(
635            start.duration_since(later),
636            Err(KernelError::TimeReversed {
637                by: Duration::from_millis(250)
638            })
639        );
640    }
641
642    #[test]
643    fn the_ends_of_the_range_saturate_rather_than_wrap() {
644        let end = Instant::<Tai>::from_unix_seconds(i64::MAX);
645        assert_eq!(end.checked_add(Duration::from_secs(1)), None);
646        assert!(end.saturating_add(Duration::from_secs(1)) >= end);
647        let start = Instant::<Tai>::from_unix_seconds(i64::MIN);
648        assert_eq!(start.checked_sub(Duration::from_nanos(1)), None);
649        assert_eq!(start.saturating_sub(Duration::from_secs(1)), start);
650        // The full range exceeds an `i64` second count but fits `Duration`
651        // (`u64` seconds).
652        assert_eq!(
653            end.checked_duration_since(start),
654            Some(Duration::from_secs(u64::MAX))
655        );
656    }
657
658    #[test]
659    fn the_calendar_round_trips_on_known_dates() {
660        let cases = [
661            (Civil::date(1970, 1, 1), 0),
662            (Civil::date(2000, 3, 1), 951_868_800),
663            (Civil::date(2017, 1, 1), 1_483_228_800),
664            (Civil::date(1969, 12, 31), -86_400),
665            (Civil::date(1600, 2, 29), -11_670_998_400),
666            (Civil::date(2400, 2, 29), 13_574_563_200),
667        ];
668        for (civil, seconds) in cases {
669            let instant = Instant::<Utc>::from_civil(civil).unwrap();
670            assert_eq!(instant.seconds(), seconds, "{civil:?}");
671            assert_eq!(instant.civil(), civil);
672        }
673        let reading = Civil {
674            year: 2026,
675            month: 9,
676            day: 11,
677            hour: 10,
678            minute: 15,
679            second: 30,
680            nanos: 250_000_000,
681        };
682        let instant = Instant::<Utc>::from_civil(reading).unwrap();
683        assert_eq!(instant.civil(), reading);
684        assert_eq!(format!("{instant}"), "2026-09-11T10:15:30.250 UTC");
685        assert_eq!(format!("{instant:.0}"), "2026-09-11T10:15:30 UTC");
686        assert_eq!(format!("{instant:?}"), "2026-09-11T10:15:30.250000000 UTC");
687    }
688
689    #[test]
690    fn impossible_dates_are_errors() {
691        assert!(Instant::<Utc>::from_civil(Civil::date(2023, 2, 29)).is_err());
692        assert!(Instant::<Utc>::from_civil(Civil::date(2024, 2, 29)).is_ok());
693        assert!(Instant::<Utc>::from_civil(Civil::date(2024, 13, 1)).is_err());
694        assert!(Instant::<Utc>::from_civil(Civil::date(2024, 4, 31)).is_err());
695        let leap = Civil {
696            second: 60,
697            ..Civil::date(2016, 12, 31)
698        };
699        assert!(Instant::<Utc>::from_civil(leap).is_err());
700    }
701
702    #[test]
703    fn scales_convert_through_the_table_and_back() {
704        let utc = Instant::<Utc>::from_civil(Civil::date(2026, 9, 11)).unwrap();
705        let tai = utc_to_tai(utc, &SinceTwentySeventeen).unwrap();
706        assert_eq!(
707            tai.duration_since(utc.relabel()).unwrap(),
708            Duration::from_secs(37)
709        );
710        let gps = utc_to_gps(utc, &SinceTwentySeventeen).unwrap();
711        assert_eq!(
712            gps.duration_since(utc.relabel()).unwrap(),
713            Duration::from_secs(18)
714        );
715        assert_eq!(tai_to_utc(tai, &SinceTwentySeventeen).unwrap(), utc);
716        assert_eq!(gps_to_utc(gps, &SinceTwentySeventeen).unwrap(), utc);
717        assert_eq!(format!("{gps}"), "2026-09-11T00:00:18.000 GPS");
718    }
719
720    #[test]
721    fn a_stepping_table_is_read_at_the_answer_not_the_guess() {
722        // One-step table: 36 s before 2017, 37 s after.
723        struct Stepping;
724        impl LeapSeconds for Stepping {
725            fn tai_minus_utc(&self, at: Instant<Utc>) -> Result<Duration> {
726                let step = Instant::<Utc>::from_civil(Civil::date(2017, 1, 1)).unwrap();
727                Ok(Duration::from_secs(if at < step { 36 } else { 37 }))
728            }
729        }
730        // 1 s before the step (UTC): 36 s applies, whichever side the initial
731        // guess lands.
732        let utc = Instant::<Utc>::from_civil(Civil::date(2017, 1, 1))
733            .unwrap()
734            .checked_sub(Duration::from_secs(1))
735            .unwrap();
736        let tai = utc_to_tai(utc, &Stepping).unwrap();
737        assert_eq!(tai_to_utc(tai, &Stepping).unwrap(), utc);
738    }
739
740    #[test]
741    fn an_expired_table_is_an_error_not_a_guess() {
742        struct Expired;
743        impl LeapSeconds for Expired {
744            fn tai_minus_utc(&self, _at: Instant<Utc>) -> Result<Duration> {
745                Err(KernelError::OutsideValidity {
746                    data: "leap second table",
747                })
748            }
749        }
750        assert!(utc_to_gps(Instant::UNIX_EPOCH, &Expired).is_err());
751    }
752
753    #[cfg(feature = "serde")]
754    #[test]
755    fn serde_round_trips_and_validates() {
756        let instant = Instant::<Utc>::new(1_700_000_000, 5).unwrap();
757        let json = serde_json::to_string(&instant).unwrap();
758        assert_eq!(json, r#"{"seconds":1700000000,"nanos":5}"#);
759        assert_eq!(
760            serde_json::from_str::<Instant<Utc>>(&json).unwrap(),
761            instant
762        );
763        assert!(
764            serde_json::from_str::<Instant<Utc>>(r#"{"seconds":0,"nanos":1000000000}"#).is_err()
765        );
766    }
767}