1use crate::sink::Sink;
10use crate::text::write_u64;
11
12const MAX_YEAR: i32 = 999_999;
15
16const DAY_MS: i64 = 86_400_000;
18
19#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
21pub struct Date {
22 year: i32,
23 month: u8,
24 day: u8,
25}
26
27const fn is_leap(year: i32) -> bool {
29 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
30}
31
32const fn month_days(year: i32, month: u8) -> u8 {
34 match month {
35 2 if is_leap(year) => 29,
36 2 => 28,
37 4 | 6 | 9 | 11 => 30,
38 _ => 31,
39 }
40}
41
42impl Date {
43 pub const fn new(year: i32, month: u8, day: u8) -> Option<Date> {
46 if year < -MAX_YEAR || year > MAX_YEAR || month < 1 || month > 12 {
47 return None;
48 }
49 if day < 1 || day > month_days(year, month) {
50 return None;
51 }
52 Some(Date { year, month, day })
53 }
54
55 pub const fn year(self) -> i32 {
57 self.year
58 }
59
60 pub const fn month(self) -> u8 {
62 self.month
63 }
64
65 pub const fn day(self) -> u8 {
67 self.day
68 }
69
70 pub const fn days_since_epoch(self) -> i64 {
72 let m = self.month as i64;
74 let y = self.year as i64 - if m <= 2 { 1 } else { 0 };
75 let era = (if y >= 0 { y } else { y - 399 }) / 400;
76 let yoe = y - era * 400;
77 let mp = if m > 2 { m - 3 } else { m + 9 };
78 let doy = (153 * mp + 2) / 5 + self.day as i64 - 1;
79 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
80 era * 146_097 + doe - 719_468
81 }
82
83 pub const fn from_days_since_epoch(days: i64) -> Option<Date> {
85 if days < -400_000_000 || days > 400_000_000 {
88 return None;
89 }
90 let z = days + 719_468;
91 let era = (if z >= 0 { z } else { z - 146_096 }) / 146_097;
92 let doe = z - era * 146_097;
93 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
94 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
95 let mp = (5 * doy + 2) / 153;
96 let day = doy - (153 * mp + 2) / 5 + 1;
97 let month = if mp < 10 { mp + 3 } else { mp - 9 };
98 let year = yoe + era * 400 + if month <= 2 { 1 } else { 0 };
99 if year < -(MAX_YEAR as i64) || year > MAX_YEAR as i64 {
100 return None;
101 }
102 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
104 let (year, month, day) = (year as i32, month as u8, day as u8);
105 Date::new(year, month, day)
106 }
107
108 pub const fn weekday(self) -> u8 {
110 let w = (self.days_since_epoch() + 3).rem_euclid(7);
112 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
113 let w = w as u8;
114 w + 1
115 }
116}
117
118#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
121pub struct Time {
122 hour: u8,
123 minute: u8,
124 second: u8,
125 millisecond: u16,
126}
127
128impl Time {
129 pub const MIDNIGHT: Time = Time {
131 hour: 0,
132 minute: 0,
133 second: 0,
134 millisecond: 0,
135 };
136
137 pub const fn new(hour: u8, minute: u8, second: u8, millisecond: u16) -> Option<Time> {
140 if hour > 23 || minute > 59 || second > 59 || millisecond > 999 {
141 return None;
142 }
143 Some(Time {
144 hour,
145 minute,
146 second,
147 millisecond,
148 })
149 }
150
151 pub const fn hour(self) -> u8 {
153 self.hour
154 }
155
156 pub const fn minute(self) -> u8 {
158 self.minute
159 }
160
161 pub const fn second(self) -> u8 {
163 self.second
164 }
165
166 pub const fn millisecond(self) -> u16 {
168 self.millisecond
169 }
170
171 pub const fn ms_of_day(self) -> i64 {
173 ((self.hour as i64 * 60 + self.minute as i64) * 60 + self.second as i64) * 1000
174 + self.millisecond as i64
175 }
176
177 pub const fn from_ms_of_day(ms: i64) -> Option<Time> {
179 if ms < 0 || ms >= DAY_MS {
180 return None;
181 }
182 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
184 let (h, m, s, milli) = (
185 (ms / 3_600_000) as u8,
186 (ms / 60_000 % 60) as u8,
187 (ms / 1000 % 60) as u8,
188 (ms % 1000) as u16,
189 );
190 Time::new(h, m, s, milli)
191 }
192}
193
194const MAX_OFFSET: i32 = 86_400;
196
197#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
202#[non_exhaustive]
203pub struct DateTime<'a> {
204 pub date: Date,
206 pub time: Time,
208 pub offset: Option<i32>,
212 pub zone: Option<&'a str>,
214 pub options: DateTimeOptions<'a>,
217}
218
219impl<'a> DateTime<'a> {
220 pub const fn floating(date: Date, time: Time) -> DateTime<'a> {
222 DateTime {
223 date,
224 time,
225 offset: None,
226 zone: None,
227 options: DateTimeOptions::NONE,
228 }
229 }
230
231 pub const fn from_epoch_ms(ms: i64) -> Option<DateTime<'a>> {
234 let Some(date) = Date::from_days_since_epoch(ms.div_euclid(DAY_MS)) else {
235 return None;
236 };
237 let Some(time) = Time::from_ms_of_day(ms.rem_euclid(DAY_MS)) else {
238 return None;
239 };
240 Some(DateTime {
241 date,
242 time,
243 offset: Some(0),
244 zone: None,
245 options: DateTimeOptions::NONE,
246 })
247 }
248
249 pub const fn to_epoch_ms(&self) -> Option<i64> {
251 match self.offset {
252 Some(o) => Some(
253 self.date.days_since_epoch() * DAY_MS + self.time.ms_of_day() - o as i64 * 1000,
254 ),
255 None => None,
256 }
257 }
258
259 pub const fn with_offset(self, seconds: i32) -> Option<Self> {
262 if seconds <= -MAX_OFFSET || seconds >= MAX_OFFSET {
263 return None;
264 }
265 Some(DateTime {
266 offset: Some(seconds),
267 ..self
268 })
269 }
270
271 #[must_use]
279 pub const fn in_zone(self, zone: &'a str) -> Self {
280 DateTime {
281 zone: Some(zone),
282 ..self
283 }
284 }
285
286 pub fn write_iso(&self, out: &mut dyn Sink) {
291 let y = self.date.year;
292 if (0..=9999).contains(&y) {
293 write_padded(y.unsigned_abs(), 4, out);
294 } else {
295 out.push_str(if y < 0 { "-" } else { "+" });
296 write_padded(y.unsigned_abs(), 6, out);
297 }
298 out.push_str("-");
299 write_padded(u32::from(self.date.month), 2, out);
300 out.push_str("-");
301 write_padded(u32::from(self.date.day), 2, out);
302 out.push_str("T");
303 write_padded(u32::from(self.time.hour), 2, out);
304 out.push_str(":");
305 write_padded(u32::from(self.time.minute), 2, out);
306 out.push_str(":");
307 write_padded(u32::from(self.time.second), 2, out);
308 if self.time.millisecond != 0 {
309 out.push_str(".");
310 write_padded(u32::from(self.time.millisecond), 3, out);
311 }
312 match self.offset {
313 Some(0) => out.push_str("Z"),
314 Some(o) => write_offset(o, out),
315 None => {}
316 }
317 if let Some(z) = self.zone {
318 out.push_str("[");
319 out.push_str(z);
320 out.push_str("]");
321 }
322 }
323}
324
325fn write_padded(n: u32, width: u32, out: &mut dyn Sink) {
327 let mut digits = 1;
328 let mut m = n;
329 while m >= 10 {
330 m /= 10;
331 digits += 1;
332 }
333 while digits < width {
334 out.push_str("0");
335 digits += 1;
336 }
337 write_u64(u64::from(n), out);
338}
339
340pub(crate) fn write_offset(seconds: i32, out: &mut dyn Sink) {
342 out.push_str(if seconds < 0 { "-" } else { "+" });
343 let s = seconds.unsigned_abs();
344 write_padded(s / 3600, 2, out);
345 out.push_str(":");
346 write_padded(s / 60 % 60, 2, out);
347 if !s.is_multiple_of(60) {
348 out.push_str(":");
349 write_padded(s % 60, 2, out);
350 }
351}
352
353#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default)]
357#[non_exhaustive]
358pub struct DateTimeOptions<'a> {
359 pub date: Option<DateStyle>,
361 pub time: Option<TimePrecision>,
363 pub time_zone_style: Option<ZoneStyle>,
365 pub time_zone: Option<ZoneOption<'a>>,
367 pub hour12: Option<bool>,
369 pub calendar: Option<&'a str>,
371}
372
373impl DateTimeOptions<'_> {
374 pub const NONE: DateTimeOptions<'static> = DateTimeOptions {
376 date: None,
377 time: None,
378 time_zone_style: None,
379 time_zone: None,
380 hour12: None,
381 calendar: None,
382 };
383
384 pub const fn is_resolved(&self) -> bool {
387 self.date.is_some() || self.time.is_some()
388 }
389}
390
391#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
393pub struct DateStyle {
394 pub fields: DateFields,
396 pub length: DateLength,
398}
399
400#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
402#[non_exhaustive]
403pub enum DateFields {
404 Weekday,
406 DayWeekday,
408 MonthDay,
410 MonthDayWeekday,
412 YearMonthDay,
414 YearMonthDayWeekday,
416}
417
418#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
420#[non_exhaustive]
421pub enum DateLength {
422 Long,
424 Medium,
426 Short,
428}
429
430#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
432#[non_exhaustive]
433pub enum TimePrecision {
434 Hour,
436 Minute,
438 Second,
440}
441
442#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
444#[non_exhaustive]
445pub enum ZoneStyle {
446 Long,
448 Short,
450}
451
452#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
454#[non_exhaustive]
455pub enum ZoneOption<'a> {
456 Input,
458 Utc,
460 Offset(i32),
462 Named(&'a str),
467}
468
469const MAX_ZONE_NAME: usize = 64;
472
473#[derive(Clone, Copy, PartialEq, Eq, Hash)]
477pub struct TimeZone {
478 repr: Repr,
479}
480
481#[derive(Clone, Copy, PartialEq, Eq, Hash)]
482enum Repr {
483 Utc,
484 Offset(i32),
485 Named {
491 len: u8,
492 name: [u8; MAX_ZONE_NAME],
493 },
494}
495
496const RULE: u8 = 0xFF;
498
499impl TimeZone {
500 pub const UTC: TimeZone = TimeZone { repr: Repr::Utc };
502
503 pub const fn offset(seconds: i32) -> Option<TimeZone> {
506 if seconds <= -MAX_OFFSET || seconds >= MAX_OFFSET {
507 return None;
508 }
509 Some(TimeZone {
510 repr: Repr::Offset(seconds),
511 })
512 }
513
514 pub fn named(name: &str) -> Option<TimeZone> {
517 if !is_zone_name(name) || name.len() > MAX_ZONE_NAME {
518 return None;
519 }
520 let mut buf = [0u8; MAX_ZONE_NAME];
521 for (b, &c) in buf.iter_mut().zip(name.as_bytes()) {
522 *b = c;
523 }
524 Some(TimeZone {
525 repr: Repr::Named {
526 len: u8::try_from(name.len()).ok()?,
527 name: buf,
528 },
529 })
530 }
531
532 pub fn rules(rule: &str) -> Option<TimeZone> {
545 if rule.is_empty()
546 || rule.len() >= MAX_ZONE_NAME
547 || !rule.bytes().all(|b| b.is_ascii_graphic())
548 {
549 return None;
550 }
551 let mut name = [0u8; MAX_ZONE_NAME];
552 name.get_mut(..rule.len())?.copy_from_slice(rule.as_bytes());
553 if let Some(last) = name.last_mut() {
554 *last = RULE;
555 }
556 Some(TimeZone {
557 repr: Repr::Named {
558 len: u8::try_from(rule.len()).ok()?,
559 name,
560 },
561 })
562 }
563
564 pub fn as_option(&self) -> ZoneOption<'_> {
567 match &self.repr {
568 Repr::Utc => ZoneOption::Utc,
569 Repr::Offset(s) => ZoneOption::Offset(*s),
570 Repr::Named { len, name } => ZoneOption::Named(
571 name.get(..usize::from(*len))
572 .and_then(|b| core::str::from_utf8(b).ok())
573 .unwrap_or(""),
574 ),
575 }
576 }
577
578 fn rule(&self) -> Option<&str> {
580 match &self.repr {
581 Repr::Named { len, name } if name.last() == Some(&RULE) => name
582 .get(..usize::from(*len))
583 .and_then(|b| core::str::from_utf8(b).ok()),
584 _ => None,
585 }
586 }
587}
588
589impl core::fmt::Debug for TimeZone {
592 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
593 if let Some(rule) = self.rule() {
594 return f
595 .write_str("TimeZone(Rules(")
596 .and_then(|()| core::fmt::Debug::fmt(rule, f))
597 .and_then(|()| f.write_str("))"));
598 }
599 match self.as_option() {
600 ZoneOption::Named(n) => f.debug_tuple("TimeZone").field(&n).finish(),
601 other => f.debug_tuple("TimeZone").field(&other).finish(),
602 }
603 }
604}
605
606impl Default for TimeZone {
607 fn default() -> Self {
608 TimeZone::UTC
609 }
610}
611
612pub fn is_zone_name(s: &str) -> bool {
616 let b = s.as_bytes();
617 if b.is_empty() {
618 return false;
619 }
620 let mut start = 0;
621 let mut i = 0;
622 while i <= b.len() {
623 let end = i == b.len();
624 if end || b.get(i) == Some(&b'/') {
625 let part = b.get(start..i).unwrap_or(&[]);
626 if !zone_part(part) {
627 return false;
628 }
629 start = i + 1;
630 }
631 i += 1;
632 }
633 true
634}
635
636fn zone_part(p: &[u8]) -> bool {
637 let initial = |c: u8| c.is_ascii_alphabetic() || c == b'.' || c == b'_';
638 match p {
639 [] | [b'.'] | [b'.', b'.'] => false,
640 [first, rest @ ..] => {
641 initial(*first)
642 && rest
643 .iter()
644 .all(|&c| initial(c) || c.is_ascii_digit() || c == b'-' || c == b'+')
645 }
646 }
647}
648
649#[derive(Clone, Copy, Debug)]
652#[non_exhaustive]
653pub struct DateTimeRequest<'r> {
654 pub epoch_ms: i64,
657 pub zone: ZoneOption<'r>,
659 pub options: &'r DateTimeOptions<'r>,
662}
663
664impl<'r> DateTimeRequest<'r> {
665 pub const fn new(
668 epoch_ms: i64,
669 zone: ZoneOption<'r>,
670 options: &'r DateTimeOptions<'r>,
671 ) -> DateTimeRequest<'r> {
672 DateTimeRequest {
673 epoch_ms,
674 zone,
675 options,
676 }
677 }
678}
679
680#[cfg(test)]
681#[allow(clippy::unwrap_used)]
682mod tests {
683 use super::*;
684 use alloc::string::String;
685
686 #[test]
687 fn civil_days_round_trip() {
688 for days in [-719_468i64, -1, 0, 1, 10_957, 13_150, 2_932_896, -2_932_897] {
689 let d = Date::from_days_since_epoch(days).unwrap();
690 assert_eq!(d.days_since_epoch(), days, "{d:?}");
691 }
692 let d = Date::new(2006, 1, 2).unwrap();
693 assert_eq!(d.days_since_epoch(), 13_150);
694 assert_eq!(d.weekday(), 1); assert_eq!(Date::new(1970, 1, 1).unwrap().weekday(), 4);
696 assert!(Date::new(2023, 2, 29).is_none());
697 assert!(Date::new(2024, 2, 29).is_some());
698 assert!(Date::new(1900, 2, 29).is_none());
699 assert!(Date::new(2000, 2, 29).is_some());
700 assert!(Date::new(1_000_000, 1, 1).is_none());
701 }
702
703 #[test]
704 fn epoch_round_trip() {
705 for ms in [
706 0i64,
707 1,
708 -1,
709 1_136_214_246_000,
710 -62_135_596_800_000,
711 253_402_300_799_999,
712 ] {
713 let dt = DateTime::from_epoch_ms(ms).unwrap();
714 assert_eq!(dt.to_epoch_ms(), Some(ms));
715 }
716 let dt = DateTime::from_epoch_ms(1_136_214_246_000).unwrap();
717 let mut s = String::new();
718 dt.write_iso(&mut s);
719 assert_eq!(s, "2006-01-02T15:04:06Z");
720 let dt = dt.with_offset(-7 * 3600).unwrap().in_zone("America/Denver");
721 s.clear();
722 dt.write_iso(&mut s);
723 assert_eq!(s, "2006-01-02T15:04:06-07:00[America/Denver]");
724 assert_eq!(dt.to_epoch_ms(), Some(1_136_214_246_000 + 7 * 3_600_000));
725 assert!(DateTime::from_epoch_ms(i64::MAX).is_none());
726 assert!(dt.with_offset(86_400).is_none());
727 }
728
729 #[test]
730 fn iso_text() {
731 let mut s = String::new();
732 let dt = DateTime::floating(
733 Date::new(-44, 3, 15).unwrap(),
734 Time::new(9, 5, 0, 7).unwrap(),
735 );
736 dt.write_iso(&mut s);
737 assert_eq!(s, "-000044-03-15T09:05:00.007");
738 }
739
740 #[test]
741 fn zones() {
742 for good in [
743 "UTC",
744 "America/New_York",
745 "Etc/GMT+5",
746 "America/Argentina/ComodRivadavia",
747 "._x",
748 ] {
749 assert!(is_zone_name(good), "{good}");
750 let z = TimeZone::named(good).unwrap();
751 assert_eq!(z.as_option(), ZoneOption::Named(good));
752 }
753 for bad in ["", "/", "a/", "/a", "a//b", ".", "a/..", "1a", "a b", "é"] {
754 assert!(!is_zone_name(bad), "{bad}");
755 assert!(TimeZone::named(bad).is_none());
756 }
757 assert_eq!(TimeZone::default().as_option(), ZoneOption::Utc);
758 assert_eq!(
759 TimeZone::offset(3600).unwrap().as_option(),
760 ZoneOption::Offset(3600)
761 );
762 assert!(TimeZone::offset(-86_400).is_none());
763 }
764
765 #[test]
768 fn rules() {
769 let rule = "EST5EDT,M3.2.0,M11.1.0";
770 assert!(!is_zone_name(rule));
771 let z = TimeZone::rules(rule).unwrap();
772 assert_eq!(z.as_option(), ZoneOption::Named(rule));
773 assert_ne!(Some(z), TimeZone::named("EST5EDT"));
774 let mut shown = String::new();
775 core::fmt::write(&mut shown, format_args!("{z:?}")).unwrap();
776 assert_eq!(shown, "TimeZone(Rules(\"EST5EDT,M3.2.0,M11.1.0\"))");
777 let longest = "<+0330>-3:30<+0430>,J79/24,J263/24-and-then-some-to-sixty-three";
778 assert_eq!(longest.len(), 63);
779 assert_eq!(
780 TimeZone::rules(longest).unwrap().as_option(),
781 ZoneOption::Named(longest)
782 );
783 for bad in [
784 "",
785 "EST 5",
786 "CET-1CEST,M3.5.0,M10.5.0/3\n",
787 "é",
788 &"x".repeat(64),
789 ] {
790 assert!(TimeZone::rules(bad).is_none(), "{bad}");
791 }
792 let name = "a".repeat(64);
794 let mut shown = String::new();
795 core::fmt::write(
796 &mut shown,
797 format_args!("{:?}", TimeZone::named(&name).unwrap()),
798 )
799 .unwrap();
800 let mut expected = String::new();
801 core::fmt::write(&mut expected, format_args!("TimeZone({name:?})")).unwrap();
802 assert_eq!(shown, expected);
803 }
804}