1#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
56pub struct CivilDate {
57 pub year: i32,
59 pub month: u8,
61 pub day: u8,
63}
64
65impl CivilDate {
66 #[inline]
70 #[must_use]
71 pub const fn new(
72 year: i32,
73 month: u8,
74 day: u8,
75 ) -> Self {
76 CivilDate { year, month, day }
77 }
78
79 #[inline]
85 #[must_use]
86 pub const fn days_from_unix(self) -> i64 {
87 days_from_unix_impl(self.year, self.month as i32, self.day as i32)
88 }
89
90 #[inline]
92 #[must_use]
93 pub const fn days_until(
94 self,
95 other: CivilDate,
96 ) -> i64 {
97 other.days_from_unix() - self.days_from_unix()
98 }
99
100 #[inline]
104 #[must_use]
105 pub const fn seconds_until(
106 self,
107 other: CivilDate,
108 ) -> i64 {
109 self.days_until(other) * 86_400
110 }
111
112 #[inline]
116 #[must_use]
117 pub const fn nanos_until(
118 self,
119 other: CivilDate,
120 ) -> i64 {
121 self.seconds_until(other) * 1_000_000_000
122 }
123}
124
125const fn days_from_unix_impl(
130 y: i32,
131 m: i32,
132 d: i32,
133) -> i64 {
134 let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
137 let y = y as i64;
138 let era = if y >= 0 { y / 400 } else { (y - 399) / 400 };
140 let yoe = (y - era * 400) as u64; let doy = ((153 * m as i64 + 2) / 5 + d as i64 - 1) as u64;
144 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
146 era * 146_097 + doe as i64 - 719_468
148}
149
150pub const TAI_EPOCH: CivilDate = CivilDate::new(1958, 1, 1);
152
153pub const UNIX_EPOCH: CivilDate = CivilDate::new(1970, 1, 1);
155
156pub const GPS_EPOCH: CivilDate = CivilDate::new(1980, 1, 6);
158
159pub const GLONASS_EPOCH: CivilDate = CivilDate::new(1996, 1, 1);
161
162pub const GALILEO_EPOCH: CivilDate = CivilDate::new(1999, 8, 22);
164
165pub const BEIDOU_EPOCH: CivilDate = CivilDate::new(2006, 1, 1);
167
168pub const LEAP_SECONDS_AT_GPS_EPOCH: i64 = 19;
170
171pub const LEAP_SECONDS_AT_GLONASS_EPOCH: i64 = 30;
173
174pub const LEAP_SECONDS_AT_GALILEO_EPOCH: i64 = 32;
176
177pub const LEAP_SECONDS_AT_BEIDOU_EPOCH: i64 = 33;
179
180pub const DAYS_GPS_TO_GALILEO: i64 = GPS_EPOCH.days_until(GALILEO_EPOCH);
182
183pub const DAYS_GPS_TO_BEIDOU: i64 = GPS_EPOCH.days_until(BEIDOU_EPOCH);
185
186pub const DAYS_GPS_TO_GLONASS: i64 = GPS_EPOCH.days_until(GLONASS_EPOCH);
188
189pub const DAYS_UNIX_TO_GPS: i64 = UNIX_EPOCH.days_until(GPS_EPOCH);
191
192pub const NANOS_GPS_TO_GALILEO_EPOCH: i64 = GPS_EPOCH.nanos_until(GALILEO_EPOCH);
194
195pub const NANOS_GPS_TO_BEIDOU_EPOCH_CALENDAR: i64 = GPS_EPOCH.nanos_until(BEIDOU_EPOCH);
197
198const _VERIFY_GALILEO: () = {
200 let s = NANOS_GPS_TO_GALILEO_EPOCH / 1_000_000_000;
201 assert!(s == 619_315_200, "Galileo epoch offset check failed");
202};
203
204const _VERIFY_BEIDOU: () = {
206 let s = NANOS_GPS_TO_BEIDOU_EPOCH_CALENDAR / 1_000_000_000;
207 assert!(s == 820_108_800, "BeiDou epoch offset check failed");
208};
209
210const _VERIFY_GPS_UNIX: () = {
212 assert!(DAYS_UNIX_TO_GPS == 3657, "GPS Unix offset check failed");
213};
214
215const _VERIFY_GLONASS: () = {
217 assert!(
218 DAYS_GPS_TO_GLONASS == 5839,
219 "GLONASS epoch offset check failed"
220 );
221};
222
223impl core::fmt::Display for CivilDate {
224 fn fmt(
225 &self,
226 f: &mut core::fmt::Formatter<'_>,
227 ) -> core::fmt::Result {
228 write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
229 }
230}
231
232#[cfg(test)]
237mod tests {
238 use super::*;
239
240 #[test]
241 fn test_unix_epoch_is_day_zero() {
242 assert_eq!(UNIX_EPOCH.days_from_unix(), 0);
243 }
244
245 #[test]
246 fn test_gps_epoch_is_3657_days_from_unix() {
247 assert_eq!(GPS_EPOCH.days_from_unix(), 3657);
248 }
249
250 #[test]
251 fn test_galileo_epoch_days_from_unix() {
252 assert_eq!(GALILEO_EPOCH.days_from_unix(), 10825);
254 }
255
256 #[test]
257 fn test_beidou_epoch_days_from_unix() {
258 assert_eq!(BEIDOU_EPOCH.days_from_unix(), 13149);
260 }
261
262 #[test]
263 fn test_glonass_epoch_days_from_unix() {
264 assert_eq!(GLONASS_EPOCH.days_from_unix(), 9496);
266 }
267
268 #[test]
269 fn test_gps_to_galileo_is_7168_days() {
270 assert_eq!(DAYS_GPS_TO_GALILEO, 7168);
271 }
272
273 #[test]
274 fn test_gps_to_beidou_is_9492_days() {
275 assert_eq!(DAYS_GPS_TO_BEIDOU, 9492);
276 }
277
278 #[test]
279 fn test_gps_to_glonass_is_5839_days() {
280 assert_eq!(DAYS_GPS_TO_GLONASS, 5839);
281 }
282
283 #[test]
284 fn test_galileo_minus_gps_is_619315200_seconds() {
285 assert_eq!(GPS_EPOCH.seconds_until(GALILEO_EPOCH), 619_315_200);
286 }
287
288 #[test]
289 fn test_beidou_minus_gps_calendar_is_820108800_seconds() {
290 assert_eq!(GPS_EPOCH.seconds_until(BEIDOU_EPOCH), 820_108_800);
291 }
292
293 #[test]
294 fn test_glonass_minus_gps_is_505123200_seconds() {
295 let expected = 5839_i64 * 86_400;
297
298 assert_eq!(GPS_EPOCH.seconds_until(GLONASS_EPOCH), expected);
299 }
300
301 #[test]
302 fn test_days_until_is_antisymmetric() {
303 let a = CivilDate::new(2000, 1, 1);
304 let b = CivilDate::new(2001, 1, 1);
305
306 assert_eq!(a.days_until(b), -b.days_until(a));
307 }
308
309 #[test]
310 fn test_days_until_self_is_zero() {
311 assert_eq!(GPS_EPOCH.days_until(GPS_EPOCH), 0);
312 }
313
314 #[test]
315 fn test_year_2000_is_leap_year() {
316 let feb29 = CivilDate::new(2000, 2, 29);
318 let mar01 = CivilDate::new(2000, 3, 1);
319
320 assert_eq!(feb29.days_until(mar01), 1);
321 }
322
323 #[test]
324 fn test_year_1900_is_not_leap_year() {
325 let feb28 = CivilDate::new(1900, 2, 28);
327 let mar01 = CivilDate::new(1900, 3, 1);
328
329 assert_eq!(feb28.days_until(mar01), 1);
331 }
332
333 #[test]
334 fn test_epoch_dates_are_correct() {
335 assert_eq!(GPS_EPOCH, CivilDate::new(1980, 1, 6));
336 assert_eq!(GLONASS_EPOCH, CivilDate::new(1996, 1, 1));
337 assert_eq!(GALILEO_EPOCH, CivilDate::new(1999, 8, 22));
338 assert_eq!(BEIDOU_EPOCH, CivilDate::new(2006, 1, 1));
339 assert_eq!(TAI_EPOCH, CivilDate::new(1958, 1, 1));
340 assert_eq!(UNIX_EPOCH, CivilDate::new(1970, 1, 1));
341 }
342
343 #[test]
344 fn test_leap_seconds_at_epochs_match_official_values() {
345 assert_eq!(LEAP_SECONDS_AT_GPS_EPOCH, 19);
347 assert_eq!(LEAP_SECONDS_AT_GLONASS_EPOCH, 30);
348 assert_eq!(LEAP_SECONDS_AT_BEIDOU_EPOCH, 33);
349 }
350
351 #[test]
352 fn test_nanos_gps_to_galileo_matches_known_value() {
353 assert_eq!(NANOS_GPS_TO_GALILEO_EPOCH, 619_315_200_000_000_000_i64);
354 }
355
356 #[test]
357 fn test_nanos_gps_to_beidou_calendar_matches_known_value() {
358 assert_eq!(
359 NANOS_GPS_TO_BEIDOU_EPOCH_CALENDAR,
360 820_108_800_000_000_000_i64
361 );
362 }
363
364 #[test]
365 fn test_pre_unix_date_is_negative() {
366 let date = CivilDate::new(1960, 1, 1);
367
368 assert!(date.days_from_unix() < 0);
369 }
370
371 #[test]
372 fn test_invalid_date_does_not_panic() {
373 let date = CivilDate::new(2024, 13, 40);
374 let _ = date.days_from_unix(); }
376
377 #[test]
378 fn test_ordering_is_consistent() {
379 let a = CivilDate::new(2000, 1, 1);
380 let b = CivilDate::new(2001, 1, 1);
381
382 assert!(a.days_from_unix() < b.days_from_unix());
383 }
384
385 #[test]
386 fn test_seconds_until_matches_days() {
387 let a = CivilDate::new(2000, 1, 1);
388 let b = CivilDate::new(2000, 1, 2);
389
390 assert_eq!(a.seconds_until(b), 86_400);
391 }
392
393 #[test]
394 fn test_nanos_until_matches_seconds() {
395 let a = CivilDate::new(2000, 1, 1);
396 let b = CivilDate::new(2000, 1, 2);
397
398 assert_eq!(a.nanos_until(b), 86_400_000_000_000);
399 }
400
401 #[test]
402 fn test_gps_epoch_days_constant_is_stable() {
403 assert_eq!(GPS_EPOCH.days_from_unix(), 3657);
404 }
405
406 #[test]
407 fn test_monotonicity_property() {
408 let a = CivilDate::new(2000, 1, 1);
409 let b = CivilDate::new(2000, 1, 2);
410 let c = CivilDate::new(2000, 1, 3);
411
412 assert!(a.days_from_unix() < b.days_from_unix());
413 assert!(b.days_from_unix() < c.days_from_unix());
414 }
415}