Skip to main content

t2t_core/
utc.rs

1//! A point on the wall clock as a date and a time of day.
2
3#[cfg(feature = "zerocopy-08")]
4use zerocopy::{FromBytes, Immutable, KnownLayout};
5
6use crate::Timestamp;
7use crate::consts::{
8    NANOS_PER_DAY, NANOS_PER_SECOND, SECONDS_PER_DAY, SECONDS_PER_HOUR, SECONDS_PER_MINUTE,
9};
10
11/// The days in 400 years of the Gregorian calendar, the cycle it repeats in.
12const DAYS_PER_CYCLE: i64 = 146_097;
13/// The days from 0000-03-01, where Neri and Schneider's calendar starts, to 1970-01-01.
14const EPOCH_DAY: i64 = 719_468;
15/// The first day a [`Timestamp`] reaches, 1677-09-21, counted from 1970-01-01.
16const FIRST_DAY: i64 = Timestamp::MIN.0.div_euclid(NANOS_PER_DAY);
17/// The 400-year cycles the calendar is shifted by, so every year an `i32` holds counts up from
18/// zero, where Neri and Schneider's algorithm works.
19const CYCLE_SHIFT: i64 = 5_368_710;
20/// The years in [`CYCLE_SHIFT`].
21const YEAR_SHIFT: i64 = 400 * CYCLE_SHIFT;
22/// The days from the shifted calendar's origin, a March 1st, to 1970-01-01.
23const DAY_SHIFT: i64 = EPOCH_DAY + DAYS_PER_CYCLE * CYCLE_SHIFT;
24
25// The conversions are `#[inline]`, so a build without LTO, as a default release build is,
26// inlines them across crates: it reads 1,024 instants as dates in 0.98 µs against 8.0 µs, and
27// dates as instants in 5.1 µs against 6.9. Under fat LTO the first is no faster and the second
28// 13% slower, 5.2 µs against 4.6; both under
29// `benches/results/2026-10-03T08-54Z-635b659-calendar-and-digit-pairs`.
30
31/// A point on the wall clock read as a date and a time of day: UTC, in the proleptic Gregorian
32/// calendar.
33///
34/// A view of a point: arithmetic stays on the [`Timestamp`] it came from. Unix time has no leap
35/// seconds, so `second` is never 60. The fields are public, so one may be built by hand;
36/// [`is_valid`](Self::is_valid) checks one.
37///
38/// # Examples
39/// ```
40/// use t2t_core::{Timestamp, UtcDateTime};
41///
42/// let instant = Timestamp::from_nanos(1_789_544_735_123_456_789);
43/// let date_time = instant.to_utc();
44///
45/// assert_eq!((date_time.year, date_time.month, date_time.day), (2026, 9, 16), "the date");
46/// assert_eq!(
47///     (date_time.hour, date_time.minute, date_time.second),
48///     (7, 45, 35),
49///     "the time of day"
50/// );
51/// assert_eq!(date_time.nanosecond, 123_456_789, "to the nanosecond");
52/// assert_eq!(date_time.to_timestamp(), Some(instant), "and back to its instant");
53/// ```
54#[repr(C)]
55#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
56#[cfg_attr(feature = "zerocopy-08", derive(FromBytes, Immutable, KnownLayout))]
57pub struct UtcDateTime {
58    /// The year.
59    pub year: i32,
60    /// The month of the year, from 1.
61    pub month: u8,
62    /// The day of the month, from 1.
63    pub day: u8,
64    /// The hour of the day, below 24.
65    pub hour: u8,
66    /// The minute of the hour, below 60.
67    pub minute: u8,
68    /// The second of the minute, below 60.
69    pub second: u8,
70    /// The nanoseconds past the second, below a billion.
71    pub nanosecond: u32,
72}
73
74impl UtcDateTime {
75    /// The point this names, or `None` for one that is not [valid](Self::is_valid) or is
76    /// outside 1677-09-21 to 2262-04-11.
77    #[must_use]
78    #[expect(
79        clippy::arithmetic_side_effects,
80        clippy::as_conversions,
81        clippy::cast_possible_truncation,
82        reason = "a date and time's nanoseconds fit `i128`, and the sum is checked to fit `i64`; `From` \
83                  and `TryFrom` are not const"
84    )]
85    #[inline]
86    pub const fn to_timestamp(self) -> Option<Timestamp> {
87        if !self.is_valid() {
88            return None;
89        }
90        // In `i128`, since the second holding `Timestamp::MIN` starts below it.
91        let nanos =
92            seconds_from_civil(self) as i128 * NANOS_PER_SECOND as i128 + self.nanosecond as i128;
93        if nanos < i64::MIN as i128 || nanos > i64::MAX as i128 {
94            return None;
95        }
96        Some(Timestamp(nanos as i64))
97    }
98
99    /// Whether every field names a real date and time.
100    #[must_use]
101    #[expect(clippy::as_conversions, reason = "widening is lossless; `From` is not const")]
102    #[inline]
103    pub const fn is_valid(self) -> bool {
104        self.month >= 1
105            && self.month <= 12
106            && self.day >= 1
107            && self.day <= days_in_month(self.year, self.month)
108            && self.hour < 24
109            && self.minute < 60
110            && self.second < 60
111            && (self.nanosecond as i64) < NANOS_PER_SECOND
112    }
113}
114
115impl Timestamp {
116    /// This point read as a date and a time of day in UTC.
117    #[must_use]
118    #[expect(
119        clippy::arithmetic_side_effects,
120        clippy::as_conversions,
121        clippy::cast_possible_truncation,
122        clippy::cast_sign_loss,
123        reason = "counted from the first day a timestamp reaches, its seconds are nonnegative and \
124                  inside `u64`, and its days inside `u32`; `TryFrom` is not const"
125    )]
126    #[inline]
127    pub const fn to_utc(self) -> UtcDateTime {
128        // Counted from `FIRST_DAY`, every split is an unsigned division by a constant, and the
129        // time of day's in 32 bits.
130        let seconds_per_day = SECONDS_PER_DAY.unsigned_abs();
131        let seconds_per_hour = SECONDS_PER_HOUR as u32;
132        let seconds_per_minute = SECONDS_PER_MINUTE as u32;
133        let secs = (self.0.div_euclid(NANOS_PER_SECOND) - FIRST_DAY * SECONDS_PER_DAY) as u64;
134        let day = (secs / seconds_per_day) as u32 + (EPOCH_DAY + FIRST_DAY) as u32;
135        let second_of_day = (secs % seconds_per_day) as u32;
136        let (year, month, day) = civil_from_day(day);
137        UtcDateTime {
138            year,
139            month,
140            day,
141            hour: (second_of_day / seconds_per_hour) as u8,
142            minute: (second_of_day % seconds_per_hour / seconds_per_minute) as u8,
143            second: (second_of_day % seconds_per_minute) as u8,
144            nanosecond: self.0.rem_euclid(NANOS_PER_SECOND) as u32,
145        }
146    }
147}
148
149impl From<Timestamp> for UtcDateTime {
150    #[inline]
151    fn from(instant: Timestamp) -> Self {
152        instant.to_utc()
153    }
154}
155
156/// Whether `year` has a February 29th.
157const fn is_leap_year(year: i32) -> bool {
158    year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
159}
160
161/// The days in `month` of `year`.
162const fn days_in_month(year: i32, month: u8) -> u8 {
163    match month {
164        2 if is_leap_year(year) => 29,
165        2 => 28,
166        4 | 6 | 9 | 11 => 30,
167        _ => 31,
168    }
169}
170
171/// The seconds from 1970-01-01T00:00:00 to `date_time`, by Neri and Schneider's algorithm.
172#[expect(
173    clippy::arithmetic_side_effects,
174    clippy::as_conversions,
175    reason = "every term stays far inside `i64` for any field values; `From` is not const"
176)]
177#[inline]
178const fn seconds_from_civil(date_time: UtcDateTime) -> i64 {
179    let is_january_or_february = date_time.month <= 2;
180    let year = date_time.year as i64 + YEAR_SHIFT - is_january_or_february as i64;
181    let month = date_time.month as i64 + if is_january_or_february { 12 } else { 0 };
182    let century = year / 100;
183    let year_days = 1_461 * year / 4 - century + century / 4;
184    let month_days = (979 * month - 2_919) / 32;
185    let days = year_days + month_days + date_time.day as i64 - 1 - DAY_SHIFT;
186    days * SECONDS_PER_DAY
187        + date_time.hour as i64 * SECONDS_PER_HOUR
188        + date_time.minute as i64 * SECONDS_PER_MINUTE
189        + date_time.second as i64
190}
191
192/// The date `day` days after 0000-03-01, a day a [`Timestamp`] reaches, by Neri and Schneider.
193#[expect(
194    clippy::arithmetic_side_effects,
195    clippy::as_conversions,
196    clippy::cast_possible_truncation,
197    clippy::cast_possible_wrap,
198    reason = "a timestamp's day keeps every term inside `u32`, or `u64` for the year's product, and \
199              each part inside its type; `From` and `TryFrom` are not const"
200)]
201#[inline]
202const fn civil_from_day(day: u32) -> (i32, u8, u8) {
203    let century_part = 4 * day + 3;
204    let days_per_cycle = DAYS_PER_CYCLE as u32;
205    let century = century_part / days_per_cycle;
206    let day_of_century = century_part % days_per_cycle / 4;
207    let year_part = 2_939_745 * (4 * day_of_century + 3) as u64;
208    let year_of_century = (year_part >> 32) as u32;
209    let day_of_year = year_part as u32 / 2_939_745 / 4;
210    let month_part = 2_141 * day_of_year + 197_913;
211    let day = (month_part & 0xFFFF) / 2_141 + 1;
212    let is_january_or_february = day_of_year >= 306;
213    let year = 100 * century + year_of_century + is_january_or_february as u32;
214    let month = (month_part >> 16) - if is_january_or_february { 12 } else { 0 };
215    (year as i32, month as u8, day as u8)
216}
217
218#[cfg(test)]
219mod tests {
220    use proptest::prelude::{any, prop_assert_eq, prop_oneof, proptest};
221    use rstest::rstest;
222
223    use super::{DAYS_PER_CYCLE, EPOCH_DAY, civil_from_day};
224    use crate::{Timestamp, UtcDateTime};
225
226    /// A leap day at noon, which a case alters by a field.
227    const LEAP_DAY: UtcDateTime = UtcDateTime {
228        year: 2024,
229        month: 2,
230        day: 29,
231        hour: 12,
232        minute: 0,
233        second: 0,
234        nanosecond: 0,
235    };
236
237    /// The date `days` after 1970-01-01, by Howard Hinnant's `civil_from_days`, as a reference.
238    #[expect(clippy::arithmetic_side_effects, reason = "a timestamp's day keeps every term small")]
239    fn reference(days: i64) -> (i32, u8, u8) {
240        let day_count = days.checked_add(EPOCH_DAY).expect("a timestamp's day");
241        let era = day_count.div_euclid(DAYS_PER_CYCLE);
242        let day_of_era = day_count.rem_euclid(DAYS_PER_CYCLE);
243        let year_of_era =
244            (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
245        let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
246        let month_part = (5 * day_of_year + 2) / 153;
247        let day = day_of_year - (153 * month_part + 2) / 5 + 1;
248        let month = if month_part < 10 { month_part + 3 } else { month_part - 9 };
249        let year = year_of_era + era * 400 + i64::from(month <= 2);
250        (
251            i32::try_from(year).expect("a timestamp's year"),
252            u8::try_from(month).expect("a month"),
253            u8::try_from(day).expect("a day"),
254        )
255    }
256
257    #[test]
258    fn the_epoch_is_the_first_instant_of_1970() {
259        let date_time = Timestamp::UNIX_EPOCH.to_utc();
260        assert_eq!(
261            date_time,
262            UtcDateTime {
263                year: 1970,
264                month: 1,
265                day: 1,
266                hour: 0,
267                minute: 0,
268                second: 0,
269                nanosecond: 0
270            },
271            "midnight on the first of January 1970"
272        );
273        assert_eq!(date_time.to_timestamp(), Some(Timestamp::UNIX_EPOCH), "and back");
274    }
275
276    #[test]
277    fn an_instant_before_the_epoch_counts_its_fraction_forwards() {
278        let date_time = Timestamp::from_nanos(-1).to_utc();
279        let date = (date_time.year, date_time.month, date_time.day);
280        assert_eq!(date, (1969, 12, 31), "the last day of 1969");
281        let time = (date_time.hour, date_time.minute, date_time.second);
282        assert_eq!(time, (23, 59, 59), "its last second");
283        assert_eq!(date_time.nanosecond, 999_999_999, "and its last nanosecond");
284    }
285
286    #[test]
287    fn the_range_ends_read_as_their_dates() {
288        assert_eq!(Timestamp::MIN.to_utc().year, 1677, "the earliest in 1677");
289        assert_eq!(Timestamp::MAX.to_utc().year, 2262, "the latest in 2262");
290        let (earliest, latest) = (Timestamp::MIN.to_utc(), Timestamp::MAX.to_utc());
291        assert_eq!(earliest.to_timestamp(), Some(Timestamp::MIN), "each read back");
292        assert_eq!(latest.to_timestamp(), Some(Timestamp::MAX), "both");
293    }
294
295    #[test]
296    fn a_date_outside_the_range_has_no_instant() {
297        let date_time = Timestamp::UNIX_EPOCH.to_utc();
298        assert_eq!(UtcDateTime { year: 3000, ..date_time }.to_timestamp(), None, "after 2262");
299        assert_eq!(UtcDateTime { year: 1000, ..date_time }.to_timestamp(), None, "before 1677");
300    }
301
302    #[test]
303    fn a_leap_day_reads_back() {
304        let round_trip = LEAP_DAY.to_timestamp().map(Timestamp::to_utc);
305        assert_eq!(round_trip, Some(LEAP_DAY), "a leap day reads back");
306    }
307
308    #[test]
309    fn a_february_30th_has_no_instant() {
310        assert_eq!(UtcDateTime { day: 30, ..LEAP_DAY }.to_timestamp(), None, "no February 30th");
311    }
312
313    #[rstest]
314    #[case::a_leap_day(LEAP_DAY, true)]
315    #[case::a_common_year(UtcDateTime { year: 2023, ..LEAP_DAY }, false)]
316    #[case::a_century(UtcDateTime { year: 1900, ..LEAP_DAY }, false)]
317    #[case::a_400th_year(UtcDateTime { year: 2000, ..LEAP_DAY }, true)]
318    #[case::a_day_past_the_months_end(UtcDateTime { day: 30, ..LEAP_DAY }, false)]
319    #[case::a_13th_month(UtcDateTime { month: 13, ..LEAP_DAY }, false)]
320    #[case::a_leap_second(UtcDateTime { second: 60, ..LEAP_DAY }, false)]
321    #[case::a_whole_second_of_nanoseconds(
322        UtcDateTime { nanosecond: 1_000_000_000, ..LEAP_DAY },
323        false
324    )]
325    fn a_date_time_is_valid_where_every_field_names_a_real_one(
326        #[case] date_time: UtcDateTime, #[case] is_valid: bool,
327    ) {
328        assert_eq!(date_time.is_valid(), is_valid, "as the calendar has it");
329    }
330
331    proptest! {
332        #[test]
333        fn the_calendar_agrees_with_the_reference(days in -106_752_i64..=106_751) {
334            let day = days.checked_add(EPOCH_DAY).and_then(|day| u32::try_from(day).ok());
335            prop_assert_eq!(day.map(civil_from_day), Some(reference(days)), "the same date");
336        }
337
338        #[test]
339        fn every_instant_reads_back_from_its_date(nanos in any::<i64>()) {
340            let instant = Timestamp::from_nanos(nanos);
341            prop_assert_eq!(instant.to_utc().to_timestamp(), Some(instant), "the same instant");
342        }
343
344        // Every field a little past its range, and any year, as bytes or a hand may build them.
345        #[test]
346        fn any_fields_read_as_the_instant_they_name_or_as_none(
347            year in prop_oneof![any::<i32>(), 1_600_i32..=2_300],
348            month in 0_u8..=13,
349            day in 0_u8..=32,
350            hour in 0_u8..=24,
351            minute in 0_u8..=60,
352            second in 0_u8..=60,
353            nanosecond in 0_u32..=1_000_000_000,
354        ) {
355            let date_time = UtcDateTime { year, month, day, hour, minute, second, nanosecond };
356            if let Some(instant) = date_time.to_timestamp() {
357                prop_assert_eq!(instant.to_utc(), date_time, "the instant reads back as its fields");
358            }
359        }
360    }
361}