Skip to main content

ical/
recur.rs

1//! # Recurrence expansion
2//!
3//! The typed recurrence rule and its occurrence iterator (RFC 5545 3.3.10,
4//! extended by RFC 7529).
5//!
6//! [`IcalRecur`](crate::value::recur::IcalRecur) keeps a rule as its raw text,
7//! which is what byte-faithful round-tripping needs and all a store or a codec
8//! ever wants. This module is the opt-in layer above it:
9//! [`IcalRecurRule::parse`] decodes that text into typed parts, and
10//! [`expand::IcalRecurExpand`] turns a rule plus a start into the dates it
11//! actually denotes.
12//!
13//! A rule is only ever part of the answer. What a component actually happens on
14//! is its whole *recurrence set*: `DTSTART` plus every `RRULE` and `RDATE`,
15//! minus every `EXDATE` and `EXRULE`, with the `RECURRENCE-ID` overrides
16//! applied. That is [`set::IcalRecurSet`], and it is what a client wants.
17//!
18//! ## Civil times, no time zones
19//!
20//! RFC 5545 defines expansion on the local wall-clock time of `DTSTART`: a
21//! daily rule on a zoned start recurs at the same local time every day, and a
22//! UTC start is simply the case where local is UTC. Expansion therefore never
23//! needs a UTC offset, and this module never resolves one. Occurrences are
24//! civil [`IcalRecurDateTime`]s, and turning one into an instant, along with
25//! the time-zone database, the invalid local times of a spring-forward and the
26//! ambiguous ones of a fall-back, belongs to the caller at that boundary.
27//!
28//! ## Liberal in, strict out
29//!
30//! Parsing follows the crate's Postel's law: an unrecognised rule part is
31//! ignored rather than refused, since RFC 5545 requires exactly that, and a
32//! malformed value inside a part the module does claim to understand is an
33//! error. The typed rule is not the round-trip path (the syntax tree is), so
34//! ignored parts are dropped rather than carried.
35
36use alloc::vec::Vec;
37use core::{fmt, num::ParseIntError, str::FromStr};
38
39mod civil;
40pub mod expand;
41pub mod set;
42pub mod validate;
43
44pub use self::validate::{IcalRecurPart, IcalRecurRuleProblem};
45
46/// A civil date and time, with no time zone and no offset.
47///
48/// The unit both ends of this module speak: the start an expansion runs from,
49/// the bound `UNTIL` sets, and every occurrence yielded. Comparison is
50/// lexicographic through the derived ordering, which is the chronological
51/// order for civil values.
52#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
53pub struct IcalRecurDateTime {
54    /// The proleptic Gregorian year, negative before 1 BCE.
55    pub year: i32,
56    /// The month, 1 to 12.
57    pub month: u8,
58    /// The day of the month, 1 to 31.
59    pub day: u8,
60    /// The hour, 0 to 23.
61    pub hour: u8,
62    /// The minute, 0 to 59.
63    pub minute: u8,
64    /// The second, 0 to 60, leap seconds included.
65    pub second: u8,
66}
67
68impl IcalRecurDateTime {
69    /// The second this civil date-time names, 1970-01-01T00:00:00 being zero.
70    ///
71    /// Civil throughout: no offset is applied, so this counts seconds on the
72    /// wall clock, not on any timeline. It is the arithmetic unit for a
73    /// duration between two civil times.
74    pub const fn seconds(&self) -> i64 {
75        civil::days_from_civil(self.year, self.month, self.day) * 86_400
76            + self.hour as i64 * 3600
77            + self.minute as i64 * 60
78            + self.second as i64
79    }
80
81    /// The civil date-time a second count names, the inverse of
82    /// [`seconds`](Self::seconds).
83    pub const fn from_seconds(seconds: i64) -> Self {
84        let days = seconds.div_euclid(86_400);
85        let rest = seconds.rem_euclid(86_400);
86        let (year, month, day) = civil::civil_from_days(days);
87
88        Self {
89            year,
90            month,
91            day,
92            hour: (rest / 3600) as u8,
93            minute: (rest % 3600 / 60) as u8,
94            second: (rest % 60) as u8,
95        }
96    }
97
98    /// Builds a date at midnight.
99    pub const fn date(year: i32, month: u8, day: u8) -> Self {
100        Self {
101            year,
102            month,
103            day,
104            hour: 0,
105            minute: 0,
106            second: 0,
107        }
108    }
109
110    /// Parses the `DATE` and `DATE-TIME` forms of RFC 5545 3.3.4 and 3.3.5.
111    ///
112    /// Accepts `YYYYMMDD`, `YYYYMMDDTHHMMSS` and the `Z`-suffixed UTC form,
113    /// the three spellings `UNTIL` admits. The suffix is consumed and not
114    /// recorded: this type is civil, and a UTC `UNTIL` against a zoned start
115    /// is the caller's to convert (see [`IcalRecurRule::until`]).
116    pub fn parse(value: &str) -> Result<Self, IcalRecurRuleError> {
117        let bytes = value.as_bytes();
118        let naive = match bytes.len() {
119            8 => value,
120            15 => value,
121            16 if bytes[15] == b'Z' || bytes[15] == b'z' => &value[..15],
122            _ => return Err(IcalRecurRuleError::DateTime),
123        };
124        if naive.len() == 15 && !matches!(naive.as_bytes()[8], b'T' | b't') {
125            return Err(IcalRecurRuleError::DateTime);
126        }
127
128        let num = |range: core::ops::Range<usize>| -> Result<u32, IcalRecurRuleError> {
129            naive
130                .get(range)
131                .ok_or(IcalRecurRuleError::DateTime)?
132                .parse()
133                .map_err(|_| IcalRecurRuleError::DateTime)
134        };
135
136        let mut parsed = Self::date(num(0..4)? as i32, num(4..6)? as u8, num(6..8)? as u8);
137        if naive.len() == 15 {
138            parsed.hour = num(9..11)? as u8;
139            parsed.minute = num(11..13)? as u8;
140            parsed.second = num(13..15)? as u8;
141        }
142
143        let valid_date = (1..=12).contains(&parsed.month)
144            && parsed.day >= 1
145            && parsed.day <= civil::days_in_month(parsed.year, parsed.month);
146        let valid_time = parsed.hour < 24 && parsed.minute < 60 && parsed.second < 61;
147        if !valid_date || !valid_time {
148            return Err(IcalRecurRuleError::DateTime);
149        }
150
151        Ok(parsed)
152    }
153}
154
155/// The frequency a rule repeats at (`FREQ`).
156///
157/// The one required rule part, and the axis every `BY` part is read against:
158/// the same part expands the candidate set at one frequency and narrows it at
159/// another (RFC 5545 3.3.10).
160#[derive(Clone, Copy, Debug, PartialEq, Eq)]
161pub enum IcalRecurFreq {
162    /// Every second.
163    Secondly,
164    /// Every minute.
165    Minutely,
166    /// Every hour.
167    Hourly,
168    /// Every day.
169    Daily,
170    /// Every week, starting on the rule's `WKST`.
171    Weekly,
172    /// Every month.
173    Monthly,
174    /// Every year.
175    Yearly,
176}
177
178impl FromStr for IcalRecurFreq {
179    type Err = IcalRecurRuleError;
180
181    fn from_str(s: &str) -> Result<Self, Self::Err> {
182        if s.eq_ignore_ascii_case("SECONDLY") {
183            Ok(Self::Secondly)
184        } else if s.eq_ignore_ascii_case("MINUTELY") {
185            Ok(Self::Minutely)
186        } else if s.eq_ignore_ascii_case("HOURLY") {
187            Ok(Self::Hourly)
188        } else if s.eq_ignore_ascii_case("DAILY") {
189            Ok(Self::Daily)
190        } else if s.eq_ignore_ascii_case("WEEKLY") {
191            Ok(Self::Weekly)
192        } else if s.eq_ignore_ascii_case("MONTHLY") {
193            Ok(Self::Monthly)
194        } else if s.eq_ignore_ascii_case("YEARLY") {
195            Ok(Self::Yearly)
196        } else {
197            Err(IcalRecurRuleError::Freq)
198        }
199    }
200}
201
202/// A day of the week, as `BYDAY` and `WKST` spell it.
203///
204/// Ordered from Sunday so the discriminant matches the day number the civil
205/// arithmetic produces, which is what makes the weekday of a date a cast.
206#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
207pub enum IcalRecurWeekday {
208    /// `SU`.
209    Sunday = 0,
210    /// `MO`.
211    Monday = 1,
212    /// `TU`.
213    Tuesday = 2,
214    /// `WE`.
215    Wednesday = 3,
216    /// `TH`.
217    Thursday = 4,
218    /// `FR`.
219    Friday = 5,
220    /// `SA`.
221    Saturday = 6,
222}
223
224impl FromStr for IcalRecurWeekday {
225    type Err = IcalRecurRuleError;
226
227    fn from_str(s: &str) -> Result<Self, Self::Err> {
228        if s.eq_ignore_ascii_case("SU") {
229            Ok(Self::Sunday)
230        } else if s.eq_ignore_ascii_case("MO") {
231            Ok(Self::Monday)
232        } else if s.eq_ignore_ascii_case("TU") {
233            Ok(Self::Tuesday)
234        } else if s.eq_ignore_ascii_case("WE") {
235            Ok(Self::Wednesday)
236        } else if s.eq_ignore_ascii_case("TH") {
237            Ok(Self::Thursday)
238        } else if s.eq_ignore_ascii_case("FR") {
239            Ok(Self::Friday)
240        } else if s.eq_ignore_ascii_case("SA") {
241            Ok(Self::Saturday)
242        } else {
243            Err(IcalRecurRuleError::Weekday)
244        }
245    }
246}
247
248/// One `BYDAY` entry: a weekday, optionally ordinal.
249///
250/// The ordinal counts occurrences of that weekday inside the period the
251/// frequency defines, forward when positive and backward when negative, so
252/// `-1SU` is the last Sunday of a monthly or yearly period. It is meaningless
253/// at any other frequency and RFC 5545 forbids it there.
254#[derive(Clone, Copy, Debug, PartialEq, Eq)]
255pub struct IcalRecurWeekdayNum {
256    /// The occurrence within the period, `None` when unqualified.
257    pub ordinal: Option<i16>,
258    /// The weekday itself.
259    pub weekday: IcalRecurWeekday,
260}
261
262impl FromStr for IcalRecurWeekdayNum {
263    type Err = IcalRecurRuleError;
264
265    fn from_str(s: &str) -> Result<Self, Self::Err> {
266        // NOTE: The weekday is the last two characters, which is not the last
267        // two bytes: a rule is text off the wire, so splitting on a byte offset
268        // would cut a multi-byte character in half and panic.
269        let split = s.char_indices().rev().nth(1).map_or(0, |(index, _)| index);
270        let (ordinal, weekday) = s.split_at(split);
271        let weekday = weekday.parse()?;
272        let ordinal = match ordinal {
273            "" => None,
274            ordinal => Some(ordinal.parse().map_err(IcalRecurRuleError::Ordinal)?),
275        };
276        Ok(Self { ordinal, weekday })
277    }
278}
279
280/// How RFC 7529 resolves a date its calendar scale cannot express.
281///
282/// Only ever consulted for a non-Gregorian `RSCALE`, or for a leap day a
283/// Gregorian year lacks. Parsed so a rule carrying it round-trips through the
284/// typed form; expansion honours [`Self::Omit`], the default, and treats the
285/// other two as omission until a non-Gregorian scale is supported.
286#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
287pub enum IcalRecurSkip {
288    /// Drop the unrepresentable date. The default.
289    #[default]
290    Omit,
291    /// Move it back to the closest valid date.
292    Backward,
293    /// Move it forward to the closest valid date.
294    Forward,
295}
296
297impl FromStr for IcalRecurSkip {
298    type Err = IcalRecurRuleError;
299
300    fn from_str(s: &str) -> Result<Self, Self::Err> {
301        if s.eq_ignore_ascii_case("OMIT") {
302            Ok(Self::Omit)
303        } else if s.eq_ignore_ascii_case("BACKWARD") {
304            Ok(Self::Backward)
305        } else if s.eq_ignore_ascii_case("FORWARD") {
306            Ok(Self::Forward)
307        } else {
308            Err(IcalRecurRuleError::Skip)
309        }
310    }
311}
312
313/// A decoded recurrence rule.
314///
315/// Every part RFC 5545 3.3.10 defines, plus the RFC 7529 extensions, in the
316/// shape expansion consumes. Absent parts stay empty or `None` rather than
317/// being defaulted from a start date: the defaulting rules depend on the
318/// frequency and belong to [`expand::IcalRecurExpand`], not to the decoded
319/// value.
320#[derive(Clone, Debug, PartialEq, Eq)]
321pub struct IcalRecurRule {
322    /// The frequency, the only required part.
323    pub freq: IcalRecurFreq,
324    /// The last date the rule may yield, inclusive.
325    ///
326    /// NOTE: RFC 5545 requires this to be UTC whenever `DTSTART` is zoned,
327    /// while expansion is civil throughout. A caller whose start carries a
328    /// `TZID` therefore converts this bound into that zone before expanding;
329    /// left unconverted it is off by the zone's offset at the boundary.
330    pub until: Option<IcalRecurDateTime>,
331    /// How many occurrences the rule yields in total, the start included.
332    pub count: Option<u32>,
333    /// How many frequency periods separate two occurrences, at least one.
334    pub interval: u32,
335    /// `BYSECOND`, seconds of the minute, 0 to 60.
336    pub by_second: Vec<u8>,
337    /// `BYMINUTE`, minutes of the hour, 0 to 59.
338    pub by_minute: Vec<u8>,
339    /// `BYHOUR`, hours of the day, 0 to 23.
340    pub by_hour: Vec<u8>,
341    /// `BYDAY`, weekdays, each optionally ordinal.
342    pub by_day: Vec<IcalRecurWeekdayNum>,
343    /// `BYMONTHDAY`, days of the month, 1 to 31 or -31 to -1.
344    pub by_month_day: Vec<i8>,
345    /// `BYYEARDAY`, days of the year, 1 to 366 or -366 to -1.
346    pub by_year_day: Vec<i16>,
347    /// `BYWEEKNO`, weeks of the year, 1 to 53 or -53 to -1.
348    pub by_week_no: Vec<i8>,
349    /// `BYMONTH`, months of the year, 1 to 12.
350    pub by_month: Vec<u8>,
351    /// `BYSETPOS`, positions within one period's candidate set.
352    ///
353    /// Applied last, once the other parts have produced and ordered the
354    /// candidates of a period, counting from the start when positive and from
355    /// the end when negative.
356    pub by_set_pos: Vec<i16>,
357    /// `WKST`, the weekday a week starts on. Monday unless stated.
358    pub week_start: IcalRecurWeekday,
359    /// `RSCALE`, the RFC 7529 calendar scale, uppercased.
360    ///
361    /// Anything other than `GREGORIAN` is decoded and then refused by
362    /// expansion, which implements the Gregorian scale alone.
363    pub scale: Option<alloc::string::String>,
364    /// `SKIP`, the RFC 7529 resolution of an unrepresentable date.
365    pub skip: IcalRecurSkip,
366}
367
368impl IcalRecurRule {
369    /// Decodes a rule from the raw text of a `RRULE` or `EXRULE` value.
370    ///
371    /// Unrecognised parts are ignored, as RFC 5545 requires; a malformed
372    /// value inside a known part is an error. `FREQ` is mandatory, and
373    /// `UNTIL` with `COUNT` is refused since the RFC declares them mutually
374    /// exclusive.
375    pub fn parse(value: &str) -> Result<Self, IcalRecurRuleError> {
376        let mut freq = None;
377        let mut rule = Self {
378            freq: IcalRecurFreq::Daily,
379            until: None,
380            count: None,
381            interval: 1,
382            by_second: Vec::new(),
383            by_minute: Vec::new(),
384            by_hour: Vec::new(),
385            by_day: Vec::new(),
386            by_month_day: Vec::new(),
387            by_year_day: Vec::new(),
388            by_week_no: Vec::new(),
389            by_month: Vec::new(),
390            by_set_pos: Vec::new(),
391            week_start: IcalRecurWeekday::Monday,
392            scale: None,
393            skip: IcalRecurSkip::Omit,
394        };
395
396        for part in value.split(';').filter(|part| !part.is_empty()) {
397            let Some((name, raw)) = part.split_once('=') else {
398                continue;
399            };
400            let name = name.trim();
401            let raw = raw.trim();
402
403            if name.eq_ignore_ascii_case("FREQ") {
404                freq = Some(raw.parse()?);
405            } else if name.eq_ignore_ascii_case("UNTIL") {
406                rule.until = Some(IcalRecurDateTime::parse(raw)?);
407            } else if name.eq_ignore_ascii_case("COUNT") {
408                rule.count = Some(raw.parse().map_err(IcalRecurRuleError::Count)?);
409            } else if name.eq_ignore_ascii_case("INTERVAL") {
410                let interval = raw.parse().map_err(IcalRecurRuleError::Interval)?;
411                if interval == 0 {
412                    return Err(IcalRecurRuleError::IntervalZero);
413                }
414                rule.interval = interval;
415            } else if name.eq_ignore_ascii_case("BYSECOND") {
416                rule.by_second = numbers(raw, 0, 60)?;
417            } else if name.eq_ignore_ascii_case("BYMINUTE") {
418                rule.by_minute = numbers(raw, 0, 59)?;
419            } else if name.eq_ignore_ascii_case("BYHOUR") {
420                rule.by_hour = numbers(raw, 0, 23)?;
421            } else if name.eq_ignore_ascii_case("BYDAY") {
422                rule.by_day = weekday_nums(raw)?;
423            } else if name.eq_ignore_ascii_case("BYMONTHDAY") {
424                rule.by_month_day = signed(raw, 1, 31)?;
425            } else if name.eq_ignore_ascii_case("BYYEARDAY") {
426                rule.by_year_day = signed(raw, 1, 366)?;
427            } else if name.eq_ignore_ascii_case("BYWEEKNO") {
428                rule.by_week_no = signed(raw, 1, 53)?;
429            } else if name.eq_ignore_ascii_case("BYMONTH") {
430                rule.by_month = numbers(raw, 1, 12)?;
431            } else if name.eq_ignore_ascii_case("BYSETPOS") {
432                rule.by_set_pos = signed(raw, 1, 366)?;
433            } else if name.eq_ignore_ascii_case("WKST") {
434                rule.week_start = raw.parse()?;
435            } else if name.eq_ignore_ascii_case("RSCALE") {
436                rule.scale = Some(raw.to_ascii_uppercase());
437            } else if name.eq_ignore_ascii_case("SKIP") {
438                rule.skip = raw.parse()?;
439            }
440        }
441
442        // NOTE: A rule carrying both UNTIL and COUNT breaks RFC 5545 3.3.10,
443        // but it is still a rule, and refusing it here would be strictness
444        // applied on the way in. `validate` is where that is reported, as
445        // `UntilWithCount`; expansion honours whichever bound comes first.
446        rule.freq = freq.ok_or(IcalRecurRuleError::FreqMissing)?;
447
448        Ok(rule)
449    }
450}
451
452/// Parses a comma-separated list of unsigned numbers, bounds inclusive.
453fn numbers<T>(raw: &str, min: i32, max: i32) -> Result<Vec<T>, IcalRecurRuleError>
454where
455    T: TryFrom<i32>,
456{
457    let mut parsed = Vec::new();
458    for item in raw.split(',').filter(|item| !item.is_empty()) {
459        let value: i32 = item.trim().parse().map_err(IcalRecurRuleError::Number)?;
460        if value < min || value > max {
461            return Err(IcalRecurRuleError::Range);
462        }
463        parsed.push(T::try_from(value).map_err(|_| IcalRecurRuleError::Range)?);
464    }
465    Ok(parsed)
466}
467
468/// Parses a comma-separated list of signed numbers, zero excluded.
469///
470/// The bounds apply to the magnitude, since every signed `BY` part admits the
471/// same range forward and backward.
472fn signed<T>(raw: &str, min: i32, max: i32) -> Result<Vec<T>, IcalRecurRuleError>
473where
474    T: TryFrom<i32>,
475{
476    let mut parsed = Vec::new();
477    for item in raw.split(',').filter(|item| !item.is_empty()) {
478        let value: i32 = item.trim().parse().map_err(IcalRecurRuleError::Number)?;
479        let magnitude = value.unsigned_abs() as i32;
480        if value == 0 || magnitude < min || magnitude > max {
481            return Err(IcalRecurRuleError::Range);
482        }
483        parsed.push(T::try_from(value).map_err(|_| IcalRecurRuleError::Range)?);
484    }
485    Ok(parsed)
486}
487
488/// Parses a comma-separated `BYDAY` list.
489fn weekday_nums(raw: &str) -> Result<Vec<IcalRecurWeekdayNum>, IcalRecurRuleError> {
490    let mut parsed = Vec::new();
491    for item in raw.split(',').filter(|item| !item.is_empty()) {
492        parsed.push(item.trim().parse()?);
493    }
494    Ok(parsed)
495}
496
497/// Everything that can go wrong decoding a rule.
498#[derive(Clone, Debug, PartialEq, Eq)]
499pub enum IcalRecurRuleError {
500    /// `FREQ` names no frequency this module knows.
501    Freq,
502    /// The rule carries no `FREQ`, which RFC 5545 requires.
503    FreqMissing,
504    /// A weekday is not one of the seven two-letter codes.
505    Weekday,
506    /// `SKIP` names no RFC 7529 resolution.
507    Skip,
508    /// `UNTIL` is not a `DATE` or `DATE-TIME`, or names no real instant.
509    DateTime,
510    /// `COUNT` is not a number.
511    Count(ParseIntError),
512    /// `INTERVAL` is not a number.
513    Interval(ParseIntError),
514    /// `INTERVAL` is zero, which would never advance.
515    IntervalZero,
516    /// A `BYDAY` ordinal is not a number.
517    Ordinal(ParseIntError),
518    /// A `BY` part holds something that is not a number.
519    Number(ParseIntError),
520    /// A `BY` part holds a number outside the range RFC 5545 allows.
521    Range,
522}
523
524impl fmt::Display for IcalRecurRuleError {
525    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
526        match self {
527            Self::Freq => write!(f, "Unknown recurrence frequency"),
528            Self::FreqMissing => write!(f, "Missing recurrence frequency"),
529            Self::Weekday => write!(f, "Unknown recurrence weekday"),
530            Self::Skip => write!(f, "Unknown recurrence skip"),
531            Self::DateTime => write!(f, "Invalid recurrence date or date-time"),
532            Self::Count(err) => write!(f, "Invalid recurrence count: {err}"),
533            Self::Interval(err) => write!(f, "Invalid recurrence interval: {err}"),
534            Self::IntervalZero => write!(f, "Recurrence interval cannot be zero"),
535            Self::Ordinal(err) => write!(f, "Invalid recurrence weekday ordinal: {err}"),
536            Self::Number(err) => write!(f, "Invalid recurrence number: {err}"),
537            Self::Range => write!(f, "Recurrence number out of range"),
538        }
539    }
540}
541
542#[cfg(test)]
543mod tests {
544    use alloc::vec;
545
546    use crate::recur::*;
547
548    #[test]
549    fn parses_every_part() {
550        let rule = IcalRecurRule::parse(
551            "FREQ=YEARLY;INTERVAL=2;BYMONTH=1,3;BYDAY=-1SU,MO;BYMONTHDAY=1,-1;\
552             BYYEARDAY=100,-1;BYWEEKNO=1,-1;BYHOUR=9;BYMINUTE=30;BYSECOND=0;\
553             BYSETPOS=-1;WKST=SU;UNTIL=20301231T235959Z",
554        )
555        .unwrap();
556
557        assert_eq!(rule.freq, IcalRecurFreq::Yearly);
558        assert_eq!(rule.interval, 2);
559        assert_eq!(rule.by_month, vec![1, 3]);
560        assert_eq!(rule.by_day[0].ordinal, Some(-1));
561        assert_eq!(rule.by_day[0].weekday, IcalRecurWeekday::Sunday);
562        assert_eq!(rule.by_day[1].ordinal, None);
563        assert_eq!(rule.by_month_day, vec![1, -1]);
564        assert_eq!(rule.by_year_day, vec![100, -1]);
565        assert_eq!(rule.by_week_no, vec![1, -1]);
566        assert_eq!(rule.by_set_pos, vec![-1]);
567        assert_eq!(rule.week_start, IcalRecurWeekday::Sunday);
568        assert_eq!(
569            rule.until,
570            Some(IcalRecurDateTime {
571                year: 2030,
572                month: 12,
573                day: 31,
574                hour: 23,
575                minute: 59,
576                second: 59,
577            })
578        );
579    }
580
581    #[test]
582    fn defaults_interval_and_week_start() {
583        let rule = IcalRecurRule::parse("FREQ=DAILY").unwrap();
584        assert_eq!(rule.interval, 1);
585        assert_eq!(rule.week_start, IcalRecurWeekday::Monday);
586        assert_eq!(rule.skip, IcalRecurSkip::Omit);
587    }
588
589    #[test]
590    fn ignores_unknown_parts() {
591        let rule = IcalRecurRule::parse("FREQ=DAILY;X-VENDOR=1;NONSENSE=abc").unwrap();
592        assert_eq!(rule.freq, IcalRecurFreq::Daily);
593    }
594
595    #[test]
596    fn refuses_contradictions() {
597        assert_eq!(
598            IcalRecurRule::parse("INTERVAL=2"),
599            Err(IcalRecurRuleError::FreqMissing)
600        );
601        assert_eq!(
602            IcalRecurRule::parse("FREQ=DAILY;INTERVAL=0"),
603            Err(IcalRecurRuleError::IntervalZero)
604        );
605        assert_eq!(
606            IcalRecurRule::parse("FREQ=FORTNIGHTLY"),
607            Err(IcalRecurRuleError::Freq)
608        );
609    }
610
611    #[test]
612    fn refuses_out_of_range_and_zero_ordinals() {
613        assert_eq!(
614            IcalRecurRule::parse("FREQ=MONTHLY;BYMONTHDAY=32"),
615            Err(IcalRecurRuleError::Range)
616        );
617        assert_eq!(
618            IcalRecurRule::parse("FREQ=MONTHLY;BYMONTHDAY=0"),
619            Err(IcalRecurRuleError::Range)
620        );
621        assert_eq!(
622            IcalRecurRule::parse("FREQ=YEARLY;BYMONTH=13"),
623            Err(IcalRecurRuleError::Range)
624        );
625    }
626
627    #[test]
628    fn refuses_a_multi_byte_weekday_rather_than_splitting_it() {
629        // NOTE: A rule is text off the wire, so a BYDAY whose last characters
630        // are multi-byte must be refused, never split mid-character.
631        for value in ["€", "𝄞", "SU€", "-1€", "1FR€"] {
632            assert!(IcalRecurRule::parse(&alloc::format!("FREQ=DAILY;BYDAY={value}")).is_err());
633        }
634    }
635
636    #[test]
637    fn parses_the_three_until_spellings() {
638        let date = IcalRecurRule::parse("FREQ=DAILY;UNTIL=20300102").unwrap();
639        assert_eq!(date.until, Some(IcalRecurDateTime::date(2030, 1, 2)));
640
641        let local = IcalRecurRule::parse("FREQ=DAILY;UNTIL=20300102T030405").unwrap();
642        let utc = IcalRecurRule::parse("FREQ=DAILY;UNTIL=20300102T030405Z").unwrap();
643        assert_eq!(local.until, utc.until);
644        assert_eq!(local.until.unwrap().hour, 3);
645
646        assert_eq!(
647            IcalRecurRule::parse("FREQ=DAILY;UNTIL=20300230"),
648            Err(IcalRecurRuleError::DateTime)
649        );
650    }
651}