1use core::fmt;
16
17use alloc::vec::Vec;
18
19use crate::{
20 recur::{IcalRecurFreq, IcalRecurRule, IcalRecurSkip},
21 valid::IcalValid,
22};
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub enum IcalRecurPart {
27 BySecond,
29 ByMinute,
31 ByHour,
33 ByDay,
35 ByMonthDay,
37 ByYearDay,
39 ByWeekNo,
41 ByMonth,
43 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
65pub enum IcalRecurRuleProblem {
66 PartFreq {
68 part: IcalRecurPart,
70 freq: IcalRecurFreq,
72 },
73 OrdinalFreq {
75 freq: IcalRecurFreq,
77 },
78 OrdinalWithWeekNo,
81 SetPosAlone,
83 UntilWithCount,
85 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
114fn 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 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 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 if !self.by_week_no.is_empty() && freq != Yearly {
152 problems.push(IcalRecurRuleProblem::PartFreq {
153 part: ByWeekNo,
154 freq,
155 });
156 }
157
158 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 if !self.by_month_day.is_empty() && freq == Weekly {
170 problems.push(IcalRecurRuleProblem::PartFreq {
171 part: ByMonthDay,
172 freq,
173 });
174 }
175
176 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 if !self.by_set_pos.is_empty() && !self.has_other_by_part() {
193 problems.push(IcalRecurRuleProblem::SetPosAlone);
194 }
195
196 if self.until.is_some() && self.count.is_some() {
200 problems.push(IcalRecurRuleProblem::UntilWithCount);
201 }
202
203 if self.skip != IcalRecurSkip::Omit && self.scale.is_none() {
207 problems.push(IcalRecurRuleProblem::SkipWithoutScale);
208 }
209
210 problems
211 }
212
213 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 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 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}