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) -> String {
89        match self.tz {
90            Some(tz) => format!("{}{}", self.date.format("%Y-%m-%d"), tz),
91            None => self.date.format("%Y-%m-%d").to_string(),
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) -> String {
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()
203            }
204            None => self
205                .naive_date_time
206                .format("%Y-%m-%dT%H:%M:%S%.f")
207                .to_string(),
208        }
209    }
210}
211
212/// Represents either an `xs:date` or an `xs:dateTime`.
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
214pub enum XsDateOrDateTime {
215    /// An `xs:date` value.
216    Date(XsDate),
217    /// An `xs:dateTime` value.
218    DateTime(XsDateTime),
219}
220
221impl XsDateOrDateTime {
222    /// Returns the date of the [`XsDate`] or [`XsDateTime`].
223    ///
224    /// If the value is an [`XsDateTime`] and the timezone is unknown, the date-time is assumed to be
225    /// in the specified timezone. In these cases the resulting date may be ambiguous or non-existent.
226    pub fn date<Tz: TimeZone>(&self, tz: &Tz) -> MappedLocalTime<NaiveDate> {
227        match self {
228            // for XsDate, we just return the date and ignore the timezone (no clear way to map a date to a timezone)
229            XsDateOrDateTime::Date(d) => MappedLocalTime::Single(d.date),
230            // for XsDateTime, we convert to the specified timezone and extract the date
231            XsDateOrDateTime::DateTime(dt) => {
232                let dt = dt.datetime_tz(tz);
233                match dt {
234                    MappedLocalTime::Single(dt) => MappedLocalTime::Single(dt.date_naive()),
235                    MappedLocalTime::None => MappedLocalTime::None,
236                    MappedLocalTime::Ambiguous(first, second) => {
237                        // If the first and second are on the same date, the date is not ambiguous for our purposes
238                        // DST transitions typically do not happen at midnight, so this is usually the case
239                        if first.date_naive() == second.date_naive() {
240                            MappedLocalTime::Single(first.date_naive())
241                        } else {
242                            MappedLocalTime::Ambiguous(first.date_naive(), second.date_naive())
243                        }
244                    }
245                }
246            }
247        }
248    }
249}
250
251impl From<XsDate> for XsDateOrDateTime {
252    fn from(value: XsDate) -> Self {
253        XsDateOrDateTime::Date(value)
254    }
255}
256
257impl From<XsDateTime> for XsDateOrDateTime {
258    fn from(value: XsDateTime) -> Self {
259        XsDateOrDateTime::DateTime(value)
260    }
261}
262
263impl From<NaiveDate> for XsDateOrDateTime {
264    fn from(value: NaiveDate) -> Self {
265        XsDateOrDateTime::Date(XsDate::new(value))
266    }
267}
268
269impl<T> From<DateTime<T>> for XsDateOrDateTime
270where
271    T: TimeZone,
272{
273    fn from(value: DateTime<T>) -> Self {
274        XsDateOrDateTime::DateTime(XsDateTime::new(value))
275    }
276}
277
278impl FromStr for XsDateOrDateTime {
279    type Err = chrono::ParseError;
280
281    fn from_str(s: &str) -> Result<Self, Self::Err> {
282        if s.contains('T') {
283            let date_time = s.parse::<XsDateTime>()?;
284            Ok(XsDateOrDateTime::DateTime(date_time))
285        } else {
286            let date = s.parse::<XsDate>()?;
287            Ok(XsDateOrDateTime::Date(date))
288        }
289    }
290}
291
292impl StringValueData for XsDateOrDateTime {
293    type Error = chrono::ParseError;
294
295    fn parse_from_str(s: &str) -> Result<Self, Self::Error>
296    where
297        Self: Sized,
298    {
299        s.parse()
300    }
301
302    fn to_raw_value(&self) -> String {
303        match self {
304            XsDateOrDateTime::Date(d) => d.to_raw_value(),
305            XsDateOrDateTime::DateTime(dt) => dt.to_raw_value(),
306        }
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use chrono::{Datelike as _, Timelike as _};
313
314    use super::*;
315
316    #[test]
317    fn test_xs_date_parse() {
318        let d1: XsDate = "2025-10-05".parse().unwrap();
319        assert_eq!(d1.date, NaiveDate::from_ymd_opt(2025, 10, 5).unwrap());
320        assert!(d1.tz.is_none());
321
322        let d2: XsDate = "2025-10-05+02:00".parse().unwrap();
323        assert_eq!(d2.date, NaiveDate::from_ymd_opt(2025, 10, 5).unwrap());
324        assert_eq!(d2.tz.unwrap(), FixedOffset::east_opt(2 * 3600).unwrap());
325
326        let d3: XsDate = "2025-10-05Z".parse().unwrap();
327        assert_eq!(d3.date, NaiveDate::from_ymd_opt(2025, 10, 5).unwrap());
328        assert_eq!(d3.tz.unwrap(), Utc.fix());
329
330        let d4: XsDate = "2025-10-05-05:00".parse().unwrap();
331        assert_eq!(d4.date, NaiveDate::from_ymd_opt(2025, 10, 5).unwrap());
332        assert_eq!(d4.tz.unwrap(), FixedOffset::west_opt(5 * 3600).unwrap());
333    }
334
335    #[test]
336    fn test_xs_date_time_parse() {
337        let dt1: XsDateTime = "2025-10-05T14:30:00".parse().unwrap();
338        assert_eq!(
339            dt1.naive_date_time,
340            NaiveDate::from_ymd_opt(2025, 10, 5)
341                .unwrap()
342                .and_hms_opt(14, 30, 0)
343                .unwrap()
344        );
345        assert!(dt1.tz.is_none());
346
347        let dt2: XsDateTime = "2025-10-05T14:30:00+02:00".parse().unwrap();
348        assert_eq!(
349            dt2.naive_date_time,
350            NaiveDate::from_ymd_opt(2025, 10, 5)
351                .unwrap()
352                .and_hms_opt(12, 30, 0)
353                .unwrap()
354        );
355        assert_eq!(dt2.tz.unwrap(), FixedOffset::east_opt(2 * 3600).unwrap());
356
357        let dt3: XsDateTime = "2025-10-05T14:30:00Z".parse().unwrap();
358        assert_eq!(
359            dt3.naive_date_time,
360            NaiveDate::from_ymd_opt(2025, 10, 5)
361                .unwrap()
362                .and_hms_opt(14, 30, 0)
363                .unwrap()
364        );
365        assert_eq!(dt3.tz.unwrap(), Utc.fix());
366
367        let dt4: XsDateTime = "2025-10-05T14:30:00.123456".parse().unwrap();
368        assert_eq!(
369            dt4.naive_date_time,
370            NaiveDate::from_ymd_opt(2025, 10, 5)
371                .unwrap()
372                .and_hms_micro_opt(14, 30, 0, 123456)
373                .unwrap()
374        );
375        assert!(dt4.tz.is_none());
376
377        let dt5: XsDateTime = "2025-10-05T14:30:00.123456-02:00".parse().unwrap();
378        assert_eq!(
379            dt5.naive_date_time,
380            NaiveDate::from_ymd_opt(2025, 10, 5)
381                .unwrap()
382                .and_hms_micro_opt(16, 30, 0, 123456)
383                .unwrap()
384        );
385        assert_eq!(dt5.tz.unwrap(), FixedOffset::west_opt(2 * 3600).unwrap());
386    }
387
388    #[test]
389    fn test_xs_date_time_to_datetime_utc() {
390        let dt1: XsDateTime = "2025-10-05T14:30:00+02:00".parse().unwrap();
391        let utc_dt1 = dt1.datetime_utc();
392        assert_eq!(utc_dt1.year(), 2025);
393        assert_eq!(utc_dt1.month(), 10);
394        assert_eq!(utc_dt1.day(), 5);
395        assert_eq!(utc_dt1.hour(), 12);
396        assert_eq!(utc_dt1.minute(), 30);
397
398        let dt2: XsDateTime = "2025-10-05T14:30:00".parse().unwrap();
399        let utc_dt2 = dt2.datetime_utc();
400        assert_eq!(utc_dt2.year(), 2025);
401        assert_eq!(utc_dt2.month(), 10);
402        assert_eq!(utc_dt2.day(), 5);
403        assert_eq!(utc_dt2.hour(), 14);
404        assert_eq!(utc_dt2.minute(), 30);
405    }
406
407    #[test]
408    fn test_xs_date_time_to_datetime_tz() {
409        let dt1: XsDateTime = "2025-10-05T14:30:00+02:00".parse().unwrap();
410        let tz = FixedOffset::east_opt(3600).unwrap();
411        let dt1_in_tz = dt1.datetime_tz(&tz).single().unwrap();
412        assert_eq!(dt1_in_tz.year(), 2025);
413        assert_eq!(dt1_in_tz.month(), 10);
414        assert_eq!(dt1_in_tz.day(), 5);
415        assert_eq!(dt1_in_tz.hour(), 13);
416        assert_eq!(dt1_in_tz.minute(), 30);
417
418        let dt2: XsDateTime = "2025-10-05T14:30:00".parse().unwrap();
419        let dt2_in_tz = dt2.datetime_tz(&tz).single().unwrap();
420        assert_eq!(dt2_in_tz.year(), 2025);
421        assert_eq!(dt2_in_tz.month(), 10);
422        assert_eq!(dt2_in_tz.day(), 5);
423        assert_eq!(dt2_in_tz.hour(), 14);
424        assert_eq!(dt2_in_tz.minute(), 30);
425    }
426
427    #[test]
428    fn test_xs_date_or_date_time_parse() {
429        let d: XsDateOrDateTime = "2025-10-05".parse().unwrap();
430        match d {
431            XsDateOrDateTime::Date(date) => {
432                assert_eq!(date.date, NaiveDate::from_ymd_opt(2025, 10, 5).unwrap());
433                assert!(date.tz.is_none());
434            }
435            _ => panic!("Expected XsDate variant"),
436        }
437
438        let dt: XsDateOrDateTime = "2025-10-05T14:30:00+02:00".parse().unwrap();
439        match dt {
440            XsDateOrDateTime::DateTime(date_time) => {
441                assert_eq!(
442                    date_time.naive_date_time,
443                    NaiveDate::from_ymd_opt(2025, 10, 5)
444                        .unwrap()
445                        .and_hms_opt(12, 30, 0)
446                        .unwrap()
447                );
448                assert_eq!(
449                    date_time.tz.unwrap(),
450                    FixedOffset::east_opt(2 * 3600).unwrap()
451                );
452            }
453            _ => panic!("Expected XsDateTime variant"),
454        }
455    }
456}