1use core::fmt::{Display, Formatter};
2
3use thiserror::Error;
4
5use crate::{
6 Duration, String,
7 common::{DateTime, TimeZone, date_time::TimeOffset},
8};
9
10impl Display for TimeZone {
11 fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
12 write!(f, "{}", self.id)
13 }
14}
15
16impl Display for DateTime {
17 fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
18 if self.year != 0 {
19 write!(f, "{:04}-", self.year)?;
20 }
21 write!(f, "{:02}-{:02}", self.month, self.day)?;
22
23 write!(
24 f,
25 "T{:02}:{:02}:{:02}",
26 self.hours, self.minutes, self.seconds
27 )?;
28
29 if let Some(TimeOffset::UtcOffset(duration)) = &self.time_offset {
30 let total_offset_seconds = duration.normalized().seconds;
31 let is_negative = total_offset_seconds < 0;
32 let abs_total_offset_seconds = total_offset_seconds.abs();
33
34 let hours = abs_total_offset_seconds / 3600;
35 let minutes = (abs_total_offset_seconds % 3600) / 60;
36
37 if is_negative {
38 write!(f, "-{hours:02}:{minutes:02}")?
39 } else if total_offset_seconds == 0 && duration.nanos == 0 {
40 write!(f, "Z")? } else {
42 write!(f, "+{hours:02}:{minutes:02}")?
43 }
44 }
45 Ok(())
46 }
47}
48
49#[derive(Debug, Error, PartialEq, Eq, Clone)]
51#[non_exhaustive]
52pub enum DateTimeError {
53 #[error(
54 "The year must be a value from 0 (to indicate a DateTime with no specific year) to 9999"
55 )]
56 InvalidYear,
57 #[error("If the year is set to 0, month and day cannot be set to 0")]
58 InvalidDate,
59 #[error("Invalid month value (must be within 1 and 12)")]
60 InvalidMonth,
61 #[error("Invalid day value (must be within 1 and 31)")]
62 InvalidDay,
63 #[error("Invalid hours value (must be within 0 and 23)")]
64 InvalidHours,
65 #[error("Invalid minutes value (must be within 0 and 59)")]
66 InvalidMinutes,
67 #[error("Invalid seconds value (must be within 0 and 59)")]
68 InvalidSeconds,
69 #[error("Invalid nanos value (must be within 0 and 999.999.999)")]
70 InvalidNanos,
71 #[error(
72 "DateTime has an invalid time component (e.g., hours, minutes, seconds, nanos out of range)"
73 )]
74 InvalidTime,
75 #[error("DateTime arithmetic resulted in a time outside its representable range")]
76 OutOfRange,
77 #[error("DateTime conversion error: {0}")]
78 ConversionError(String),
79}
80
81impl PartialOrd for TimeOffset {
82 #[inline]
83 fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
84 match (self, other) {
85 (Self::UtcOffset(a), Self::UtcOffset(b)) => a.partial_cmp(b),
86 (Self::TimeZone(_) | Self::UtcOffset(_), Self::TimeZone(_))
88 | (Self::TimeZone(_), Self::UtcOffset(_)) => None,
89 }
90 }
91}
92
93impl PartialOrd for DateTime {
94 fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
95 if !(self.is_valid() && other.is_valid()) {
96 return None;
97 }
98
99 if (self.year == 0 && other.year != 0) || (self.year != 0 && other.year == 0) {
100 return None;
101 }
102
103 let ord = self
104 .year
105 .cmp(&other.year)
106 .then_with(|| self.month.cmp(&other.month))
107 .then_with(|| self.day.cmp(&other.day))
108 .then_with(|| self.hours.cmp(&other.hours))
109 .then_with(|| self.minutes.cmp(&other.minutes))
110 .then_with(|| self.seconds.cmp(&other.seconds))
111 .then_with(|| self.nanos.cmp(&other.nanos));
112
113 if ord != core::cmp::Ordering::Equal {
114 return Some(ord);
115 }
116
117 self.time_offset.partial_cmp(&other.time_offset)
118 }
119}
120
121#[allow(clippy::too_many_arguments)]
122fn datetime_is_valid(
123 year: i32,
124 month: i32,
125 day: i32,
126 hours: i32,
127 minutes: i32,
128 seconds: i32,
129 nanos: i32,
130) -> Result<(), DateTimeError> {
131 if !(0..=9999).contains(&year) {
132 return Err(DateTimeError::InvalidYear);
133 }
134 if !(1..=12).contains(&month) {
135 return Err(DateTimeError::InvalidMonth);
136 }
137 let max_days = crate::date::days_in_month(month, year);
138 if !(1..=max_days).contains(&day) {
139 return Err(DateTimeError::InvalidDay);
140 }
141
142 if year == 0 && (day == 0 || month == 0) {
143 return Err(DateTimeError::InvalidDate);
144 }
145
146 if !(0..=23).contains(&hours) {
147 return Err(DateTimeError::InvalidHours);
148 }
149 if !(0..=59).contains(&minutes) {
150 return Err(DateTimeError::InvalidMinutes);
151 }
152 if !(0..=59).contains(&seconds) {
153 return Err(DateTimeError::InvalidSeconds);
154 }
155 if !(0..=999_999_999).contains(&nanos) {
156 return Err(DateTimeError::InvalidNanos);
157 }
158
159 Ok(())
160}
161
162impl DateTime {
163 pub fn validate(&self) -> Result<(), DateTimeError> {
165 datetime_is_valid(
166 self.year,
167 self.month,
168 self.day,
169 self.hours,
170 self.minutes,
171 self.seconds,
172 self.nanos,
173 )
174 }
175
176 #[must_use]
177 #[inline]
178 pub fn is_valid(&self) -> bool {
180 self.validate().is_ok()
181 }
182
183 #[must_use]
184 #[inline]
185 pub const fn has_year(&self) -> bool {
187 self.year != 0
188 }
189
190 #[must_use]
192 #[inline]
193 pub const fn has_utc_offset(&self) -> bool {
194 matches!(self.time_offset, Some(TimeOffset::UtcOffset(_)))
195 }
196
197 #[must_use]
199 #[inline]
200 pub const fn has_timezone(&self) -> bool {
201 matches!(self.time_offset, Some(TimeOffset::TimeZone(_)))
202 }
203
204 #[must_use]
206 #[inline]
207 pub const fn is_local(&self) -> bool {
208 self.time_offset.is_none()
209 }
210
211 #[must_use]
213 #[inline]
214 pub fn with_utc_offset(mut self, offset: Duration) -> Self {
215 self.time_offset = Some(TimeOffset::UtcOffset(offset));
216 self
217 }
218
219 #[must_use]
221 #[inline]
222 pub fn with_time_zone(mut self, time_zone: TimeZone) -> Self {
223 self.time_offset = Some(TimeOffset::TimeZone(time_zone));
224 self
225 }
226}
227
228pub const UTC_OFFSET: Duration = Duration {
229 seconds: 0,
230 nanos: 0,
231};
232
233#[cfg(feature = "chrono")]
234mod chrono_impls {
235 use chrono::Utc;
236
237 use super::{DateTime, DateTimeError};
238 use crate::{Duration, String, ToString, date_time::TimeOffset, datetime::UTC_OFFSET, format};
239
240 impl DateTime {
241 #[cfg(any(feature = "std", feature = "chrono-wasm"))]
242 #[must_use]
244 pub fn now_utc() -> Self {
245 Utc::now().into()
246 }
247
248 pub fn to_datetime_utc(self) -> Result<chrono::DateTime<chrono::Utc>, DateTimeError> {
251 self.try_into()
252 }
253
254 pub fn to_fixed_offset_datetime(
257 self,
258 ) -> Result<chrono::DateTime<chrono::FixedOffset>, DateTimeError> {
259 self.try_into()
260 }
261
262 #[cfg(feature = "chrono-tz")]
263 pub fn to_datetime_with_tz(self) -> Result<chrono::DateTime<chrono_tz::Tz>, DateTimeError> {
266 self.try_into()
267 }
268 }
269
270 impl TryFrom<DateTime> for chrono::DateTime<chrono::FixedOffset> {
274 type Error = DateTimeError;
275 fn try_from(value: DateTime) -> Result<Self, Self::Error> {
276 use crate::date_time::TimeOffset;
277
278 match &value.time_offset {
279 Some(TimeOffset::UtcOffset(proto_duration)) => {
280 use crate::constants::NANOS_PER_SECOND;
281
282 let total_nanos_i128 = i128::from(proto_duration.seconds)
283 .checked_mul(i128::from(NANOS_PER_SECOND))
284 .ok_or(DateTimeError::ConversionError(
285 "UtcOffset seconds multiplied by NANOS_PER_SECOND overflowed i128"
286 .to_string(),
287 ))?
288 .checked_add(i128::from(proto_duration.nanos))
289 .ok_or(DateTimeError::ConversionError(
290 "UtcOffset nanos addition overflowed i128".to_string(),
291 ))?;
292
293 let total_seconds_i128 = total_nanos_i128
294 .checked_div(i128::from(NANOS_PER_SECOND))
295 .ok_or(DateTimeError::ConversionError(
296 "UtcOffset total nanoseconds division overflowed i128 (should not happen)"
297 .to_string(),
298 ))?; let total_seconds_i32: i32 = total_seconds_i128.try_into().map_err(|_| {
301 DateTimeError::ConversionError(
302 "UtcOffset total seconds is outside of i32 range for FixedOffset"
303 .to_string(),
304 )
305 })?;
306
307 let offset = chrono::FixedOffset::east_opt(total_seconds_i32).ok_or_else(|| {
308 DateTimeError::ConversionError(
309 "Failed to convert proto::Duration to chrono::FixedOffset due to invalid offset values"
310 .to_string(),
311 )
312 })?;
313
314 let naive_dt: chrono::NaiveDateTime = value.try_into()?;
315
316 naive_dt
317 .and_local_timezone(offset)
318 .single() .ok_or(DateTimeError::ConversionError(
320 "Ambiguous or invalid local time to FixedOffset conversion".to_string(),
321 ))
322 }
323 Some(TimeOffset::TimeZone(tz_info)) => {
324 #[cfg(feature = "chrono-tz")]
325 {
326 use chrono::{Offset, TimeZone};
327 use core::str::FromStr;
328
329 let tz = chrono_tz::Tz::from_str(&tz_info.id).map_err(|_| {
331 DateTimeError::ConversionError(format!(
332 "Unknown TimeZone ID: {}",
333 tz_info.id
334 ))
335 })?;
336
337 let naive_dt: chrono::NaiveDateTime = value.try_into()?;
338
339 let dt_with_tz = tz.from_local_datetime(&naive_dt).single().ok_or(
342 DateTimeError::ConversionError(
343 "Ambiguous or invalid time for this timezone (DST gap/overlap)"
344 .into(),
345 ),
346 )?;
347
348 Ok(dt_with_tz.with_timezone(&dt_with_tz.offset().fix()))
351 }
352
353 #[cfg(not(feature = "chrono-tz"))]
354 {
355 Err(DateTimeError::ConversionError(
356 "Enable the 'chrono-tz' feature to convert named TimeZones to FixedOffset"
357 .to_string(),
358 ))
359 }
360 }
361 None => Err(DateTimeError::ConversionError(
362 "Cannot convert local DateTime (no offset) to FixedOffset. \
363 If you intended UTC, use .with_utc_offset() first."
364 .to_string(),
365 )),
366 }
367 }
368 }
369
370 impl From<chrono::NaiveDateTime> for DateTime {
373 #[inline]
374 fn from(ndt: chrono::NaiveDateTime) -> Self {
375 use chrono::{Datelike, Timelike};
376
377 Self {
380 year: ndt.year(),
381 month: ndt.month().cast_signed(),
382 day: ndt.day().cast_signed(),
383 hours: ndt.hour().cast_signed(),
384 minutes: ndt.minute().cast_signed(),
385 seconds: ndt.second().cast_signed(),
386 nanos: ndt.nanosecond().cast_signed(),
387 time_offset: None,
388 }
389 }
390 }
391
392 impl TryFrom<DateTime> for chrono::NaiveDateTime {
393 type Error = DateTimeError;
394
395 fn try_from(dt: DateTime) -> Result<Self, Self::Error> {
396 if dt.year == 0 {
398 return Err(DateTimeError::ConversionError(
399 "Cannot convert DateTime with year 0 to NaiveDateTime".to_string(),
400 ));
401 }
402
403 dt.validate()?;
404
405 let date = chrono::NaiveDate::from_ymd_opt(
407 dt.year,
408 dt.month.cast_unsigned(),
409 dt.day.cast_unsigned(),
410 )
411 .ok_or(DateTimeError::InvalidDate)?;
412 let time = chrono::NaiveTime::from_hms_nano_opt(
413 dt.hours.cast_unsigned(),
414 dt.minutes.cast_unsigned(),
415 dt.seconds.cast_unsigned(),
416 dt.nanos.cast_unsigned(),
417 )
418 .ok_or(DateTimeError::InvalidTime)?;
419
420 Ok(Self::new(date, time))
421 }
422 }
423
424 impl From<chrono::DateTime<chrono::Utc>> for DateTime {
427 #[inline]
428 fn from(value: chrono::DateTime<chrono::Utc>) -> Self {
429 use chrono::{Datelike, Timelike};
430
431 use crate::date_time::TimeOffset;
432 Self {
434 year: value.year(),
435 month: value.month().cast_signed(),
436 day: value.day().cast_signed(),
437 hours: value.hour().cast_signed(),
438 minutes: value.minute().cast_signed(),
439 seconds: value.second().cast_signed(),
440 nanos: value.nanosecond().cast_signed(),
441 time_offset: Some(TimeOffset::UtcOffset(Duration::new(0, 0))),
442 }
443 }
444 }
445
446 impl TryFrom<DateTime> for chrono::DateTime<chrono::Utc> {
447 type Error = DateTimeError;
448 fn try_from(value: DateTime) -> Result<Self, Self::Error> {
449 match &value.time_offset {
450 Some(TimeOffset::UtcOffset(proto_duration)) => {
451 if *proto_duration != UTC_OFFSET {
452 return Err(DateTimeError::ConversionError(
453 "Cannot convert DateTime to TimeZone<Utc> when the UtcOffset is not 0."
454 .to_string(),
455 ));
456 }
457 }
458 Some(TimeOffset::TimeZone(_)) | None => {
459 return Err(DateTimeError::ConversionError(
460 "Cannot convert DateTime to TimeZone<Utc> when a UtcOffset is not set."
461 .to_string(),
462 ));
463 }
464 };
465
466 let naive_dt: chrono::NaiveDateTime = value.try_into()?;
467
468 Ok(naive_dt.and_utc())
469 }
470 }
471
472 #[cfg(feature = "chrono-tz")]
473 impl From<chrono_tz::Tz> for super::TimeZone {
474 fn from(value: chrono_tz::Tz) -> Self {
475 Self {
476 id: value.to_string(),
477 version: String::new(), }
479 }
480 }
481
482 #[cfg(feature = "chrono-tz")]
485 impl From<chrono::DateTime<chrono_tz::Tz>> for DateTime {
486 fn from(value: chrono::DateTime<chrono_tz::Tz>) -> Self {
487 use chrono::{Datelike, Timelike};
488
489 Self {
490 year: value.year(),
491 month: value.month().cast_signed(),
492 day: value.day().cast_signed(),
493 hours: value.hour().cast_signed(),
494 minutes: value.minute().cast_signed(),
495 seconds: value.second().cast_signed(),
496 nanos: value.nanosecond().cast_signed(),
497 time_offset: Some(TimeOffset::TimeZone(super::TimeZone {
498 id: value.timezone().to_string(),
499 version: String::new(), })),
501 }
502 }
503 }
504
505 #[cfg(feature = "chrono-tz")]
506 impl TryFrom<DateTime> for chrono::DateTime<chrono_tz::Tz> {
507 type Error = DateTimeError;
508
509 fn try_from(value: crate::DateTime) -> Result<Self, Self::Error> {
510 use core::str::FromStr;
511
512 use chrono::{NaiveDateTime, TimeZone};
513 use chrono_tz::Tz;
514
515 let timezone = match &value.time_offset {
516 Some(TimeOffset::UtcOffset(proto_duration)) => {
517 if *proto_duration == UTC_OFFSET {
518 Tz::UTC
519 } else {
520 return Err(DateTimeError::ConversionError(
521 "Cannot convert non-zero UtcOffset to a named TimeZone (Tz)"
522 .to_string(),
523 ));
524 }
525 }
526 Some(TimeOffset::TimeZone(tz_name)) => Tz::from_str(&tz_name.id).map_err(|_| {
528 DateTimeError::ConversionError(format!(
529 "Unrecognized or invalid timezone name: {}",
530 tz_name.id
531 ))
532 })?,
533 None => {
534 return Err(DateTimeError::ConversionError(
535 "Cannot convert local DateTime to named TimeZone (Tz) without explicit offset or name"
536 .to_string(),
537 ));
538 }
539 };
540
541 let naive_dt: NaiveDateTime = value.try_into()?;
542
543 timezone
544 .from_local_datetime(&naive_dt)
545 .single()
546 .ok_or(DateTimeError::ConversionError(
547 "Ambiguous or invalid local time to named TimeZone (Tz) conversion".to_string(),
548 ))
549 }
550 }
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556 use crate::Duration;
557 use alloc::string::ToString;
558
559 fn dt(y: i32, m: i32, d: i32, h: i32, min: i32, s: i32, n: i32) -> DateTime {
560 DateTime {
561 year: y,
562 month: m,
563 day: d,
564 hours: h,
565 minutes: min,
566 seconds: s,
567 nanos: n,
568 time_offset: None,
569 }
570 }
571
572 #[test]
573 fn test_display_formatting() {
574 let d = dt(2024, 1, 15, 12, 30, 45, 0);
576 assert_eq!(d.to_string(), "2024-01-15T12:30:45");
577
578 let no_year = dt(0, 12, 25, 8, 0, 0, 0);
580 assert_eq!(no_year.to_string(), "12-25T08:00:00");
581
582 let mut utc_plus = d.clone();
584 utc_plus.time_offset = Some(TimeOffset::UtcOffset(Duration {
585 seconds: 3600,
586 nanos: 0,
587 })); assert_eq!(utc_plus.to_string(), "2024-01-15T12:30:45+01:00");
589
590 let mut utc_minus = d.clone();
592 utc_minus.time_offset = Some(TimeOffset::UtcOffset(Duration {
593 seconds: -5400,
594 nanos: 0,
595 })); assert_eq!(utc_minus.to_string(), "2024-01-15T12:30:45-01:30");
597
598 let mut utc_z = d.clone();
600 utc_z.time_offset = Some(TimeOffset::UtcOffset(Duration {
601 seconds: 0,
602 nanos: 0,
603 }));
604 assert_eq!(utc_z.to_string(), "2024-01-15T12:30:45Z");
605
606 let mut named = d;
608 named = named.with_time_zone(TimeZone {
609 id: "America/New_York".into(),
610 version: String::new(),
611 });
612 assert_eq!(named.to_string(), "2024-01-15T12:30:45");
613 }
614
615 #[test]
616 fn test_validation() {
617 assert!(dt(2024, 13, 1, 0, 0, 0, 0).validate().is_err()); assert!(dt(2024, 1, 1, 24, 0, 0, 0).validate().is_err()); assert!(dt(2023, 2, 29, 12, 0, 0, 0).validate().is_err()); assert!(dt(2024, 2, 29, 12, 0, 0, 0).validate().is_ok()); assert!(dt(0, 1, 1, 0, 0, 0, 0).validate().is_ok());
627 assert!(dt(0, 0, 1, 0, 0, 0, 0).validate().is_err()); }
629
630 #[test]
631 fn test_partial_ord() {
632 let d1 = dt(2024, 1, 1, 10, 0, 0, 0);
633 let d2 = dt(2024, 1, 1, 11, 0, 0, 0);
634
635 assert!(d1 < d2);
636
637 let d_year0 = dt(0, 1, 1, 10, 0, 0, 0);
639 assert_eq!(d1.partial_cmp(&d_year0), None);
640 }
641
642 #[cfg(feature = "chrono")]
643 mod chrono_tests {
644 use super::*;
645 use chrono::{Datelike, Timelike};
646
647 #[test]
648 fn test_to_naive_datetime() {
649 let d = dt(2024, 5, 20, 10, 30, 0, 500);
650 let naive: chrono::NaiveDateTime = d.try_into().unwrap();
651
652 assert_eq!(naive.year(), 2024);
653 assert_eq!(naive.hour(), 10);
654 assert_eq!(naive.nanosecond(), 500);
655 }
656
657 #[test]
658 fn test_to_fixed_offset() {
659 let mut d = dt(2024, 5, 20, 10, 0, 0, 0);
660 d = d.with_utc_offset(Duration {
662 seconds: 3600,
663 nanos: 0,
664 });
665
666 let fixed: chrono::DateTime<chrono::FixedOffset> = d.try_into().unwrap();
667
668 assert_eq!(fixed.hour(), 10);
670 assert_eq!(fixed.offset().local_minus_utc(), 3600);
671 }
672
673 #[cfg(feature = "chrono-tz")]
674 #[test]
675 fn test_to_tz() {
676 use chrono_tz::US::Pacific;
677 let mut d = dt(2024, 1, 1, 12, 0, 0, 0);
678 d = d.with_time_zone(TimeZone {
679 id: "US/Pacific".into(),
680 version: String::new(),
681 });
682
683 let tz_dt: chrono::DateTime<chrono_tz::Tz> = d.try_into().unwrap();
684 assert_eq!(tz_dt.timezone(), Pacific);
685 }
686
687 #[cfg(feature = "chrono-tz")]
688 #[test]
689 fn test_named_tz_to_fixed_offset_dst() {
690 let winter = dt(2024, 1, 1, 12, 0, 0, 0).with_time_zone(TimeZone {
692 id: "America/New_York".into(),
693 version: String::new(),
694 });
695
696 let fixed_winter: chrono::DateTime<chrono::FixedOffset> = winter.try_into().unwrap();
697 assert_eq!(fixed_winter.offset().local_minus_utc(), -5 * 3600);
698
699 let summer = dt(2024, 6, 1, 12, 0, 0, 0).with_time_zone(TimeZone {
701 id: "America/New_York".into(),
702 version: String::new(),
703 });
704
705 let fixed_summer: chrono::DateTime<chrono::FixedOffset> = summer.try_into().unwrap();
706 assert_eq!(fixed_summer.offset().local_minus_utc(), -4 * 3600);
707 }
708 }
709}