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