1use std::fmt::{Display, Formatter};
2
3use thiserror::Error;
4
5use crate::{
6 common::{date_time::TimeOffset, DateTime, TimeZone},
7 Duration,
8};
9
10impl Display for TimeZone {
11 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
12 write!(f, "{}", self.id)
13 }
14}
15
16impl Display for DateTime {
17 fn fmt(&self, f: &mut Formatter<'_>) -> std::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 match &self.time_offset {
30 Some(TimeOffset::UtcOffset(duration)) => {
31 let total_offset_seconds = duration.normalized().seconds;
32 let is_negative = total_offset_seconds < 0;
33 let abs_total_offset_seconds = total_offset_seconds.abs();
34
35 let hours = abs_total_offset_seconds / 3600;
36 let minutes = (abs_total_offset_seconds % 3600) / 60;
37
38 if is_negative {
39 write!(f, "-{:02}:{:02}", hours, minutes)?
40 } else if total_offset_seconds == 0 && duration.nanos == 0 {
41 write!(f, "Z")? } else {
43 write!(f, "+{:02}:{:02}", hours, minutes)?
44 }
45 }
46 Some(TimeOffset::TimeZone(tz)) => {
47 write!(f, "[{}]", tz.id)?;
51 }
52 None => {}
53 }
54 Ok(())
55 }
56}
57
58#[derive(Debug, Error, PartialEq, Eq, Clone)]
60pub enum DateTimeError {
61 #[error(
62 "The year must be a value from 0 (to indicate a DateTime with no specific year) to 9999"
63 )]
64 InvalidYear,
65 #[error("If the year is set to 0, month and day cannot be set to 0")]
66 InvalidDate,
67 #[error("Invalid month value (must be within 1 and 12)")]
68 InvalidMonth,
69 #[error("Invalid day value (must be within 1 and 31)")]
70 InvalidDay,
71 #[error("Invalid hours value (must be within 0 and 23)")]
72 InvalidHours,
73 #[error("Invalid minutes value (must be within 0 and 59)")]
74 InvalidMinutes,
75 #[error("Invalid seconds value (must be within 0 and 59)")]
76 InvalidSeconds,
77 #[error("Invalid nanos value (must be within 0 and 999.999.999)")]
78 InvalidNanos,
79 #[error(
80 "DateTime has an invalid time component (e.g., hours, minutes, seconds, nanos out of range)"
81 )]
82 InvalidTime,
83 #[error("DateTime arithmetic resulted in a time outside its representable range")]
84 OutOfRange,
85 #[error("DateTime conversion error: {0}")]
86 ConversionError(String),
87}
88
89impl PartialOrd for TimeOffset {
90 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
91 match (self, other) {
92 (Self::UtcOffset(a), Self::UtcOffset(b)) => a.partial_cmp(b),
93 (Self::TimeZone(_), Self::TimeZone(_)) => None,
95 (Self::UtcOffset(_), Self::TimeZone(_)) => None,
96 (Self::TimeZone(_), Self::UtcOffset(_)) => None,
97 }
98 }
99}
100
101impl PartialOrd for DateTime {
102 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
103 if !(self.is_valid() && other.is_valid()) {
104 return None;
105 }
106
107 if (self.year == 0 && other.year != 0) || (self.year != 0 && other.year == 0) {
108 return None;
109 }
110
111 let ord = self
112 .year
113 .cmp(&other.year)
114 .then_with(|| self.month.cmp(&other.month))
115 .then_with(|| self.day.cmp(&other.day))
116 .then_with(|| self.hours.cmp(&other.hours))
117 .then_with(|| self.minutes.cmp(&other.minutes))
118 .then_with(|| self.seconds.cmp(&other.seconds))
119 .then_with(|| self.nanos.cmp(&other.nanos));
120
121 if ord != std::cmp::Ordering::Equal {
122 return Some(ord);
123 }
124
125 self.time_offset.partial_cmp(&other.time_offset)
126 }
127}
128
129#[allow(clippy::too_many_arguments)]
130fn datetime_is_valid(
131 year: i32,
132 month: i32,
133 day: i32,
134 hours: i32,
135 minutes: i32,
136 seconds: i32,
137 nanos: i32,
138) -> Result<(), DateTimeError> {
139 if !(0..=9999).contains(&year) {
140 return Err(DateTimeError::InvalidYear);
141 }
142 if !(1..=12).contains(&month) {
143 return Err(DateTimeError::InvalidMonth);
144 }
145 if !(1..=31).contains(&day) {
146 return Err(DateTimeError::InvalidDay);
147 }
148
149 if year == 0 && (day == 0 || month == 0) {
150 return Err(DateTimeError::InvalidDate);
151 }
152
153 if !(0..=23).contains(&hours) {
154 return Err(DateTimeError::InvalidHours);
155 }
156 if !(0..=59).contains(&minutes) {
157 return Err(DateTimeError::InvalidMinutes);
158 }
159 if !(0..=59).contains(&seconds) {
160 return Err(DateTimeError::InvalidSeconds);
161 }
162 if !(0..=999_999_999).contains(&nanos) {
163 return Err(DateTimeError::InvalidNanos);
164 }
165
166 Ok(())
167}
168
169impl DateTime {
170 pub fn validate(&self) -> Result<(), DateTimeError> {
172 datetime_is_valid(
173 self.year,
174 self.month,
175 self.day,
176 self.hours,
177 self.minutes,
178 self.seconds,
179 self.nanos,
180 )
181 }
182
183 pub fn is_valid(&self) -> bool {
185 self.validate().is_ok()
186 }
187
188 pub fn has_year(&self) -> bool {
190 self.year != 0
191 }
192
193 pub fn has_utc_offset(&self) -> bool {
195 matches!(self.time_offset, Some(TimeOffset::UtcOffset(_)))
196 }
197
198 pub fn has_timezone(&self) -> bool {
200 matches!(self.time_offset, Some(TimeOffset::TimeZone(_)))
201 }
202
203 pub fn is_local(&self) -> bool {
205 self.time_offset.is_none()
206 }
207
208 pub fn with_utc_offset(mut self, offset: Duration) -> Self {
210 self.time_offset = Some(TimeOffset::UtcOffset(offset));
211 self
212 }
213
214 pub fn with_time_zone(mut self, time_zone: TimeZone) -> Self {
216 self.time_offset = Some(TimeOffset::TimeZone(time_zone));
217 self
218 }
219
220 #[cfg(feature = "chrono")]
221 pub fn to_datetime_utc(self) -> Result<chrono::DateTime<chrono::Utc>, DateTimeError> {
224 self.try_into()
225 }
226
227 #[cfg(feature = "chrono")]
228 pub fn to_fixed_offset_datetime(
231 self,
232 ) -> Result<chrono::DateTime<chrono::FixedOffset>, DateTimeError> {
233 self.try_into()
234 }
235
236 #[cfg(all(feature = "chrono", feature = "chrono-tz"))]
237 pub fn to_datetime_with_tz(self) -> Result<chrono::DateTime<chrono_tz::Tz>, DateTimeError> {
240 self.try_into()
241 }
242}
243
244pub const UTC_OFFSET: Duration = Duration {
245 seconds: 0,
246 nanos: 0,
247};
248
249#[cfg(all(feature = "chrono", feature = "chrono-tz"))]
250impl From<chrono_tz::Tz> for TimeZone {
251 fn from(value: chrono_tz::Tz) -> Self {
252 Self {
253 id: value.to_string(),
254 version: "".to_string(), }
256 }
257}
258
259#[cfg(all(feature = "chrono", feature = "chrono-tz"))]
262impl From<chrono::DateTime<chrono_tz::Tz>> for DateTime {
263 fn from(value: chrono::DateTime<chrono_tz::Tz>) -> Self {
264 use chrono::{Datelike, Timelike};
265
266 Self {
267 year: value.year(),
268 month: value.month() as i32,
269 day: value.day() as i32,
270 hours: value.hour() as i32,
271 minutes: value.minute() as i32,
272 seconds: value.second() as i32,
273 nanos: value.nanosecond() as i32,
274 time_offset: Some(TimeOffset::TimeZone(TimeZone {
275 id: value.timezone().to_string(),
276 version: "".to_string(), })),
278 }
279 }
280}
281
282#[cfg(all(feature = "chrono", feature = "chrono-tz"))]
283impl TryFrom<DateTime> for chrono::DateTime<chrono_tz::Tz> {
284 type Error = DateTimeError;
285
286 fn try_from(value: crate::DateTime) -> Result<Self, Self::Error> {
287 use std::str::FromStr;
288
289 use chrono::{NaiveDateTime, TimeZone};
290 use chrono_tz::Tz;
291
292 let timezone = match &value.time_offset {
293 Some(TimeOffset::UtcOffset(proto_duration)) => {
294 if *proto_duration == UTC_OFFSET {
295 Tz::UTC
296 } else {
297 return Err(DateTimeError::ConversionError(
298 "Cannot convert non-zero UtcOffset to a named TimeZone (Tz)".to_string(),
299 ));
300 }
301 }
302 Some(TimeOffset::TimeZone(tz_name)) => Tz::from_str(&tz_name.id).map_err(|_| {
304 DateTimeError::ConversionError(format!(
305 "Unrecognized or invalid timezone name: {}",
306 tz_name.id
307 ))
308 })?,
309 None => {
310 return Err(DateTimeError::ConversionError(
311 "Cannot convert local DateTime to named TimeZone (Tz) without explicit offset or name"
312 .to_string(),
313 ));
314 }
315 };
316
317 let naive_dt: NaiveDateTime = value.try_into()?;
318
319 timezone
320 .from_local_datetime(&naive_dt)
321 .single()
322 .ok_or(DateTimeError::ConversionError(
323 "Ambiguous or invalid local time to named TimeZone (Tz) conversion".to_string(),
324 ))
325 }
326}
327
328#[cfg(feature = "chrono")]
332impl TryFrom<DateTime> for chrono::DateTime<chrono::FixedOffset> {
333 type Error = DateTimeError;
334 fn try_from(value: DateTime) -> Result<Self, Self::Error> {
335 let offset = match &value.time_offset {
336 Some(TimeOffset::UtcOffset(proto_duration)) => {
337 use crate::constants::NANOS_PER_SECOND;
338
339 let total_nanos_i128 = (proto_duration.seconds as i128)
340 .checked_mul(NANOS_PER_SECOND as i128)
341 .ok_or(DateTimeError::ConversionError(
342 "UtcOffset seconds multiplied by NANOS_PER_SECOND overflowed i128".to_string(),
343 ))?
344 .checked_add(proto_duration.nanos as i128)
345 .ok_or(DateTimeError::ConversionError(
346 "UtcOffset nanos addition overflowed i128".to_string(),
347 ))?;
348
349 let total_seconds_i128 = total_nanos_i128
350 .checked_div(NANOS_PER_SECOND as i128)
351 .ok_or(DateTimeError::ConversionError(
352 "UtcOffset total nanoseconds division overflowed i128 (should not happen)".to_string(),
353 ))?; let total_seconds_i32: i32 = total_seconds_i128.try_into().map_err(|_| {
356 DateTimeError::ConversionError(
357 "UtcOffset total seconds is outside of i32 range for FixedOffset".to_string(),
358 )
359 })?;
360
361 chrono::FixedOffset::east_opt(total_seconds_i32).ok_or_else(|| {
362 DateTimeError::ConversionError(
363 "Failed to convert proto::Duration to chrono::FixedOffset due to invalid offset values"
364 .to_string(),
365 )
366 })
367 }
368 Some(TimeOffset::TimeZone(_)) => Err(DateTimeError::ConversionError(
369 "Cannot convert DateTime with named TimeZone to FixedOffset".to_string(),
370 )),
371 None => Err(DateTimeError::ConversionError(
372 "Cannot convert local DateTime to FixedOffset without explicit offset".to_string(),
373 )),
374 }?;
375
376 let naive_dt: chrono::NaiveDateTime = value.try_into()?;
377
378 naive_dt
379 .and_local_timezone(offset)
380 .single() .ok_or(DateTimeError::ConversionError(
382 "Ambiguous or invalid local time to FixedOffset conversion".to_string(),
383 ))
384 }
385}
386
387#[cfg(feature = "chrono")]
390impl From<chrono::NaiveDateTime> for DateTime {
391 fn from(ndt: chrono::NaiveDateTime) -> Self {
392 use chrono::{Datelike, Timelike};
393
394 DateTime {
397 year: ndt.year(),
398 month: ndt.month() as i32,
399 day: ndt.day() as i32,
400 hours: ndt.hour() as i32,
401 minutes: ndt.minute() as i32,
402 seconds: ndt.second() as i32,
403 nanos: ndt.nanosecond() as i32,
404 time_offset: None,
405 }
406 }
407}
408
409#[cfg(feature = "chrono")]
410impl TryFrom<DateTime> for chrono::NaiveDateTime {
411 type Error = DateTimeError;
412
413 fn try_from(dt: DateTime) -> Result<Self, Self::Error> {
414 if dt.year == 0 {
416 return Err(DateTimeError::ConversionError(
417 "Cannot convert DateTime with year 0 to NaiveDateTime".to_string(),
418 ));
419 }
420 if dt.time_offset.is_some() {
421 return Err(DateTimeError::ConversionError(
422 "Cannot convert DateTime with explicit time offset to NaiveDateTime without losing information".to_string()
423 ));
424 }
425
426 dt.validate()?;
427
428 let date = chrono::NaiveDate::from_ymd_opt(dt.year, dt.month as u32, dt.day as u32)
430 .ok_or(DateTimeError::InvalidDate)?;
431 let time = chrono::NaiveTime::from_hms_nano_opt(
432 dt.hours as u32,
433 dt.minutes as u32,
434 dt.seconds as u32,
435 dt.nanos as u32,
436 )
437 .ok_or(DateTimeError::InvalidTime)?;
438
439 Ok(chrono::NaiveDateTime::new(date, time))
440 }
441}
442
443#[cfg(feature = "chrono")]
446impl From<chrono::DateTime<chrono::Utc>> for DateTime {
447 fn from(value: chrono::DateTime<chrono::Utc>) -> Self {
448 use chrono::{Datelike, Timelike};
449 DateTime {
451 year: value.year(),
452 month: value.month() as i32,
453 day: value.day() as i32,
454 hours: value.hour() as i32,
455 minutes: value.minute() as i32,
456 seconds: value.second() as i32,
457 nanos: value.nanosecond() as i32,
458 time_offset: Some(TimeOffset::UtcOffset(Duration::new(0, 0))),
459 }
460 }
461}
462
463#[cfg(feature = "chrono")]
464impl TryFrom<DateTime> for chrono::DateTime<chrono::Utc> {
465 type Error = DateTimeError;
466 fn try_from(value: DateTime) -> Result<Self, Self::Error> {
467 match &value.time_offset {
468 Some(TimeOffset::UtcOffset(proto_duration)) => {
469 if *proto_duration != UTC_OFFSET {
470 return Err(DateTimeError::ConversionError(
471 "Cannot convert DateTime to TimeZone<Utc> when the UtcOffset is not 0.".to_string(),
472 ));
473 }
474 }
475 Some(TimeOffset::TimeZone(_)) => {
476 return Err(DateTimeError::ConversionError(
477 "Cannot convert DateTime to TimeZone<Utc> when a UtcOffset is not set.".to_string(),
478 ))
479 }
480 None => {
481 return Err(DateTimeError::ConversionError(
482 "Cannot convert DateTime to TimeZone<Utc> when a UtcOffset is not set.".to_string(),
483 ))
484 }
485 };
486
487 let naive_dt: chrono::NaiveDateTime = value.try_into()?;
488
489 Ok(naive_dt.and_utc())
490 }
491}