Skip to main content

ical/recur/
validate.rs

1//! # Rule validation
2//!
3//! The "strict out" check for a recurrence rule (RFC 5545 3.3.10).
4//!
5//! Expansion is liberal by design: a `BY` part the frequency forbids is
6//! ignored rather than refused, because that is what "liberal in what it
7//! accepts" means for a rule that arrived from someone else's server. That
8//! leaves a caller who is *writing* a rule with no way to learn it is
9//! malformed, which is what this is for. The two never disagree: validation
10//! reports the part, expansion still ignores it.
11//!
12//! A rule that passes earns a [`IcalValid`], the same proof
13//! [`Ical::validate`](crate::ical::Ical::validate) mints for a whole calendar.
14
15use core::fmt;
16
17use alloc::vec::Vec;
18
19use crate::{
20    recur::{IcalRecurFreq, IcalRecurRule, IcalRecurSkip},
21    valid::IcalValid,
22};
23
24/// A rule part, named so a problem can point at one.
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub enum IcalRecurPart {
27    /// `BYSECOND`.
28    BySecond,
29    /// `BYMINUTE`.
30    ByMinute,
31    /// `BYHOUR`.
32    ByHour,
33    /// `BYDAY`.
34    ByDay,
35    /// `BYMONTHDAY`.
36    ByMonthDay,
37    /// `BYYEARDAY`.
38    ByYearDay,
39    /// `BYWEEKNO`.
40    ByWeekNo,
41    /// `BYMONTH`.
42    ByMonth,
43    /// `BYSETPOS`.
44    BySetPos,
45}
46
47impl fmt::Display for IcalRecurPart {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        f.write_str(match self {
50            Self::BySecond => "BYSECOND",
51            Self::ByMinute => "BYMINUTE",
52            Self::ByHour => "BYHOUR",
53            Self::ByDay => "BYDAY",
54            Self::ByMonthDay => "BYMONTHDAY",
55            Self::ByYearDay => "BYYEARDAY",
56            Self::ByWeekNo => "BYWEEKNO",
57            Self::ByMonth => "BYMONTH",
58            Self::BySetPos => "BYSETPOS",
59        })
60    }
61}
62
63/// One way a rule breaks RFC 5545 3.3.10.
64#[derive(Clone, Copy, Debug, PartialEq, Eq)]
65pub enum IcalRecurRuleProblem {
66    /// A `BY` part the rule's frequency forbids.
67    PartFreq {
68        /// The part that may not appear.
69        part: IcalRecurPart,
70        /// The frequency that forbids it.
71        freq: IcalRecurFreq,
72    },
73    /// A `BYDAY` ordinal (`2MO`, `-1SU`) outside `MONTHLY` and `YEARLY`.
74    OrdinalFreq {
75        /// The frequency that forbids it.
76        freq: IcalRecurFreq,
77    },
78    /// A `BYDAY` ordinal at `YEARLY` beside a `BYWEEKNO`, where it would mean
79    /// two contradictory things at once.
80    OrdinalWithWeekNo,
81    /// `BYSETPOS` with no other `BY` part to pick positions out of.
82    SetPosAlone,
83    /// `UNTIL` and `COUNT` together, which bound the rule twice.
84    UntilWithCount,
85    /// `SKIP` with no `RSCALE`, which RFC 7529 4 requires beside it.
86    SkipWithoutScale,
87}
88
89impl fmt::Display for IcalRecurRuleProblem {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        match self {
92            Self::PartFreq { part, freq } => {
93                write!(f, "{part} may not be used with FREQ={}", freq_name(*freq))
94            }
95            Self::OrdinalFreq { freq } => {
96                write!(
97                    f,
98                    "a BYDAY ordinal may not be used with FREQ={}",
99                    freq_name(*freq)
100                )
101            }
102            Self::OrdinalWithWeekNo => {
103                f.write_str("a BYDAY ordinal may not be used with FREQ=YEARLY beside BYWEEKNO")
104            }
105            Self::SetPosAlone => f.write_str("BYSETPOS needs another BY part to pick from"),
106            Self::UntilWithCount => f.write_str("UNTIL and COUNT may not both be given"),
107            Self::SkipWithoutScale => f.write_str("SKIP may not be used without RSCALE"),
108        }
109    }
110}
111
112impl core::error::Error for IcalRecurRuleProblem {}
113
114/// The wire spelling of a frequency.
115fn freq_name(freq: IcalRecurFreq) -> &'static str {
116    match freq {
117        IcalRecurFreq::Secondly => "SECONDLY",
118        IcalRecurFreq::Minutely => "MINUTELY",
119        IcalRecurFreq::Hourly => "HOURLY",
120        IcalRecurFreq::Daily => "DAILY",
121        IcalRecurFreq::Weekly => "WEEKLY",
122        IcalRecurFreq::Monthly => "MONTHLY",
123        IcalRecurFreq::Yearly => "YEARLY",
124    }
125}
126
127impl IcalRecurRule {
128    /// Check the rule against RFC 5545 3.3.10, returning a [`IcalValid`] proof
129    /// or every problem found.
130    pub fn validate(self) -> Result<IcalValid<Self>, Vec<IcalRecurRuleProblem>> {
131        let problems = self.problems();
132
133        if problems.is_empty() {
134            Ok(IcalValid(self))
135        } else {
136            Err(problems)
137        }
138    }
139
140    /// Every RFC 5545 3.3.10 constraint this rule breaks, in the order the
141    /// section states them. Empty for a conformant rule.
142    pub fn problems(&self) -> Vec<IcalRecurRuleProblem> {
143        use IcalRecurFreq::*;
144        use IcalRecurPart::*;
145
146        let mut problems = Vec::new();
147        let freq = self.freq;
148
149        // NOTE: "The BYWEEKNO rule part MUST NOT be used when the FREQ rule
150        // part is set to anything other than YEARLY."
151        if !self.by_week_no.is_empty() && freq != Yearly {
152            problems.push(IcalRecurRuleProblem::PartFreq {
153                part: ByWeekNo,
154                freq,
155            });
156        }
157
158        // NOTE: "The BYYEARDAY rule part MUST NOT be specified when the FREQ
159        // rule part is set to DAILY, WEEKLY, or MONTHLY."
160        if !self.by_year_day.is_empty() && matches!(freq, Daily | Weekly | Monthly) {
161            problems.push(IcalRecurRuleProblem::PartFreq {
162                part: ByYearDay,
163                freq,
164            });
165        }
166
167        // NOTE: "The BYMONTHDAY rule part MUST NOT be specified when the FREQ
168        // rule part is set to WEEKLY."
169        if !self.by_month_day.is_empty() && freq == Weekly {
170            problems.push(IcalRecurRuleProblem::PartFreq {
171                part: ByMonthDay,
172                freq,
173            });
174        }
175
176        // NOTE: "The BYDAY rule part MUST NOT be specified with a numeric value
177        // when the FREQ rule part is not set to MONTHLY or YEARLY. Furthermore,
178        // the BYDAY rule part MUST NOT be specified with a numeric value with
179        // the FREQ rule part set to YEARLY when the BYWEEKNO rule part is
180        // specified."
181        let ordinal = self.by_day.iter().any(|day| day.ordinal.is_some());
182        if ordinal {
183            if !matches!(freq, Monthly | Yearly) {
184                problems.push(IcalRecurRuleProblem::OrdinalFreq { freq });
185            } else if freq == Yearly && !self.by_week_no.is_empty() {
186                problems.push(IcalRecurRuleProblem::OrdinalWithWeekNo);
187            }
188        }
189
190        // NOTE: "[BYSETPOS] MUST only be used in conjunction with another BYxxx
191        // rule part."
192        if !self.by_set_pos.is_empty() && !self.has_other_by_part() {
193            problems.push(IcalRecurRuleProblem::SetPosAlone);
194        }
195
196        // NOTE: "The UNTIL rule part and the COUNT rule part MUST NOT occur in
197        // the same 'recur'." Parsing accepts both, since a rule that says too
198        // much is still a rule; this is where it is said out loud.
199        if self.until.is_some() && self.count.is_some() {
200            problems.push(IcalRecurRuleProblem::UntilWithCount);
201        }
202
203        // NOTE: RFC 7529 4: SKIP "MUST NOT be present unless RSCALE is
204        // present". `RSCALE=GREGORIAN` is the usual way to satisfy that, and
205        // the only one this crate expands.
206        if self.skip != IcalRecurSkip::Omit && self.scale.is_none() {
207            problems.push(IcalRecurRuleProblem::SkipWithoutScale);
208        }
209
210        problems
211    }
212
213    /// Whether the rule carries a `BY` part other than `BYSETPOS`.
214    fn has_other_by_part(&self) -> bool {
215        !self.by_second.is_empty()
216            || !self.by_minute.is_empty()
217            || !self.by_hour.is_empty()
218            || !self.by_day.is_empty()
219            || !self.by_month_day.is_empty()
220            || !self.by_year_day.is_empty()
221            || !self.by_week_no.is_empty()
222            || !self.by_month.is_empty()
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use alloc::vec;
229
230    use crate::recur::{
231        IcalRecurFreq, IcalRecurRule,
232        validate::{IcalRecurPart, IcalRecurRuleProblem},
233    };
234
235    fn problems(rule: &str) -> vec::Vec<IcalRecurRuleProblem> {
236        IcalRecurRule::parse(rule)
237            .expect("a readable rule")
238            .problems()
239    }
240
241    #[test]
242    fn accepts_the_rules_the_rfc_writes() {
243        for rule in [
244            "FREQ=YEARLY;BYWEEKNO=20;BYDAY=MO",
245            "FREQ=MONTHLY;BYDAY=2MO",
246            "FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10",
247            "FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1",
248            "FREQ=SECONDLY;BYYEARDAY=1",
249            "FREQ=DAILY;COUNT=10",
250        ] {
251            assert!(problems(rule).is_empty(), "{rule} should validate");
252        }
253    }
254
255    #[test]
256    fn reports_a_part_the_frequency_forbids() {
257        assert_eq!(
258            problems("FREQ=MONTHLY;BYWEEKNO=3"),
259            [IcalRecurRuleProblem::PartFreq {
260                part: IcalRecurPart::ByWeekNo,
261                freq: IcalRecurFreq::Monthly,
262            }]
263        );
264        assert_eq!(
265            problems("FREQ=WEEKLY;BYYEARDAY=100"),
266            [IcalRecurRuleProblem::PartFreq {
267                part: IcalRecurPart::ByYearDay,
268                freq: IcalRecurFreq::Weekly,
269            }]
270        );
271        assert_eq!(
272            problems("FREQ=WEEKLY;BYMONTHDAY=15"),
273            [IcalRecurRuleProblem::PartFreq {
274                part: IcalRecurPart::ByMonthDay,
275                freq: IcalRecurFreq::Weekly,
276            }]
277        );
278    }
279
280    #[test]
281    fn reports_an_ordinal_where_it_means_nothing() {
282        assert_eq!(
283            problems("FREQ=WEEKLY;BYDAY=2MO"),
284            [IcalRecurRuleProblem::OrdinalFreq {
285                freq: IcalRecurFreq::Weekly,
286            }]
287        );
288        assert_eq!(
289            problems("FREQ=YEARLY;BYWEEKNO=20;BYDAY=2MO"),
290            [IcalRecurRuleProblem::OrdinalWithWeekNo]
291        );
292    }
293
294    #[test]
295    fn reports_a_setpos_with_nothing_to_pick_from() {
296        assert_eq!(
297            problems("FREQ=DAILY;BYSETPOS=2"),
298            [IcalRecurRuleProblem::SetPosAlone]
299        );
300        assert!(problems("FREQ=DAILY;BYHOUR=9,17;BYSETPOS=2").is_empty());
301    }
302
303    #[test]
304    fn reports_a_rule_bounded_twice() {
305        // NOTE: Parsing refuses this pair, so the case has to be built by hand,
306        // which is exactly the caller this check is for.
307        let mut rule = IcalRecurRule::parse("FREQ=DAILY;COUNT=3").unwrap();
308        rule.until = Some(crate::recur::IcalRecurDateTime::date(2026, 1, 1));
309
310        assert_eq!(rule.problems(), [IcalRecurRuleProblem::UntilWithCount]);
311    }
312
313    #[test]
314    fn a_valid_rule_mints_a_proof() {
315        let rule = IcalRecurRule::parse("FREQ=MONTHLY;BYDAY=2MO").unwrap();
316        let valid = rule.validate().expect("a conformant rule");
317
318        assert_eq!(valid.freq, IcalRecurFreq::Monthly);
319    }
320
321    #[test]
322    fn expansion_stays_liberal_about_what_validation_reports() {
323        use crate::recur::{IcalRecurDateTime, expand::IcalRecurExpand};
324
325        // NOTE: The same rule: reported by validation, ignored by expansion.
326        let rule = IcalRecurRule::parse("FREQ=MONTHLY;BYWEEKNO=3").unwrap();
327        assert!(!rule.problems().is_empty());
328
329        let start = IcalRecurDateTime::date(2026, 1, 15);
330        let occurrences: vec::Vec<_> = IcalRecurExpand::new(rule, start).take(2).collect();
331
332        assert_eq!(occurrences, [start, IcalRecurDateTime::date(2026, 2, 15)]);
333    }
334}