proto_types/common/
datetime.rs

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")? // 'Z' for UTC
42        } else {
43          write!(f, "+{:02}:{:02}", hours, minutes)?
44        }
45      }
46      Some(TimeOffset::TimeZone(tz)) => {
47        // Named timezones are not usually part of the ISO 8601 string itself
48        // (it usually implies fixed offset or UTC).
49        // However, for debugging/clarity, we can append it in parentheses.
50        write!(f, "[{}]", tz.id)?;
51      }
52      None => {}
53    }
54    Ok(())
55  }
56}
57
58/// Errors that can occur during the creation, conversion or validation of a [`DateTime`].
59#[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      // Can't determine order without timezone information
94      (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  /// Checks if this [`DateTime`] instance represents a valid date and time, and returns the related error if it does not.
171  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  /// Checks if this [`DateTime`] instance represents a valid date and time.
184  pub fn is_valid(&self) -> bool {
185    self.validate().is_ok()
186  }
187
188  /// Returns `true` if the [`DateTime`] has a specific year (i.e., `year` is not 0).
189  pub fn has_year(&self) -> bool {
190    self.year != 0
191  }
192
193  /// Returns true if the [`TimeOffset`] is a UtcOffset.
194  pub fn has_utc_offset(&self) -> bool {
195    matches!(self.time_offset, Some(TimeOffset::UtcOffset(_)))
196  }
197
198  /// Returns true if the [`TimeOffset`] is a TimeZone.
199  pub fn has_timezone(&self) -> bool {
200    matches!(self.time_offset, Some(TimeOffset::TimeZone(_)))
201  }
202
203  /// Returns true if the [`TimeOffset`] is None.
204  pub fn is_local(&self) -> bool {
205    self.time_offset.is_none()
206  }
207
208  /// Sets the `time_offset` to a UTC offset [`Duration`], clearing any existing time zone.
209  pub fn with_utc_offset(mut self, offset: Duration) -> Self {
210    self.time_offset = Some(TimeOffset::UtcOffset(offset));
211    self
212  }
213
214  /// Sets the `time_offset` to a [`TimeZone`], clearing any existing UTC offset.
215  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  /// Converts this [`DateTime`] to [`chrono::DateTime`]<Utc>.
222  /// It succeeds if the [`TimeOffset`] is a UtcOffset with 0 seconds and nanos.
223  pub fn to_datetime_utc(self) -> Result<chrono::DateTime<chrono::Utc>, DateTimeError> {
224    self.try_into()
225  }
226
227  #[cfg(feature = "chrono")]
228  /// Converts this [`DateTime`] to [`chrono::DateTime`]<[`FixedOffset`](chrono::FixedOffset)>.
229  /// It succeeds if the [`TimeOffset`] is a UtcOffset that results in an unambiguous [`FixedOffset`](chrono::FixedOffset).
230  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  /// Converts this [`DateTime`] to [`chrono::DateTime`]<[`Tz`](chrono_tz::Tz)>.
238  /// It succeeds if the [`TimeOffset`] is a [`TimeZone`] that maps to a valid [`Tz`](chrono_tz::Tz) or if the [`TimeOffset`] is a UtcOffset with 0 seconds and nanos.
239  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(), // Version is optional according to the spec
255    }
256  }
257}
258
259// DateTime<Tz> conversions
260
261#[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(), // Version is optional according to the spec
277      })),
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      // Case B: TimeZone (named IANA string) -> Use chrono_tz::Tz::from_str
303      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// FixedOffset conversions
329// From FixedOffset to DateTime is not possible because the values for the offset are not retrievable
330
331#[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          ))?; // Division by zero not possible for NANOS_PER_SECOND
354
355        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() // Take the unique result if not ambiguous
381      .ok_or(DateTimeError::ConversionError(
382        "Ambiguous or invalid local time to FixedOffset conversion".to_string(),
383      ))
384  }
385}
386
387// NaiveDateTime conversions
388
389#[cfg(feature = "chrono")]
390impl From<chrono::NaiveDateTime> for DateTime {
391  fn from(ndt: chrono::NaiveDateTime) -> Self {
392    use chrono::{Datelike, Timelike};
393
394    // NaiveDateTime has no offset, so DateTime will be local time
395    // Casting is safe due to chrono's constructor API
396    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    // NaiveDateTime does not support year 0, nor does it carry time offset.
415    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    // Casting is safe after validation
429    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// UTC Conversions
444
445#[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    // Casting is safe due to chrono's constructor API
450    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}