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