icalendar/components/
date_time.rs

1use std::str::FromStr;
2
3use chrono::{DateTime, Duration, NaiveDate, NaiveDateTime, TimeZone as _, Utc};
4
5use crate::{Property, ValueType};
6
7const NAIVE_DATE_TIME_FORMAT: &str = "%Y%m%dT%H%M%S";
8const UTC_DATE_TIME_FORMAT: &str = "%Y%m%dT%H%M%SZ";
9const NAIVE_DATE_FORMAT: &str = "%Y%m%d";
10
11// #[deprecated(note = "use `CalendarDateTime::from_str` if you can")]
12pub(crate) fn parse_utc_date_time(s: &str) -> Option<DateTime<Utc>> {
13    Utc.datetime_from_str(s, UTC_DATE_TIME_FORMAT).ok()
14}
15
16pub(crate) fn parse_naive_date_time(s: &str) -> Option<NaiveDateTime> {
17    NaiveDateTime::parse_from_str(s, NAIVE_DATE_TIME_FORMAT).ok()
18}
19
20pub(crate) fn format_utc_date_time(utc_dt: DateTime<Utc>) -> String {
21    utc_dt.format(UTC_DATE_TIME_FORMAT).to_string()
22}
23
24pub(crate) fn parse_duration(s: &str) -> Option<Duration> {
25    iso8601::duration(s)
26        .ok()
27        .and_then(|iso| Duration::from_std(iso.into()).ok())
28}
29
30pub(crate) fn naive_date_to_property(date: NaiveDate, key: &str) -> Property {
31    Property::new(key, date.format(NAIVE_DATE_FORMAT).to_string())
32        .append_parameter(ValueType::Date)
33        .done()
34}
35
36/// Representation of various forms of `DATE-TIME` per
37/// [RFC 5545, Section 3.3.5](https://tools.ietf.org/html/rfc5545#section-3.3.5)
38///
39/// Conversions from [chrono] types are provided in form of [From] implementations, see
40/// documentation of individual variants.
41///
42/// In addition to readily implemented `FORM #1` and `FORM #2`, the RFC also specifies
43/// `FORM #3: DATE WITH LOCAL TIME AND TIME ZONE REFERENCE`. This variant is not yet implemented.
44/// Adding it will require adding support for `VTIMEZONE` and referencing it using `TZID`.
45#[derive(Clone, Debug, Eq, PartialEq)]
46pub enum CalendarDateTime {
47    /// `FORM #1: DATE WITH LOCAL TIME`: floating, follows current time-zone of the attendee.
48    ///
49    /// Conversion from [`chrono::NaiveDateTime`] results in this variant.
50    ///
51    /// ## Note
52    /// finding this in a calendar is a red flag, datetimes should end in `'Z'` for `UTC` or have a `TZID` property
53    Floating(NaiveDateTime),
54
55    /// `FORM #2: DATE WITH UTC TIME`: rendered with Z suffix character.
56    ///
57    /// Conversion from [`chrono::DateTime<Utc>`](DateTime) results in this variant. Use
58    /// `date_time.with_timezone(&Utc)` to convert `date_time` from arbitrary time zone to UTC.
59    Utc(DateTime<Utc>),
60
61    /// `FORM #3: DATE WITH LOCAL TIME AND TIME ZONE REFERENCE`: refers to a time zone definition.
62    WithTimezone {
63        /// The date and time in the given time zone.
64        date_time: NaiveDateTime,
65        /// The ID of the time zone definition in a VTIMEZONE calendar component.
66        tzid: String,
67    },
68}
69
70impl CalendarDateTime {
71    /// this is not actually now, just a fixed date for testing
72    #[cfg(test)]
73    pub(crate) fn now() -> Self {
74        NaiveDate::from_ymd_opt(2015, 10, 26)
75            .unwrap()
76            .and_hms_opt(1, 22, 00)
77            .unwrap()
78            .into()
79    }
80
81    pub(crate) fn from_property(property: &Property) -> Option<Self> {
82        let value = property.value();
83        if let Some(tzid) = property.params().get("TZID") {
84            Some(Self::WithTimezone {
85                date_time: NaiveDateTime::parse_from_str(value, NAIVE_DATE_TIME_FORMAT).ok()?,
86                tzid: tzid.value().to_owned(),
87            })
88        } else if let Ok(naive_date_time) =
89            NaiveDateTime::parse_from_str(value, NAIVE_DATE_TIME_FORMAT)
90        {
91            Some(naive_date_time.into())
92        } else {
93            Self::from_str(value).ok()
94        }
95    }
96
97    pub(crate) fn to_property(&self, key: &str) -> Property {
98        match self {
99            CalendarDateTime::Floating(naive_dt) => {
100                Property::new(key, naive_dt.format(NAIVE_DATE_TIME_FORMAT).to_string())
101            }
102            CalendarDateTime::Utc(utc_dt) => Property::new(key, format_utc_date_time(*utc_dt)),
103            CalendarDateTime::WithTimezone { date_time, tzid } => {
104                Property::new(key, date_time.format(NAIVE_DATE_TIME_FORMAT).to_string())
105                    .add_parameter("TZID", tzid)
106                    .done()
107            }
108        }
109    }
110
111    pub(crate) fn from_utc_string(s: &str) -> Option<Self> {
112        parse_utc_date_time(s).map(CalendarDateTime::Utc)
113    }
114
115    pub(crate) fn from_naive_string(s: &str) -> Option<Self> {
116        parse_naive_date_time(s).map(CalendarDateTime::Floating)
117    }
118
119    /// attempts to convert the into UTC
120    #[cfg(feature = "chrono-tz")]
121    pub fn try_into_utc(&self) -> Option<DateTime<Utc>> {
122        match self {
123            CalendarDateTime::Floating(_) => None, // we shouldn't guess here
124            CalendarDateTime::Utc(inner) => Some(*inner),
125            CalendarDateTime::WithTimezone { date_time, tzid } => tzid
126                .parse::<chrono_tz::Tz>()
127                .ok()
128                .and_then(|tz| tz.from_local_datetime(date_time).single())
129                .map(|tz| tz.with_timezone(&Utc)),
130        }
131    }
132
133    /// TODO: make public or delete
134    #[cfg(feature = "chrono-tz")]
135    #[allow(dead_code)]
136    pub(crate) fn with_timezone(dt: NaiveDateTime, tz_id: chrono_tz::Tz) -> Self {
137        Self::WithTimezone {
138            date_time: dt,
139            tzid: tz_id.name().to_owned(),
140        }
141    }
142
143    /// will return [`None`] if date is not valid
144    #[cfg(feature = "chrono-tz")]
145    pub fn from_ymd_hm_tzid(
146        year: i32,
147        month: u32,
148        day: u32,
149        hour: u32,
150        min: u32,
151        tz_id: chrono_tz::Tz,
152    ) -> Option<Self> {
153        NaiveDate::from_ymd_opt(year, month, day)
154            .and_then(|date| date.and_hms_opt(hour, min, 0))
155            .zip(Some(tz_id))
156            .map(|(dt, tz)| Self::with_timezone(dt, tz))
157    }
158
159    /// Create a new instance with the given timezone
160    #[cfg(feature = "chrono-tz")]
161    pub fn from_date_time<TZ: chrono::TimeZone<Offset = O>, O: chrono_tz::OffsetName>(
162        dt: DateTime<TZ>,
163    ) -> Self {
164        Self::WithTimezone {
165            date_time: dt.naive_local(),
166            tzid: dt.offset().tz_id().to_owned(),
167        }
168    }
169}
170
171/// will return [`None`] if date is not valid
172#[cfg(feature = "chrono-tz")]
173pub fn ymd_hm_tzid(
174    year: i32,
175    month: u32,
176    day: u32,
177    hour: u32,
178    min: u32,
179    tz_id: chrono_tz::Tz,
180) -> Option<CalendarDateTime> {
181    CalendarDateTime::from_ymd_hm_tzid(year, month, day, hour, min, tz_id)
182}
183
184/// Converts from time zone-aware UTC date-time to [`CalendarDateTime::Utc`].
185impl From<DateTime<Utc>> for CalendarDateTime {
186    fn from(dt: DateTime<Utc>) -> Self {
187        Self::Utc(dt)
188    }
189}
190
191// impl<TZ: chrono::TimeZone<Offset = O>, O: chrono_tz::OffsetName> From<DateTime<TZ>>
192//     for CalendarDateTime
193// {
194//     fn from(date_time: DateTime<TZ>) -> Self {
195//         CalendarDateTime::WithTimezone {
196//             date_time: date_time.naive_local(),
197//             tzid: date_time.offset().tz_id().to_owned(),
198//         }
199//     }
200// }
201
202/// Converts from time zone-less date-time to [`CalendarDateTime::Floating`].
203impl From<NaiveDateTime> for CalendarDateTime {
204    fn from(dt: NaiveDateTime) -> Self {
205        Self::Floating(dt)
206    }
207}
208
209#[cfg(feature = "chrono-tz")]
210impl From<(NaiveDateTime, chrono_tz::Tz)> for CalendarDateTime {
211    fn from((date_time, tzid): (NaiveDateTime, chrono_tz::Tz)) -> Self {
212        Self::WithTimezone {
213            date_time,
214            tzid: tzid.name().into(),
215        }
216    }
217}
218
219#[cfg(feature = "chrono-tz")]
220impl TryFrom<(NaiveDateTime, &str)> for CalendarDateTime {
221    type Error = String;
222
223    fn try_from((dt, maybe_tz): (NaiveDateTime, &str)) -> Result<Self, Self::Error> {
224        let tzid: chrono_tz::Tz = maybe_tz
225            .parse()
226            .map_err(|e: chrono_tz::ParseError| e.to_string())?;
227        Ok(CalendarDateTime::from((dt, tzid)))
228    }
229}
230
231impl FromStr for CalendarDateTime {
232    type Err = ();
233
234    fn from_str(s: &str) -> Result<Self, Self::Err> {
235        CalendarDateTime::from_utc_string(s)
236            .or_else(|| CalendarDateTime::from_naive_string(s))
237            .ok_or(())
238    }
239}
240
241/// Either a `DATE-TIME` or a `DATE`.
242#[derive(Clone, Debug, Eq, PartialEq)]
243pub enum DatePerhapsTime {
244    /// A `DATE-TIME` property.
245    DateTime(CalendarDateTime),
246    /// A `DATE` property.
247    Date(NaiveDate),
248}
249
250impl DatePerhapsTime {
251    /// Attempts to convert the given property into a `DatePerhapsTime`.
252    pub fn from_property(property: &Property) -> Option<Self> {
253        if property.value_type() == Some(ValueType::Date) {
254            Some(
255                NaiveDate::parse_from_str(property.value(), NAIVE_DATE_FORMAT)
256                    .ok()?
257                    .into(),
258            )
259        } else {
260            Some(CalendarDateTime::from_property(property)?.into())
261        }
262    }
263
264    /// Converts this `DatePerhapsTime` into a `Property`.
265    pub fn to_property(&self, key: &str) -> Property {
266        match self {
267            Self::DateTime(date_time) => date_time.to_property(key),
268            Self::Date(date) => naive_date_to_property(*date, key),
269        }
270    }
271
272    /// Discards time, assumes UTC, and returns an owned instance of a pure date
273    pub fn date_naive(&self) -> NaiveDate {
274        use crate::DatePerhapsTime::*;
275        match self {
276            Date(date) => date.to_owned(),
277            DateTime(CalendarDateTime::Floating(date_time)) => date_time.date(),
278            DateTime(CalendarDateTime::Utc(date_time)) => date_time.date_naive(),
279            DateTime(CalendarDateTime::WithTimezone { date_time, tzid: _ }) => date_time.date(),
280        }
281    }
282}
283
284/// TODO: make public or delete
285#[cfg(feature = "chrono-tz")]
286#[allow(dead_code)]
287pub fn with_timezone<T: chrono::TimeZone + chrono_tz::OffsetName>(
288    dt: DateTime<T>,
289) -> DatePerhapsTime {
290    CalendarDateTime::WithTimezone {
291        date_time: dt.naive_local(),
292        tzid: dt.timezone().tz_id().to_owned(),
293    }
294    .into()
295}
296
297impl From<DatePerhapsTime> for NaiveDate {
298    fn from(dt: DatePerhapsTime) -> Self {
299        match dt {
300            DatePerhapsTime::Date(date) => date,
301            DatePerhapsTime::DateTime(CalendarDateTime::Floating(date_time)) => date_time.date(),
302            DatePerhapsTime::DateTime(CalendarDateTime::Utc(date_time)) => date_time.date_naive(),
303            DatePerhapsTime::DateTime(CalendarDateTime::WithTimezone { date_time, tzid: _ }) => {
304                date_time.date()
305            }
306        }
307    }
308}
309
310impl From<CalendarDateTime> for DatePerhapsTime {
311    fn from(dt: CalendarDateTime) -> Self {
312        Self::DateTime(dt)
313    }
314}
315
316impl From<DateTime<Utc>> for DatePerhapsTime {
317    fn from(dt: DateTime<Utc>) -> Self {
318        Self::DateTime(CalendarDateTime::Utc(dt))
319    }
320}
321
322// CANT HAVE NICE THINGS until specializations are stable
323// OR: breaking change and make this the default
324// impl<TZ: chrono::TimeZone<Offset = O>, O: chrono_tz::OffsetName> From<DateTime<TZ>>
325//     for DatePerhapsTime
326// {
327//     fn from(date_time: DateTime<TZ>) -> Self {
328//         Self::DateTime(CalendarDateTime::from(date_time))
329//     }
330// }
331
332#[allow(deprecated)]
333impl From<chrono::Date<Utc>> for DatePerhapsTime {
334    fn from(dt: chrono::Date<Utc>) -> Self {
335        Self::Date(dt.naive_utc())
336    }
337}
338
339impl From<NaiveDateTime> for DatePerhapsTime {
340    fn from(dt: NaiveDateTime) -> Self {
341        Self::DateTime(dt.into())
342    }
343}
344
345#[cfg(feature = "chrono-tz")]
346impl TryFrom<(NaiveDateTime, &str)> for DatePerhapsTime {
347    type Error = String;
348
349    fn try_from(value: (NaiveDateTime, &str)) -> Result<Self, Self::Error> {
350        Ok(Self::DateTime(value.try_into()?))
351    }
352}
353#[cfg(feature = "chrono-tz")]
354impl From<(NaiveDateTime, chrono_tz::Tz)> for DatePerhapsTime {
355    fn from(both: (NaiveDateTime, chrono_tz::Tz)) -> Self {
356        Self::DateTime(both.into())
357    }
358}
359
360impl From<NaiveDate> for DatePerhapsTime {
361    fn from(date: NaiveDate) -> Self {
362        Self::Date(date)
363    }
364}
365
366#[cfg(feature = "time")]
367impl From<time::Date> for DatePerhapsTime {
368    fn from(date: time::Date) -> Self {
369        let (y, o) = date.to_ordinal_date();
370        Self::Date(NaiveDate::from_yo_opt(y, o as u32).expect("bug: invalid date"))
371    }
372}
373
374#[cfg(feature = "time")]
375impl From<time::OffsetDateTime> for DatePerhapsTime {
376    fn from(datetime: time::OffsetDateTime) -> Self {
377        // NOTE: A `time::UtcOffset` doesn't carry information about the timezone other than the offset
378        datetime.to_utc().into()
379    }
380}
381
382#[cfg(feature = "time")]
383impl From<time::UtcDateTime> for DatePerhapsTime {
384    fn from(datetime: time::UtcDateTime) -> Self {
385        Self::DateTime(CalendarDateTime::Utc(
386            DateTime::from_timestamp(datetime.unix_timestamp(), datetime.nanosecond())
387                .expect("bug: invalid time"),
388        ))
389    }
390}
391
392#[cfg(feature = "time")]
393impl From<time::PrimitiveDateTime> for DatePerhapsTime {
394    fn from(datetime: time::PrimitiveDateTime) -> Self {
395        let utc = datetime.assume_utc();
396        Self::DateTime(CalendarDateTime::Floating(
397            NaiveDateTime::from_timestamp_opt(utc.unix_timestamp(), utc.nanosecond())
398                .expect("bug: invalid date"),
399        ))
400    }
401}
402
403#[cfg(feature = "parser")]
404impl TryFrom<&crate::parser::Property<'_>> for DatePerhapsTime {
405    type Error = &'static str;
406
407    fn try_from(value: &crate::parser::Property) -> Result<Self, Self::Error> {
408        let val = value.val.as_ref();
409
410        // UTC is here first because lots of fields MUST be UTC, so it should,
411        // in practice, be more common that others.
412        if let Ok(utc_dt) = Utc.datetime_from_str(val, "%Y%m%dT%H%M%SZ") {
413            return Ok(Self::DateTime(CalendarDateTime::Utc(utc_dt)));
414        };
415
416        if let Ok(naive_date) = NaiveDate::parse_from_str(val, "%Y%m%d") {
417            return Ok(Self::Date(naive_date));
418        };
419
420        if let Ok(naive_dt) = NaiveDateTime::parse_from_str(val, "%Y%m%dT%H%M%S") {
421            if let Some(tz_param) = value.params.iter().find(|p| p.key == "TZID") {
422                if let Some(tzid) = &tz_param.val {
423                    return Ok(Self::DateTime(CalendarDateTime::WithTimezone {
424                        date_time: naive_dt,
425                        tzid: tzid.as_ref().to_string(),
426                    }));
427                } else {
428                    return Err("Found empty TZID param.");
429                }
430            } else {
431                return Ok(Self::DateTime(CalendarDateTime::Floating(naive_dt)));
432            };
433        };
434
435        Err("Value does not look like a known DATE-TIME")
436    }
437}
438
439#[cfg(all(test, feature = "parser"))]
440mod try_from_tests {
441    use super::*;
442
443    #[test]
444    fn try_from_utc_dt() {
445        let prop = crate::parser::Property {
446            name: "TRIGGER".into(),
447            val: "20220716T141500Z".into(),
448            params: vec![crate::parser::Parameter {
449                key: "VALUE".into(),
450                val: Some("DATE-TIME".into()),
451            }],
452        };
453
454        let result = DatePerhapsTime::try_from(&prop);
455        let expected = Utc.ymd(2022, 7, 16).and_hms(14, 15, 0);
456
457        assert_eq!(
458            result,
459            Ok(DatePerhapsTime::DateTime(CalendarDateTime::Utc(expected)))
460        );
461    }
462
463    #[test]
464    fn try_from_naive_date() {
465        let prop = crate::parser::Property {
466            name: "TRIGGER".into(),
467            val: "19970714".into(),
468            params: vec![crate::parser::Parameter {
469                key: "VALUE".into(),
470                val: Some("DATE-TIME".into()),
471            }],
472        };
473
474        let result = DatePerhapsTime::try_from(&prop);
475        let expected = NaiveDate::from_ymd(1997, 7, 14);
476
477        assert_eq!(result, Ok(DatePerhapsTime::Date(expected)));
478    }
479
480    #[test]
481    fn try_from_dt_with_tz() {
482        let prop = crate::parser::Property {
483            name: "TRIGGER".into(),
484            val: "20220716T141500".into(),
485            params: vec![
486                crate::parser::Parameter {
487                    key: "VALUE".into(),
488                    val: Some("DATE-TIME".into()),
489                },
490                crate::parser::Parameter {
491                    key: "TZID".into(),
492                    val: Some("MY-TZ-ID".into()),
493                },
494            ],
495        };
496
497        let result = DatePerhapsTime::try_from(&prop);
498        let expected = NaiveDate::from_ymd(2022, 7, 16).and_hms(14, 15, 0);
499
500        assert_eq!(
501            result,
502            Ok(DatePerhapsTime::DateTime(CalendarDateTime::WithTimezone {
503                date_time: expected,
504                tzid: "MY-TZ-ID".into(),
505            }))
506        );
507    }
508
509    #[test]
510    fn try_from_dt_with_empty_tz() {
511        let prop = crate::parser::Property {
512            name: "TRIGGER".into(),
513            val: "20220716T141500".into(),
514            params: vec![
515                crate::parser::Parameter {
516                    key: "VALUE".into(),
517                    val: Some("DATE-TIME".into()),
518                },
519                crate::parser::Parameter {
520                    key: "TZID".into(),
521                    val: None,
522                },
523            ],
524        };
525
526        let result = DatePerhapsTime::try_from(&prop);
527
528        assert_eq!(result, Err("Found empty TZID param."));
529    }
530
531    #[test]
532    fn try_from_floating_dt() {
533        let prop = crate::parser::Property {
534            name: "TRIGGER".into(),
535            val: "20220716T141500".into(),
536            params: vec![crate::parser::Parameter {
537                key: "VALUE".into(),
538                val: Some("DATE-TIME".into()),
539            }],
540        };
541
542        let result = DatePerhapsTime::try_from(&prop);
543        let expected = NaiveDate::from_ymd(2022, 7, 16).and_hms(14, 15, 0);
544
545        assert_eq!(
546            result,
547            Ok(DatePerhapsTime::DateTime(CalendarDateTime::Floating(
548                expected
549            )))
550        );
551    }
552
553    #[test]
554    fn try_from_non_dt_prop() {
555        let prop = crate::parser::Property {
556            name: "TZNAME".into(),
557            val: "CET".into(),
558            params: vec![],
559        };
560
561        let result = DatePerhapsTime::try_from(&prop);
562
563        assert_eq!(result, Err("Value does not look like a known DATE-TIME"));
564    }
565}