1use std::time::{Duration, SystemTime};
6
7#[cfg(any(test, feature = "serde", feature = "xml", feature = "openstep"))]
8use time::{Month, Time};
9use time::{OffsetDateTime, PrimitiveDateTime};
10
11#[cfg(any(test, feature = "binary"))]
13const APPLE_EPOCH_UNIX_SECONDS: f64 = 978_307_200.0;
14
15#[cfg(any(
16 test,
17 feature = "serde",
18 feature = "binary",
19 feature = "xml",
20 feature = "openstep"
21))]
22const NANOS_PER_SECOND: i128 = 1_000_000_000;
23
24const MIN_INSTANT: OffsetDateTime = PrimitiveDateTime::MIN.assume_utc();
25const MAX_INSTANT: OffsetDateTime = PrimitiveDateTime::MAX.assume_utc();
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
46pub struct Date(OffsetDateTime);
47
48impl Date {
49 #[cfg(any(test, feature = "serde", feature = "binary"))]
53 pub(crate) fn from_unix(secs: i64, nanos: i64) -> Self {
54 Self::clamped(i128::from(secs) * NANOS_PER_SECOND + i128::from(nanos))
55 }
56
57 fn clamped(unix_nanos: i128) -> Self {
58 OffsetDateTime::from_unix_timestamp_nanos(unix_nanos).map_or_else(
59 |_| {
60 if unix_nanos < 0 {
61 Self(MIN_INSTANT)
62 } else {
63 Self(MAX_INSTANT)
64 }
65 },
66 Self,
67 )
68 }
69
70 pub(crate) const fn unix_parts(self) -> (i64, u32) {
73 (self.0.unix_timestamp(), self.0.time().nanosecond())
74 }
75
76 #[cfg(any(test, feature = "binary"))]
83 pub(crate) fn from_apple_epoch(seconds: f64) -> Self {
84 let val = seconds + APPLE_EPOCH_UNIX_SECONDS;
85 #[expect(
86 clippy::cast_possible_truncation,
87 reason = "float-to-int conversion site; saturating `as` keeps every payload succeeding (spec 02 §2.7.4)"
88 )]
89 let (secs, nanos) = (val.trunc() as i64, (val.fract() * 1e9) as i64);
90 Self::from_unix(secs, nanos)
91 }
92
93 #[cfg(any(test, feature = "binary"))]
98 pub(crate) fn to_apple_epoch(self) -> f64 {
99 let (secs, nanos) = self.unix_parts();
100 let total = i128::from(secs) * NANOS_PER_SECOND + i128::from(nanos);
101 #[expect(
102 clippy::cast_precision_loss,
103 reason = "single rounding of the combined nanosecond count to f64"
104 )]
105 let total_f64 = total as f64;
106 total_f64 / 1e9 - APPLE_EPOCH_UNIX_SECONDS
107 }
108
109 #[cfg(any(test, feature = "serde", feature = "xml"))]
114 pub(crate) fn parse_rfc3339(input: &str) -> Option<Self> {
115 let mut cursor = Cursor::new(input);
116 let year = cursor.fixed_digits(4)?;
117 cursor.literal(b'-')?;
118 let month = cursor.fixed_digits(2)?;
119 cursor.literal(b'-')?;
120 let day = cursor.fixed_digits(2)?;
121 cursor.literal(b'T')?;
122 let (hour, minute, second, nanos) = cursor.clock()?;
123 let offset_seconds = cursor.rfc3339_offset()?;
124 if !cursor.done() {
125 return None;
126 }
127 Self::from_civil(
128 year,
129 month,
130 day,
131 hour,
132 minute,
133 second,
134 nanos,
135 offset_seconds,
136 )
137 }
138
139 #[cfg(any(test, feature = "serde", feature = "xml"))]
143 pub(crate) fn format_rfc3339(self) -> String {
144 let (year, month, day, hour, minute, second) = self.civil_parts();
145 let year = format_year(year);
146 format!("{year}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
147 }
148
149 #[cfg(any(test, feature = "serde", feature = "openstep"))]
153 pub(crate) fn parse_text_layout(input: &str) -> Option<Self> {
154 let mut cursor = Cursor::new(input);
155 let year = cursor.fixed_digits(4)?;
156 cursor.literal(b'-')?;
157 let month = cursor.fixed_digits(2)?;
158 cursor.literal(b'-')?;
159 let day = cursor.fixed_digits(2)?;
160 cursor.literal(b' ')?;
161 let (hour, minute, second, nanos) = cursor.clock()?;
162 cursor.literal(b' ')?;
163 let offset_seconds = cursor.numeric_offset()?;
164 if !cursor.done() {
165 return None;
166 }
167 Self::from_civil(
168 year,
169 month,
170 day,
171 hour,
172 minute,
173 second,
174 nanos,
175 offset_seconds,
176 )
177 }
178
179 #[cfg(any(test, feature = "openstep"))]
183 pub(crate) fn format_text_layout(self) -> String {
184 let (year, month, day, hour, minute, second) = self.civil_parts();
185 let year = format_year(year);
186 format!("{year}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02} +0000")
187 }
188
189 #[cfg(any(test, feature = "serde", feature = "xml", feature = "openstep"))]
190 fn from_civil(
191 year: i64,
192 month: i64,
193 day: i64,
194 hour: i64,
195 minute: i64,
196 second: i64,
197 nanos: u32,
198 offset_seconds: i64,
199 ) -> Option<Self> {
200 let month = Month::try_from(u8::try_from(month).ok()?).ok()?;
201 let date = time::Date::from_calendar_date(
202 i32::try_from(year).ok()?,
203 month,
204 u8::try_from(day).ok()?,
205 )
206 .ok()?;
207 let clock = Time::from_hms_nano(
208 u8::try_from(hour).ok()?,
209 u8::try_from(minute).ok()?,
210 u8::try_from(second).ok()?,
211 nanos,
212 )
213 .ok()?;
214 let civil = PrimitiveDateTime::new(date, clock).assume_utc();
215 let unix_nanos =
216 civil.unix_timestamp_nanos() - i128::from(offset_seconds) * NANOS_PER_SECOND;
217 Some(Self::clamped(unix_nanos))
218 }
219
220 #[cfg(any(test, feature = "serde", feature = "xml", feature = "openstep"))]
221 fn civil_parts(self) -> (i32, u8, u8, u8, u8, u8) {
222 let date = self.0.date();
223 let clock = self.0.time();
224 (
225 date.year(),
226 u8::from(date.month()),
227 date.day(),
228 clock.hour(),
229 clock.minute(),
230 clock.second(),
231 )
232 }
233}
234
235impl From<SystemTime> for Date {
236 fn from(value: SystemTime) -> Self {
237 match value.duration_since(SystemTime::UNIX_EPOCH) {
238 Ok(after) => Self::clamped(i128::try_from(after.as_nanos()).unwrap_or(i128::MAX)),
239 Err(before) => {
240 let nanos = i128::try_from(before.duration().as_nanos()).unwrap_or(i128::MAX);
241 Self::clamped(-nanos)
242 }
243 }
244 }
245}
246
247impl From<Date> for SystemTime {
248 fn from(value: Date) -> Self {
249 let (secs, nanos) = value.unix_parts();
250 let result = if secs >= 0 {
251 Self::UNIX_EPOCH.checked_add(Duration::new(secs.cast_unsigned(), nanos))
252 } else if nanos == 0 {
253 Self::UNIX_EPOCH.checked_sub(Duration::new(secs.unsigned_abs(), 0))
254 } else {
255 let back = Duration::new(secs.unsigned_abs() - 1, 1_000_000_000 - nanos);
256 Self::UNIX_EPOCH.checked_sub(back)
257 };
258 result.unwrap_or(Self::UNIX_EPOCH)
260 }
261}
262
263#[cfg(any(test, feature = "serde", feature = "xml", feature = "openstep"))]
264fn format_year(year: i32) -> String {
265 if year < 0 {
266 format!("-{:04}", year.unsigned_abs())
267 } else {
268 format!("{year:04}")
269 }
270}
271
272#[cfg(any(test, feature = "serde", feature = "xml", feature = "openstep"))]
273struct Cursor<'a> {
274 bytes: &'a [u8],
275 pos: usize,
276}
277
278#[cfg(any(test, feature = "serde", feature = "xml", feature = "openstep"))]
279impl<'a> Cursor<'a> {
280 const fn new(input: &'a str) -> Self {
281 Self {
282 bytes: input.as_bytes(),
283 pos: 0,
284 }
285 }
286
287 fn literal(&mut self, expected: u8) -> Option<()> {
288 if self.bytes.get(self.pos) == Some(&expected) {
289 self.pos += 1;
290 Some(())
291 } else {
292 None
293 }
294 }
295
296 fn digit(&mut self) -> Option<i64> {
297 let &c = self.bytes.get(self.pos)?;
298 if c.is_ascii_digit() {
299 self.pos += 1;
300 Some(i64::from(c - b'0'))
301 } else {
302 None
303 }
304 }
305
306 fn fixed_digits(&mut self, count: u32) -> Option<i64> {
307 let mut value = 0;
308 for _ in 0..count {
309 value = value * 10 + self.digit()?;
310 }
311 Some(value)
312 }
313
314 fn one_or_two_digits(&mut self) -> Option<i64> {
316 let first = self.digit()?;
317 Some(self.digit().map_or(first, |second| first * 10 + second))
318 }
319
320 fn clock(&mut self) -> Option<(i64, i64, i64, u32)> {
323 let hour = self.one_or_two_digits()?;
324 self.literal(b':')?;
325 let minute = self.fixed_digits(2)?;
326 self.literal(b':')?;
327 let second = self.fixed_digits(2)?;
328 Some((hour, minute, second, self.fraction_nanos()))
329 }
330
331 fn fraction_nanos(&mut self) -> u32 {
332 if !matches!(self.bytes.get(self.pos), Some(b'.' | b',')) {
333 return 0;
334 }
335 if !self.bytes.get(self.pos + 1).is_some_and(u8::is_ascii_digit) {
336 return 0;
337 }
338 self.pos += 1;
339 let mut nanos: u32 = 0;
340 let mut digits = 0;
341 while let Some(&c) = self.bytes.get(self.pos) {
342 if !c.is_ascii_digit() {
343 break;
344 }
345 if digits < 9 {
346 nanos = nanos * 10 + u32::from(c - b'0');
347 digits += 1;
348 }
349 self.pos += 1;
350 }
351 while digits < 9 {
352 nanos *= 10;
353 digits += 1;
354 }
355 nanos
356 }
357
358 #[cfg(any(test, feature = "serde", feature = "xml"))]
360 fn rfc3339_offset(&mut self) -> Option<i64> {
361 if self.literal(b'Z').is_some() {
362 return Some(0);
363 }
364 let negative = self.sign()?;
365 let hours = self.fixed_digits(2)?;
366 self.literal(b':')?;
367 let minutes = self.fixed_digits(2)?;
368 Self::zone_seconds(hours, minutes, negative)
369 }
370
371 #[cfg(any(test, feature = "serde", feature = "openstep"))]
373 fn numeric_offset(&mut self) -> Option<i64> {
374 let negative = self.sign()?;
375 let hours = self.fixed_digits(2)?;
376 let minutes = self.fixed_digits(2)?;
377 Self::zone_seconds(hours, minutes, negative)
378 }
379
380 fn sign(&mut self) -> Option<bool> {
381 match self.bytes.get(self.pos) {
382 Some(b'+') => {
383 self.pos += 1;
384 Some(false)
385 }
386 Some(b'-') => {
387 self.pos += 1;
388 Some(true)
389 }
390 _ => None,
391 }
392 }
393
394 const fn zone_seconds(hours: i64, minutes: i64, negative: bool) -> Option<i64> {
396 if hours > 24 || minutes > 60 {
397 return None;
398 }
399 let seconds = (hours * 60 + minutes) * 60;
400 Some(if negative { -seconds } else { seconds })
401 }
402
403 const fn done(&self) -> bool {
404 self.pos == self.bytes.len()
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 #![expect(
411 clippy::unwrap_used,
412 clippy::float_cmp,
413 reason = "test code: unwrap is the assertion; float expectations are bit-exact"
414 )]
415
416 use super::*;
417
418 fn rfc3339(s: &str) -> Date {
419 Date::parse_rfc3339(s).unwrap()
420 }
421
422 #[test]
423 fn apple_epoch_round_trips_the_golden_fixture() {
424 let date = rfc3339("2013-11-27T00:34:00Z");
425 let encoded = date.to_apple_epoch();
426 assert_eq!(encoded, 407_205_240.0);
427 assert_eq!(encoded.to_bits(), 0x41B8_4575_7800_0000);
428 assert_eq!(Date::from_apple_epoch(407_205_240.0), date);
429 }
430
431 #[test]
432 fn apple_epoch_parse_truncates_fractional_nanos_toward_zero() {
433 let date = Date::from_apple_epoch(0.5);
434 assert_eq!(date.unix_parts(), (978_307_200, 500_000_000));
435
436 let date = Date::from_apple_epoch(-978_307_200.5);
439 assert_eq!(date.unix_parts(), (-1, 500_000_000));
440 }
441
442 #[test]
443 fn apple_epoch_parse_never_fails_and_clamps() {
444 let max = Date::from_apple_epoch(f64::INFINITY);
445 assert_eq!(max, Date(MAX_INSTANT));
446 assert_eq!(Date::from_apple_epoch(1e300), Date(MAX_INSTANT));
447 assert_eq!(Date::from_apple_epoch(f64::NEG_INFINITY), Date(MIN_INSTANT));
448 assert_eq!(Date::from_apple_epoch(-1e300), Date(MIN_INSTANT));
449 assert_eq!(Date::from_apple_epoch(f64::NAN).unix_parts(), (0, 0));
452 }
453
454 #[test]
455 fn apple_epoch_encode_rounds_through_the_nanosecond_intermediate() {
456 let date = Date::from_unix(1_385_512_440, 250_000_000);
459 assert_eq!(date.to_apple_epoch().to_bits(), 0x41B8_4575_783F_FFFC);
460 }
461
462 #[test]
463 fn rfc3339_parse_accepts_the_grammar() {
464 assert_eq!(
465 rfc3339("2013-11-27T00:34:00Z").unix_parts(),
466 (1_385_512_440, 0)
467 );
468 assert_eq!(
469 rfc3339("2013-11-27T00:34:00.5Z").unix_parts(),
470 (1_385_512_440, 500_000_000)
471 );
472 assert_eq!(
473 rfc3339("2013-11-27T00:34:00,5Z").unix_parts(),
474 (1_385_512_440, 500_000_000)
475 );
476 assert_eq!(
477 rfc3339("2013-11-27T1:34:00Z").unix_parts(),
478 (1_385_516_040, 0)
479 );
480 assert_eq!(
481 rfc3339("2013-11-27T00:34:00+07:00").unix_parts(),
482 (1_385_487_240, 0)
483 );
484 assert_eq!(
485 rfc3339("2013-11-27T00:34:00-00:30").unix_parts(),
486 (1_385_514_240, 0)
487 );
488 assert_eq!(
489 rfc3339("2013-11-27T00:34:00+24:00").unix_parts(),
490 (1_385_426_040, 0)
491 );
492 assert_eq!(
493 rfc3339("2013-11-27T00:34:00+23:60").unix_parts(),
494 (1_385_426_040, 0)
495 );
496 assert_eq!(
497 rfc3339("2013-11-27T00:34:00.123456789123Z").unix_parts(),
498 (1_385_512_440, 123_456_789)
499 );
500 assert_eq!(
501 rfc3339("0000-01-01T00:00:00+24:00").unix_parts(),
502 (-62_167_305_600, 0)
503 );
504 }
505
506 #[test]
507 fn rfc3339_parse_rejects_malformed_input() {
508 for s in [
509 "",
510 "2013-11-27t00:34:00Z",
511 "2013-11-27T00:34:00",
512 "2013-11-27T00:34:00z",
513 "2013-11-27T00:34:00+0700",
514 "2013-11-27T00:34:00+25:00",
515 "2013-02-30T00:34:00Z",
516 "12013-11-27T00:34:00Z",
517 "2013-11-27T00:34:60Z",
518 "2013-11-27T24:34:00Z",
519 "2013-1-27T00:34:00Z",
520 "2013-11-27T0:4:00Z",
521 "2013-11-27T00:34:00.Z",
522 "2013-11-27T00:34:00Z ",
523 "2013-13-01T00:00:00Z",
524 "2013-00-01T00:00:00Z",
525 "2013-11-00T00:00:00Z",
526 ] {
527 assert!(Date::parse_rfc3339(s).is_none(), "{s}");
528 }
529 }
530
531 #[test]
532 fn rfc3339_parse_clamps_past_the_calendar_edge() {
533 let date = rfc3339("9999-12-31T23:59:59-24:00");
536 assert_eq!(date, Date(MAX_INSTANT));
537 }
538
539 #[test]
540 fn rfc3339_format_is_utc_z_with_subseconds_dropped() {
541 assert_eq!(
542 rfc3339("2013-11-27T00:34:00Z").format_rfc3339(),
543 "2013-11-27T00:34:00Z"
544 );
545 assert_eq!(
546 rfc3339("2013-11-27T00:34:00.75Z").format_rfc3339(),
547 "2013-11-27T00:34:00Z"
548 );
549 assert_eq!(
550 rfc3339("2013-11-27T05:34:00+05:00").format_rfc3339(),
551 "2013-11-27T00:34:00Z"
552 );
553 assert_eq!(Date(MIN_INSTANT).format_rfc3339(), "-9999-01-01T00:00:00Z");
554 assert_eq!(
555 rfc3339("0001-02-03T04:05:06Z").format_rfc3339(),
556 "0001-02-03T04:05:06Z"
557 );
558 }
559
560 #[test]
561 fn text_layout_parse_accepts_the_grammar() {
562 let parse = Date::parse_text_layout;
563 assert_eq!(
564 parse("2013-11-27 00:34:00 +0000").unwrap().unix_parts(),
565 (1_385_512_440, 0)
566 );
567 assert_eq!(
568 parse("2013-11-27 0:34:00 +0000").unwrap().unix_parts(),
569 (1_385_512_440, 0)
570 );
571 assert_eq!(
572 parse("2013-11-27 00:34:00.25 +0000").unwrap().unix_parts(),
573 (1_385_512_440, 250_000_000)
574 );
575 assert_eq!(
576 parse("2013-11-27 00:34:00,5 +0000").unwrap().unix_parts(),
577 (1_385_512_440, 500_000_000)
578 );
579 assert_eq!(
580 parse("2013-11-27 00:34:00 -0500").unwrap().unix_parts(),
581 (1_385_530_440, 0)
582 );
583 assert_eq!(
584 parse("2013-11-27 00:34:00 +0060").unwrap().unix_parts(),
585 (1_385_508_840, 0)
586 );
587 }
588
589 #[test]
590 fn text_layout_parse_rejects_malformed_input() {
591 for s in [
592 "",
593 "2013-11-27 00:34:00 Z",
594 "2013-11-27 00:34:00 +00:00",
595 "2013-11-27 00:34:00",
596 "2013-11-27 00:34:00 +000",
597 "2013-11-27 00:34:00 +9900",
598 "2013-11-27 00:34:00 +2500",
599 "2013-11-27 00:34:00 +0061",
600 "2013-02-30 00:34:00 +0000",
601 "2013-11-27T00:34:00 +0000",
602 "2013-11-27 00:34:00 +0000 ",
603 ] {
604 assert!(Date::parse_text_layout(s).is_none(), "{s}");
605 }
606 }
607
608 #[test]
609 fn text_layout_format_is_utc_plus_zero_zero() {
610 let date = rfc3339("2013-11-27T00:34:00.9Z");
611 assert_eq!(date.format_text_layout(), "2013-11-27 00:34:00 +0000");
612 }
613
614 #[test]
615 fn text_and_rfc3339_round_trip_whole_second_dates() {
616 let date = Date::parse_text_layout("2013-11-27 05:34:00 -0500").unwrap();
617 assert_eq!(date.format_text_layout(), "2013-11-27 10:34:00 +0000");
618 assert_eq!(Date::parse_rfc3339(&date.format_rfc3339()).unwrap(), date);
619 assert_eq!(
620 Date::parse_text_layout(&date.format_text_layout()).unwrap(),
621 date
622 );
623 }
624
625 #[test]
626 fn system_time_conversions_round_trip() {
627 let after = SystemTime::UNIX_EPOCH + Duration::new(1_385_512_440, 123);
628 assert_eq!(SystemTime::from(Date::from(after)), after);
629
630 let before = SystemTime::UNIX_EPOCH - Duration::new(86_400, 250_000_000);
631 let date = Date::from(before);
632 assert_eq!(date.unix_parts(), (-86_401, 750_000_000));
633 assert_eq!(SystemTime::from(date), before);
634 }
635
636 #[test]
637 fn ordering_and_hashing_are_instant_based() {
638 let earlier = rfc3339("2013-11-27T00:34:00Z");
639 let later = rfc3339("2013-11-27T00:34:01Z");
640 assert!(earlier < later);
641 assert_eq!(rfc3339("2013-11-27T01:34:00+01:00"), earlier);
642 }
643
644 #[test]
645 fn from_unix_normalizes_negative_nanos() {
646 assert_eq!(
647 Date::from_unix(0, -500_000_000).unix_parts(),
648 (-1, 500_000_000)
649 );
650 assert_eq!(
651 Date::from_unix(1, 1_500_000_000).unix_parts(),
652 (2, 500_000_000)
653 );
654 assert_eq!(Date::from_unix(i64::MAX, i64::MAX), Date(MAX_INSTANT));
655 assert_eq!(Date::from_unix(i64::MIN, i64::MIN), Date(MIN_INSTANT));
656 }
657}