Skip to main content

eml_nl/utils/
date_time.rs

1use std::str::FromStr;
2
3use chrono::{
4    DateTime, FixedOffset, MappedLocalTime, NaiveDate, NaiveDateTime, Offset, TimeZone, Utc,
5};
6
7use crate::utils::StringValueData;
8
9/// Represents an `xs:date`.
10///
11/// These kinds of dates may optionally contain timezone information using a
12/// fixed offset from UTC.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub struct XsDate {
15    /// The date part of the `xs:date`.
16    pub date: NaiveDate,
17    /// The optional timezone offset from UTC of the `xs:date`.
18    pub tz: Option<FixedOffset>,
19}
20
21impl XsDate {
22    /// Create a new `XsDate` without timezone information.
23    pub fn new(date: NaiveDate) -> Self {
24        XsDate { date, tz: None }
25    }
26
27    /// Create a new `XsDate` without timezone information from the specified year, month and day.
28    pub fn from_date(year: i32, month: u32, day: u32) -> Option<Self> {
29        NaiveDate::from_ymd_opt(year, month, day).map(XsDate::new)
30    }
31
32    /// Create a new `XsDate` with timezone information.
33    pub fn new_with_tz<O: Offset>(date: NaiveDate, tz: O) -> Self {
34        XsDate {
35            date,
36            tz: Some(tz.fix()),
37        }
38    }
39}
40
41impl From<NaiveDate> for XsDate {
42    fn from(value: NaiveDate) -> Self {
43        XsDate::new(value)
44    }
45}
46
47impl FromStr for XsDate {
48    type Err = chrono::ParseError;
49
50    fn from_str(s: &str) -> Result<Self, Self::Err> {
51        // count the number of '-' and '+' in the string to determine if there's a timezone
52        let sep_count = s.chars().filter(|&c| c == '-' || c == '+').count();
53        if sep_count > 2
54            && let Some(pos) = s.rfind(['+', '-'])
55        {
56            // The string should be of the form YYYY-MM-DD±HH:MM
57            let (date_str, tz_str) = s.split_at(pos);
58            let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")?;
59            let tz = tz_str.parse::<FixedOffset>()?;
60            Ok(XsDate { date, tz: Some(tz) })
61        } else if s.ends_with('Z') || s.ends_with('z') {
62            // The string should be of the form YYYY-MM-DDZ
63            let date_str = &s[..s.len() - 1];
64            let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")?;
65
66            Ok(XsDate {
67                date,
68                tz: Some(Utc.fix()),
69            })
70        } else {
71            // There is no timezone info, just a YYYY-MM-DD date
72            let date = NaiveDate::parse_from_str(s, "%Y-%m-%d")?;
73            Ok(XsDate { date, tz: None })
74        }
75    }
76}
77
78impl StringValueData for XsDate {
79    type Error = chrono::ParseError;
80
81    fn parse_from_str(s: &str) -> Result<Self, Self::Error>
82    where
83        Self: Sized,
84    {
85        s.parse()
86    }
87
88    fn to_raw_value(&self) -> Box<str> {
89        match self.tz {
90            Some(tz) => format!("{}{}", self.date.format("%Y-%m-%d"), tz).into(),
91            None => self.date.format("%Y-%m-%d").to_string().into(),
92        }
93    }
94}
95
96/// Represents an `xs:dateTime`.
97///
98/// These kinds of date-times may optionally contain timezone information using
99/// a fixed offset from UTC.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
101pub struct XsDateTime {
102    /// The naive date-time. This information does not reflect a specific point
103    /// in time without considering timezone information. If a specific point in
104    /// time needs to be referenced use the [`Self::datetime_utc`] or [`Self::datetime_tz`]
105    /// methods.
106    pub naive_date_time: NaiveDateTime,
107    /// The timezone offset, if specified. If [`None`], the date-time needs external
108    /// context to determine the actual point in time it represents.
109    pub tz: Option<FixedOffset>,
110}
111
112impl XsDateTime {
113    /// Create a new `XsDateTime` from a `DateTime` with timezone information.
114    pub fn new<T: TimeZone>(date_time: DateTime<T>) -> Self {
115        XsDateTime {
116            naive_date_time: date_time.naive_utc(),
117            tz: Some(date_time.offset().fix()),
118        }
119    }
120
121    /// Create a new `XsDateTime` without timezone information.
122    pub fn new_without_tz(naive_date_time: NaiveDateTime) -> Self {
123        XsDateTime {
124            naive_date_time,
125            tz: None,
126        }
127    }
128
129    /// Converts this [`XsDateTime`] to a [`DateTime<Utc>`].
130    ///
131    /// If the DateTime did not contain timezone information, it is assumed to be in UTC.
132    pub fn datetime_utc(&self) -> DateTime<Utc> {
133        match self.tz {
134            Some(tz) => {
135                DateTime::<FixedOffset>::from_naive_utc_and_offset(self.naive_date_time, tz)
136                    .to_utc()
137            }
138            None => DateTime::<Utc>::from_naive_utc_and_offset(self.naive_date_time, Utc),
139        }
140    }
141
142    /// Converts this [`XsDateTime`] to a [`DateTime`] with the specified timezone.
143    ///
144    /// If the [`XsDateTime`] did not contain timezone information, it is assumed it was a local time for the specified timezone.
145    /// This does mean that some local date-times might be ambiguous or invalid (e.g. during daylight saving time transitions).
146    pub fn datetime_tz<Tz: TimeZone>(&self, tz: &Tz) -> MappedLocalTime<DateTime<Tz>> {
147        match self.tz {
148            Some(original_tz) => MappedLocalTime::Single(
149                DateTime::<FixedOffset>::from_naive_utc_and_offset(
150                    self.naive_date_time,
151                    original_tz,
152                )
153                .with_timezone(tz),
154            ),
155            None => tz.from_local_datetime(&self.naive_date_time),
156        }
157    }
158}
159
160impl<T: TimeZone> From<DateTime<T>> for XsDateTime {
161    fn from(value: DateTime<T>) -> Self {
162        XsDateTime::new(value)
163    }
164}
165
166impl FromStr for XsDateTime {
167    type Err = chrono::ParseError;
168
169    fn from_str(s: &str) -> Result<Self, Self::Err> {
170        // Try to parse as RFC3339 first, if that fails, try without timezone info
171        if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
172            Ok(XsDateTime {
173                naive_date_time: dt.naive_utc(),
174                tz: Some(dt.offset().to_owned()),
175            })
176        } else {
177            // Fallback to parsing without timezone info
178            let naive_dt = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f")?;
179            Ok(XsDateTime {
180                naive_date_time: naive_dt,
181                tz: None,
182            })
183        }
184    }
185}
186
187impl StringValueData for XsDateTime {
188    type Error = chrono::ParseError;
189
190    fn parse_from_str(s: &str) -> Result<Self, Self::Error>
191    where
192        Self: Sized,
193    {
194        s.parse()
195    }
196
197    fn to_raw_value(&self) -> Box<str> {
198        match self.tz {
199            Some(tz) => {
200                let dt_with_tz =
201                    DateTime::<FixedOffset>::from_naive_utc_and_offset(self.naive_date_time, tz);
202                dt_with_tz.to_rfc3339().into()
203            }
204            None => self
205                .naive_date_time
206                .format("%Y-%m-%dT%H:%M:%S%.f")
207                .to_string()
208                .into(),
209        }
210    }
211}
212
213/// Represents either an `xs:date` or an `xs:dateTime`.
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
215pub enum XsDateOrDateTime {
216    /// An `xs:date` value.
217    Date(XsDate),
218    /// An `xs:dateTime` value.
219    DateTime(XsDateTime),
220}
221
222impl XsDateOrDateTime {
223    /// Returns the date of the [`XsDate`] or [`XsDateTime`].
224    ///
225    /// If the value is an [`XsDateTime`] and the timezone is unknown, the date-time is assumed to be
226    /// in the specified timezone. In these cases the resulting date may be ambiguous or non-existent.
227    pub fn date<Tz: TimeZone>(&self, tz: &Tz) -> MappedLocalTime<NaiveDate> {
228        match self {
229            // for XsDate, we just return the date and ignore the timezone (no clear way to map a date to a timezone)
230            XsDateOrDateTime::Date(d) => MappedLocalTime::Single(d.date),
231            // for XsDateTime, we convert to the specified timezone and extract the date
232            XsDateOrDateTime::DateTime(dt) => {
233                let dt = dt.datetime_tz(tz);
234                match dt {
235                    MappedLocalTime::Single(dt) => MappedLocalTime::Single(dt.date_naive()),
236                    MappedLocalTime::None => MappedLocalTime::None,
237                    MappedLocalTime::Ambiguous(first, second) => {
238                        // If the first and second are on the same date, the date is not ambiguous for our purposes
239                        // DST transitions typically do not happen at midnight, so this is usually the case
240                        if first.date_naive() == second.date_naive() {
241                            MappedLocalTime::Single(first.date_naive())
242                        } else {
243                            MappedLocalTime::Ambiguous(first.date_naive(), second.date_naive())
244                        }
245                    }
246                }
247            }
248        }
249    }
250}
251
252impl From<XsDate> for XsDateOrDateTime {
253    fn from(value: XsDate) -> Self {
254        XsDateOrDateTime::Date(value)
255    }
256}
257
258impl From<XsDateTime> for XsDateOrDateTime {
259    fn from(value: XsDateTime) -> Self {
260        XsDateOrDateTime::DateTime(value)
261    }
262}
263
264impl From<NaiveDate> for XsDateOrDateTime {
265    fn from(value: NaiveDate) -> Self {
266        XsDateOrDateTime::Date(XsDate::new(value))
267    }
268}
269
270impl<T> From<DateTime<T>> for XsDateOrDateTime
271where
272    T: TimeZone,
273{
274    fn from(value: DateTime<T>) -> Self {
275        XsDateOrDateTime::DateTime(XsDateTime::new(value))
276    }
277}
278
279impl FromStr for XsDateOrDateTime {
280    type Err = chrono::ParseError;
281
282    fn from_str(s: &str) -> Result<Self, Self::Err> {
283        if s.contains('T') {
284            let date_time = s.parse::<XsDateTime>()?;
285            Ok(XsDateOrDateTime::DateTime(date_time))
286        } else {
287            let date = s.parse::<XsDate>()?;
288            Ok(XsDateOrDateTime::Date(date))
289        }
290    }
291}
292
293impl StringValueData for XsDateOrDateTime {
294    type Error = chrono::ParseError;
295
296    fn parse_from_str(s: &str) -> Result<Self, Self::Error>
297    where
298        Self: Sized,
299    {
300        s.parse()
301    }
302
303    fn to_raw_value(&self) -> Box<str> {
304        match self {
305            XsDateOrDateTime::Date(d) => d.to_raw_value(),
306            XsDateOrDateTime::DateTime(dt) => dt.to_raw_value(),
307        }
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use chrono::{Datelike as _, Timelike as _};
314
315    use super::*;
316
317    #[test]
318    fn test_xs_date_parse() {
319        let d1: XsDate = "2025-10-05".parse().unwrap();
320        assert_eq!(d1.date, NaiveDate::from_ymd_opt(2025, 10, 5).unwrap());
321        assert!(d1.tz.is_none());
322
323        let d2: XsDate = "2025-10-05+02:00".parse().unwrap();
324        assert_eq!(d2.date, NaiveDate::from_ymd_opt(2025, 10, 5).unwrap());
325        assert_eq!(d2.tz.unwrap(), FixedOffset::east_opt(2 * 3600).unwrap());
326
327        let d3: XsDate = "2025-10-05Z".parse().unwrap();
328        assert_eq!(d3.date, NaiveDate::from_ymd_opt(2025, 10, 5).unwrap());
329        assert_eq!(d3.tz.unwrap(), Utc.fix());
330
331        let d4: XsDate = "2025-10-05-05:00".parse().unwrap();
332        assert_eq!(d4.date, NaiveDate::from_ymd_opt(2025, 10, 5).unwrap());
333        assert_eq!(d4.tz.unwrap(), FixedOffset::west_opt(5 * 3600).unwrap());
334    }
335
336    #[test]
337    fn test_xs_date_time_parse() {
338        let dt1: XsDateTime = "2025-10-05T14:30:00".parse().unwrap();
339        assert_eq!(
340            dt1.naive_date_time,
341            NaiveDate::from_ymd_opt(2025, 10, 5)
342                .unwrap()
343                .and_hms_opt(14, 30, 0)
344                .unwrap()
345        );
346        assert!(dt1.tz.is_none());
347
348        let dt2: XsDateTime = "2025-10-05T14:30:00+02:00".parse().unwrap();
349        assert_eq!(
350            dt2.naive_date_time,
351            NaiveDate::from_ymd_opt(2025, 10, 5)
352                .unwrap()
353                .and_hms_opt(12, 30, 0)
354                .unwrap()
355        );
356        assert_eq!(dt2.tz.unwrap(), FixedOffset::east_opt(2 * 3600).unwrap());
357
358        let dt3: XsDateTime = "2025-10-05T14:30:00Z".parse().unwrap();
359        assert_eq!(
360            dt3.naive_date_time,
361            NaiveDate::from_ymd_opt(2025, 10, 5)
362                .unwrap()
363                .and_hms_opt(14, 30, 0)
364                .unwrap()
365        );
366        assert_eq!(dt3.tz.unwrap(), Utc.fix());
367
368        let dt4: XsDateTime = "2025-10-05T14:30:00.123456".parse().unwrap();
369        assert_eq!(
370            dt4.naive_date_time,
371            NaiveDate::from_ymd_opt(2025, 10, 5)
372                .unwrap()
373                .and_hms_micro_opt(14, 30, 0, 123456)
374                .unwrap()
375        );
376        assert!(dt4.tz.is_none());
377
378        let dt5: XsDateTime = "2025-10-05T14:30:00.123456-02:00".parse().unwrap();
379        assert_eq!(
380            dt5.naive_date_time,
381            NaiveDate::from_ymd_opt(2025, 10, 5)
382                .unwrap()
383                .and_hms_micro_opt(16, 30, 0, 123456)
384                .unwrap()
385        );
386        assert_eq!(dt5.tz.unwrap(), FixedOffset::west_opt(2 * 3600).unwrap());
387    }
388
389    #[test]
390    fn test_xs_date_time_to_datetime_utc() {
391        let dt1: XsDateTime = "2025-10-05T14:30:00+02:00".parse().unwrap();
392        let utc_dt1 = dt1.datetime_utc();
393        assert_eq!(utc_dt1.year(), 2025);
394        assert_eq!(utc_dt1.month(), 10);
395        assert_eq!(utc_dt1.day(), 5);
396        assert_eq!(utc_dt1.hour(), 12);
397        assert_eq!(utc_dt1.minute(), 30);
398
399        let dt2: XsDateTime = "2025-10-05T14:30:00".parse().unwrap();
400        let utc_dt2 = dt2.datetime_utc();
401        assert_eq!(utc_dt2.year(), 2025);
402        assert_eq!(utc_dt2.month(), 10);
403        assert_eq!(utc_dt2.day(), 5);
404        assert_eq!(utc_dt2.hour(), 14);
405        assert_eq!(utc_dt2.minute(), 30);
406    }
407
408    #[test]
409    fn test_xs_date_time_to_datetime_tz() {
410        let dt1: XsDateTime = "2025-10-05T14:30:00+02:00".parse().unwrap();
411        let tz = FixedOffset::east_opt(3600).unwrap();
412        let dt1_in_tz = dt1.datetime_tz(&tz).single().unwrap();
413        assert_eq!(dt1_in_tz.year(), 2025);
414        assert_eq!(dt1_in_tz.month(), 10);
415        assert_eq!(dt1_in_tz.day(), 5);
416        assert_eq!(dt1_in_tz.hour(), 13);
417        assert_eq!(dt1_in_tz.minute(), 30);
418
419        let dt2: XsDateTime = "2025-10-05T14:30:00".parse().unwrap();
420        let dt2_in_tz = dt2.datetime_tz(&tz).single().unwrap();
421        assert_eq!(dt2_in_tz.year(), 2025);
422        assert_eq!(dt2_in_tz.month(), 10);
423        assert_eq!(dt2_in_tz.day(), 5);
424        assert_eq!(dt2_in_tz.hour(), 14);
425        assert_eq!(dt2_in_tz.minute(), 30);
426    }
427
428    #[test]
429    fn test_xs_date_or_date_time_parse() {
430        let d: XsDateOrDateTime = "2025-10-05".parse().unwrap();
431        match d {
432            XsDateOrDateTime::Date(date) => {
433                assert_eq!(date.date, NaiveDate::from_ymd_opt(2025, 10, 5).unwrap());
434                assert!(date.tz.is_none());
435            }
436            _ => panic!("Expected XsDate variant"),
437        }
438
439        let dt: XsDateOrDateTime = "2025-10-05T14:30:00+02:00".parse().unwrap();
440        match dt {
441            XsDateOrDateTime::DateTime(date_time) => {
442                assert_eq!(
443                    date_time.naive_date_time,
444                    NaiveDate::from_ymd_opt(2025, 10, 5)
445                        .unwrap()
446                        .and_hms_opt(12, 30, 0)
447                        .unwrap()
448                );
449                assert_eq!(
450                    date_time.tz.unwrap(),
451                    FixedOffset::east_opt(2 * 3600).unwrap()
452                );
453            }
454            _ => panic!("Expected XsDateTime variant"),
455        }
456    }
457}