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}