1use core::{error, fmt};
17
18use alloc::vec::Vec;
19
20use crate::{
21 recur::{IcalRecurFreq, IcalRecurRule, IcalRecurSkip},
22 validator::IcalValid,
23};
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum IcalRecurPart {
28 BySecond,
30 ByMinute,
32 ByHour,
34 ByDay,
36 ByMonthDay,
38 ByYearDay,
40 ByWeekNo,
42 ByMonth,
44 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66pub enum IcalRecurRuleProblem {
67 PartFreq {
69 part: IcalRecurPart,
71 freq: IcalRecurFreq,
73 },
74 OrdinalFreq {
76 freq: IcalRecurFreq,
78 },
79 OrdinalWithWeekNo,
82 SetPosAlone,
84 UntilWithCount,
86 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
115fn 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 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 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 if !self.by_week_no.is_empty() && freq != Yearly {
153 problems.push(IcalRecurRuleProblem::PartFreq {
154 part: ByWeekNo,
155 freq,
156 });
157 }
158
159 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 if !self.by_month_day.is_empty() && freq == Weekly {
171 problems.push(IcalRecurRuleProblem::PartFreq {
172 part: ByMonthDay,
173 freq,
174 });
175 }
176
177 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 if !self.by_set_pos.is_empty() && !self.has_other_by_part() {
194 problems.push(IcalRecurRuleProblem::SetPosAlone);
195 }
196
197 if self.until.is_some() && self.count.is_some() {
201 problems.push(IcalRecurRuleProblem::UntilWithCount);
202 }
203
204 if self.skip != IcalRecurSkip::Omit && self.scale.is_none() {
208 problems.push(IcalRecurRuleProblem::SkipWithoutScale);
209 }
210
211 problems
212 }
213
214 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 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}