1#[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
11const DAYS_PER_CYCLE: i64 = 146_097;
13const EPOCH_DAY: i64 = 719_468;
15const FIRST_DAY: i64 = Timestamp::MIN.0.div_euclid(NANOS_PER_DAY);
17const CYCLE_SHIFT: i64 = 5_368_710;
20const YEAR_SHIFT: i64 = 400 * CYCLE_SHIFT;
22const DAY_SHIFT: i64 = EPOCH_DAY + DAYS_PER_CYCLE * CYCLE_SHIFT;
24
25#[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 pub year: i32,
60 pub month: u8,
62 pub day: u8,
64 pub hour: u8,
66 pub minute: u8,
68 pub second: u8,
70 pub nanosecond: u32,
72}
73
74impl UtcDateTime {
75 #[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 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 #[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 #[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 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
156const fn is_leap_year(year: i32) -> bool {
158 year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
159}
160
161const 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#[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#[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 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 #[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 #[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}