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