Skip to main content

ical/value/
duration.rs

1//! # Duration value
2//!
3//! The decoded duration value kind.
4//!
5//! Backs `DURATION` and the duration form of other properties (RFC 5545
6//! 3.3.6): an ISO 8601 duration such as `P15DT5H0M20S` or `-P1D`, always
7//! prefixed by `P` (with an optional leading sign). The value is kept as its
8//! raw text, so it goes back on the wire exactly as it arrived.
9//!
10//! [`IcalDuration::seconds`] reads it as a number and
11//! [`IcalDuration::from_seconds`] writes one back, which is all the arithmetic
12//! the grammar admits: it carries no month and no year, so no calendar is
13//! needed to say how long one is.
14
15use alloc::{borrow::Cow, format, string::String};
16
17/// A decoded duration value (ISO 8601 `P...`), kept as its raw text.
18#[derive(Clone, Debug, Default, PartialEq, Eq)]
19pub struct IcalDuration<'a>(pub Cow<'a, str>);
20
21impl IcalDuration<'_> {
22    /// The duration in seconds, a leading `-` making it negative.
23    ///
24    /// `None` for anything that is not the RFC 5545 3.3.6 `P...` form, parsing
25    /// being liberal enough elsewhere to let one through. A week counts as
26    /// seven days; a month and a year are not part of the grammar, so nothing
27    /// here needs a calendar to answer.
28    pub fn seconds(&self) -> Option<i64> {
29        let span = self.0.as_ref();
30
31        let (sign, span) = match span.strip_prefix('-') {
32            Some(span) => (-1, span),
33            None => (1, span.strip_prefix('+').unwrap_or(span)),
34        };
35
36        let mut total: i64 = 0;
37        let mut amount = String::new();
38
39        for character in span.strip_prefix('P')?.chars() {
40            if character.is_ascii_digit() {
41                amount.push(character);
42                continue;
43            }
44
45            // NOTE: The T only separates the date part from the time part;
46            // every other letter closes the number before it.
47            if character == 'T' {
48                continue;
49            }
50
51            let unit = match character {
52                'W' => 604_800,
53                'D' => 86_400,
54                'H' => 3_600,
55                'M' => 60,
56                'S' => 1,
57                _ => return None,
58            };
59
60            total += amount.parse::<i64>().ok()? * unit;
61            amount.clear();
62        }
63
64        Some(sign * total)
65    }
66
67    /// A number of seconds as a duration, the inverse of
68    /// [`seconds`](Self::seconds).
69    ///
70    /// Days are the largest unit written: a week is spelled in days, since
71    /// `P7D` and `P1W` are the same length and only one of them survives a
72    /// round trip through a number.
73    pub fn from_seconds(seconds: i64) -> IcalDuration<'static> {
74        let sign = match seconds < 0 {
75            true => "-",
76            false => "",
77        };
78
79        let seconds = seconds.unsigned_abs();
80        let (days, rest) = (seconds / 86_400, seconds % 86_400);
81        let (hours, rest) = (rest / 3_600, rest % 3_600);
82        let (minutes, seconds) = (rest / 60, rest % 60);
83
84        let mut duration = String::from(sign);
85        duration.push('P');
86
87        if days > 0 {
88            duration.push_str(&format!("{days}D"));
89        }
90
91        if hours == 0 && minutes == 0 && seconds == 0 {
92            // NOTE: A whole number of days needs no time part, but a
93            // zero-length span still has to spell something.
94            if days == 0 {
95                duration.push_str("T0S");
96            }
97
98            return IcalDuration(Cow::Owned(duration));
99        }
100
101        duration.push('T');
102
103        for (amount, unit) in [(hours, 'H'), (minutes, 'M'), (seconds, 'S')] {
104            if amount > 0 {
105                duration.push_str(&format!("{amount}{unit}"));
106            }
107        }
108
109        IcalDuration(Cow::Owned(duration))
110    }
111}
112
113impl<'a> From<&'a str> for IcalDuration<'a> {
114    fn from(value: &'a str) -> Self {
115        Self(Cow::Borrowed(value))
116    }
117}
118
119impl From<String> for IcalDuration<'_> {
120    fn from(value: String) -> Self {
121        Self(Cow::Owned(value))
122    }
123}
124
125impl<'a> From<Cow<'a, str>> for IcalDuration<'a> {
126    fn from(value: Cow<'a, str>) -> Self {
127        Self(value)
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use crate::value::duration::IcalDuration;
134
135    #[test]
136    fn reads_every_unit_the_grammar_admits() {
137        assert_eq!(IcalDuration::from("P1W").seconds(), Some(604_800));
138        assert_eq!(
139            IcalDuration::from("P15DT5H0M20S").seconds(),
140            Some(1_314_020)
141        );
142        assert_eq!(IcalDuration::from("-P1D").seconds(), Some(-86_400));
143        assert_eq!(IcalDuration::from("PT0S").seconds(), Some(0));
144    }
145
146    #[test]
147    fn refuses_what_is_not_a_duration() {
148        assert_eq!(IcalDuration::from("").seconds(), None);
149        assert_eq!(IcalDuration::from("1D").seconds(), None);
150        assert_eq!(IcalDuration::from("P1Y").seconds(), None);
151    }
152
153    #[test]
154    fn a_duration_written_from_seconds_reads_back_as_those_seconds() {
155        for seconds in [0, 1, 59, 60, 3_600, 86_400, 1_314_020, -86_400, -90] {
156            let written = IcalDuration::from_seconds(seconds);
157
158            assert_eq!(written.seconds(), Some(seconds), "{}", written.0);
159        }
160    }
161
162    #[test]
163    fn a_week_comes_back_spelled_in_days() {
164        assert_eq!(&*IcalDuration::from_seconds(604_800).0, "P7D");
165    }
166}