icalendar_duration/
lib.rs

1//! A parser implementation for  durations as defined in RFC5545.
2//!
3//! These are mostly used for alarms, to indicate their time relative to the time of an event or
4//! todo.
5//!
6//! For convenience, [`Rfc5545Duration`](crate::Rfc5545Duration) implements [`Add`](core::ops::Add)
7//! for [`icalendar::DatePerhapsTime`], [`chrono::DateTime`] and
8//! [`chrono::naive::NaiveDateTime`].
9//!
10//! ## Example
11//!
12//! ```
13//! use chrono::TimeZone;
14//! use chrono::Utc;
15//!
16//! let duration = icalendar_duration::parse("PT24H")?;
17//! let dt = Utc.ymd(2022, 9, 1).and_hms(22, 9, 14);
18//!
19//! assert_eq!(dt + duration, Utc.ymd(2022, 9, 2).and_hms(22, 9, 14));
20//! # Ok::<(), Box<dyn std::error::Error>>(())
21//! ```
22
23use std::fmt::Display;
24use std::ops::{Add, MulAssign, Neg, Sub};
25
26use chrono::{DateTime, Duration, NaiveDateTime, TimeZone};
27use icalendar::{CalendarDateTime, DatePerhapsTime};
28
29mod parser;
30
31pub use parser::parse;
32
33/// The sign for a duration (which can be negative or positive).
34///
35/// The default is positive, as the absence of an explicit sign implies positive.
36#[derive(Default, PartialEq, Eq, Debug)]
37pub enum Sign {
38    #[default]
39    Positive,
40    Negative,
41}
42
43/// Converts a "+" or "-" into a `Sign`.
44///
45/// # Quirks
46///
47/// Will return `Sign:Positive` for invalid input.
48impl From<Option<char>> for Sign {
49    fn from(value: Option<char>) -> Self {
50        if Some('-') == value {
51            Sign::Negative
52        } else {
53            Sign::Positive
54        }
55    }
56}
57
58impl Display for Sign {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        let sign = match self {
61            Sign::Positive => "+",
62            Sign::Negative => "-",
63        };
64        write!(f, "{}", sign)
65    }
66}
67
68impl Neg for Sign {
69    type Output = Sign;
70
71    fn neg(self) -> Self::Output {
72        match self {
73            Sign::Positive => Sign::Negative,
74            Sign::Negative => Sign::Positive,
75        }
76    }
77}
78
79/// The Duration specified in RFC554.
80///
81/// This has a few quirks that ultimately, make it incompatible with the Duration classes from both
82/// Chrono and the stdlib. The main difference is how DST transition and leap seconds are handled.
83///
84/// From Section 3.3.6:
85///
86/// > The duration of a week or a day depends on its position in the calendar.  In the case of
87/// > discontinuities in the time scale, such as the change from standard time to daylight time and
88/// > back, the computation of the exact duration requires the subtraction or addition of the
89/// > change of duration of the discontinuity.  Leap seconds MUST NOT be considered when computing
90/// > an exact duration. When computing an exact duration, the greatest order time components MUST
91/// > be added first, that is, the number of days MUST be added first, followed by the number of
92/// > hours, number of minutes, and number of seconds.
93///
94/// This will usually behave rather intuitively for users. For an event that occurs at 16:00hs on
95/// the Monday after daylight saving, an alarm set 1 day before will trigger at 16hs, even though
96/// this is technically 25 (or 23) hours before the event.
97///
98/// # Caveats
99///
100/// The specification indicates that numerical values are "one or more digits". Technically,
101/// setting an alarm one million years before an event is perfectly valid. However, modelling this
102/// would require arbitrary precision numbers or using absurdly large sized integers. These
103/// scenarios are valid but unrealistic, and are deliberately not supported in the interest of
104/// better performance. Any `1*DIGIT` is restricted to an [u16].
105///
106/// Parsing and re-encoding a duration will sometimes yield different results. In particular the
107/// `+` sign is always dropped (it is the implicit default) and units with value `0` are dropped.
108/// E.g.: `-P0DT1H` will become `-PT1H`, and `+PT1H` will become `PT1H`. These are equivalent.
109///
110/// # See also
111///
112/// [`icalendar_duration::parse`](crate::parse).
113#[derive(Default, PartialEq, Eq, Debug)]
114pub struct Rfc5545Duration {
115    sign: Sign,
116    weeks: i64,
117    days: i64,
118    hours: i64,
119    minutes: i64,
120    seconds: i64,
121}
122
123impl Display for Rfc5545Duration {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        // XXX: Modeling an absent sign is required to avoid altering explicit "PLUS".
126        if self.sign == Sign::Negative {
127            write!(f, "-")?;
128        }
129        write!(f, "P")?;
130        if self.weeks > 0 {
131            write!(f, "{}W", self.weeks)?;
132        }
133        if self.days > 0 {
134            write!(f, "{}D", self.days)?;
135        }
136        if self.hours > 0 || self.minutes > 0 || self.seconds > 0 {
137            write!(f, "T")?;
138            if self.hours > 0 {
139                write!(f, "{}H", self.hours)?;
140            }
141            if self.minutes > 0 {
142                write!(f, "{}M", self.minutes)?;
143            }
144            if self.seconds > 0 {
145                write!(f, "{}S", self.seconds)?;
146            }
147        }
148
149        Ok(())
150    }
151}
152
153impl MulAssign<Sign> for Rfc5545Duration {
154    #[inline]
155    fn mul_assign(&mut self, rhs: Sign) {
156        self.sign = rhs;
157    }
158}
159
160impl Rfc5545Duration {
161    /// Return this duration in seconds.
162    ///
163    /// Care should be taken not to use this on timezone-aware datetimes, since the results would
164    /// not be correct.
165    fn absolute_seconds(&self) -> i64 {
166        self.days
167            .saturating_mul(24)
168            .saturating_add(self.hours)
169            .saturating_mul(60)
170            .saturating_add(self.minutes)
171            .saturating_mul(60)
172            .saturating_add(self.seconds)
173    }
174}
175
176/// Changes the sign from positive to negative and vice versa.
177///
178/// Usage of this function is discouraged, since it creates a copy of the entire struct.
179impl Neg for Rfc5545Duration {
180    type Output = Rfc5545Duration;
181
182    fn neg(self) -> Self::Output {
183        Self::Output {
184            sign: -self.sign,
185            ..self
186        }
187    }
188}
189
190/// Adds a duration to [`DatePerhapsTime`].
191///
192/// A [`DatePerhapsTime::Date`] is equivalent to one with time `00:00:00`. From rfc5545,
193/// section-3.8.6.3:
194///
195/// > Alarms specified in an event or to-do that is defined in terms of a DATE value
196/// > type will be triggered relative to 00:00:00 of the user's configured time zone
197/// > on the specified date, or relative to 00:00:00 UTC on the specified date if no
198/// > configured time zone can be found for the user.
199impl Add<Rfc5545Duration> for DatePerhapsTime {
200    type Output = CalendarDateTime;
201
202    fn add(self, rhs: Rfc5545Duration) -> Self::Output {
203        match self {
204            DatePerhapsTime::DateTime(CalendarDateTime::Floating(naive)) => {
205                CalendarDateTime::Floating(naive + rhs)
206            }
207            DatePerhapsTime::DateTime(CalendarDateTime::Utc(aware)) => {
208                CalendarDateTime::Utc(aware + rhs)
209            }
210            DatePerhapsTime::DateTime(CalendarDateTime::WithTimezone {
211                date_time: naive,
212                tzid,
213            }) => CalendarDateTime::WithTimezone {
214                date_time: naive + rhs,
215                tzid,
216            },
217            DatePerhapsTime::Date(date) => CalendarDateTime::Floating(date.and_hms(0, 0, 0) + rhs),
218        }
219    }
220}
221
222impl<Tz: TimeZone> Add<Rfc5545Duration> for DateTime<Tz> {
223    type Output = DateTime<Tz>;
224
225    /// # Panics
226    ///
227    /// Panics if the resulting time does not exist in the datetime's timezone.
228    fn add(self, rhs: Rfc5545Duration) -> Self::Output {
229        // Converting to naive AND THEN operating on that makes sure we ignore any DST transition,
230        // leap seconds and other discontinuities.
231        let result = self.naive_local() + rhs;
232
233        self.timezone()
234            .from_local_datetime(&result)
235            .earliest()
236            .unwrap()
237    }
238}
239
240impl Add<Rfc5545Duration> for NaiveDateTime {
241    type Output = NaiveDateTime;
242
243    fn add(self, rhs: Rfc5545Duration) -> Self::Output {
244        // Converting to naive AND THEN operating on that makes sure we ignore any DST transition,
245        // leap seconds and other discontinuities.
246        match rhs.sign {
247            Sign::Positive => self + Duration::seconds(rhs.absolute_seconds()),
248            Sign::Negative => self - Duration::seconds(rhs.absolute_seconds()),
249        }
250    }
251}
252
253impl<Tz: TimeZone> Sub<Rfc5545Duration> for DateTime<Tz> {
254    type Output = DateTime<Tz>;
255
256    fn sub(self, rhs: Rfc5545Duration) -> Self::Output {
257        let result = self.naive_local() - rhs;
258
259        self.timezone()
260            .from_local_datetime(&result)
261            .earliest()
262            .unwrap()
263    }
264}
265
266impl Sub<Rfc5545Duration> for NaiveDateTime {
267    type Output = NaiveDateTime;
268
269    fn sub(self, rhs: Rfc5545Duration) -> Self::Output {
270        // Converting to naive AND THEN operating on that makes sure we ignore any DST transition,
271        // leap seconds and other discontinuities.
272        //
273        // See [`Rfc5545Duration`]
274        //
275        // # Panics
276        //
277        // Panics if the resulting time does not exist in the given timezone.
278        match rhs.sign {
279            Sign::Positive => self - Duration::seconds(rhs.absolute_seconds()),
280            Sign::Negative => self + Duration::seconds(rhs.absolute_seconds()),
281        }
282    }
283}