1use std::fmt;
4
5use crate::date::{Date, days_in_month};
6
7#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
9#[non_exhaustive]
10#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
11pub enum Frequency {
12 Once,
14 Daily,
16 Weekly,
18 #[default]
20 Monthly,
21 Yearly,
23}
24
25#[cfg_attr(feature = "live", ravenrs_live::expose)]
26impl Frequency {
27 pub const ALL: [Self; 5] = [Self::Once, Self::Daily, Self::Weekly, Self::Monthly, Self::Yearly];
29}
30
31#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
33#[non_exhaustive]
34#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
35pub enum RecurrenceEnd {
36 #[default]
38 Never,
39 Until(Date),
41 After(u32),
43}
44
45#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
62#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
63pub struct RecurrenceRule {
64 pub frequency: Frequency,
66 pub interval: u32,
68 pub weekdays: [bool; 7],
71 pub end: RecurrenceEnd,
73}
74
75impl Default for RecurrenceRule {
76 fn default() -> Self {
77 Self::new(Frequency::default())
78 }
79}
80
81const WEEKDAY_CODES: [&str; 7] = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"];
83
84#[cfg_attr(feature = "live", ravenrs_live::expose)]
85impl RecurrenceRule {
86 pub fn new(frequency: Frequency) -> Self {
88 Self { frequency, interval: 1, weekdays: [false; 7], end: RecurrenceEnd::Never }
89 }
90
91 pub fn every(mut self, interval: u32) -> Self {
93 self.interval = interval.max(1);
94 self
95 }
96
97 pub fn on_weekdays(mut self, days: impl IntoIterator<Item = u8>) -> Self {
99 self.weekdays = [false; 7];
100 for day in days {
101 if let Some(chosen) = self.weekdays.get_mut(usize::from(day)) {
102 *chosen = true;
103 }
104 }
105 self
106 }
107
108 pub fn end(mut self, end: RecurrenceEnd) -> Self {
110 self.end = end;
111 self
112 }
113
114 pub fn days_from(&self, start: Date) -> impl Iterator<Item = Date> + use<> {
117 let rule = *self;
118 let mut produced = 0_u32;
119 Occurrences { rule, start, step: 0, weekday: 0 }.take_while(move |day| {
120 produced += 1;
121 match rule.end {
122 RecurrenceEnd::Never => true,
123 RecurrenceEnd::Until(last) => *day <= last,
124 RecurrenceEnd::After(count) => produced <= count,
125 }
126 })
127 }
128
129 pub fn occurrences(&self, start: Date, from: Date, to: Date) -> Vec<Date> {
131 self.days_from(start).take_while(|day| *day <= to).filter(|day| *day >= from).collect()
132 }
133
134 pub fn to_text(&self) -> String {
136 let name = match self.frequency {
137 Frequency::Once => return "FREQ=ONCE".to_owned(),
138 Frequency::Daily => "DAILY",
139 Frequency::Weekly => "WEEKLY",
140 Frequency::Monthly => "MONTHLY",
141 Frequency::Yearly => "YEARLY",
142 };
143 let mut text = format!("FREQ={name}");
144 if self.interval > 1 {
145 text.push_str(&format!(";INTERVAL={}", self.interval));
146 }
147 if self.frequency == Frequency::Weekly && self.weekdays.contains(&true) {
148 let days: Vec<&str> = (0..7).filter(|d| self.weekdays[*d]).map(|d| WEEKDAY_CODES[d]).collect();
149 text.push_str(&format!(";BYDAY={}", days.join(",")));
150 }
151 match self.end {
152 RecurrenceEnd::Never => {}
153 RecurrenceEnd::Until(day) => {
154 text.push_str(&format!(";UNTIL={:04}{:02}{:02}", day.year(), day.month(), day.day()));
155 }
156 RecurrenceEnd::After(count) => text.push_str(&format!(";COUNT={count}")),
157 }
158 text
159 }
160
161 pub fn parse(text: &str) -> Option<Self> {
163 let mut rule = Self::new(Frequency::Once);
164 let mut has_frequency = false;
165 for part in text.trim().split(';') {
166 let (key, value) = part.split_once('=')?;
167 match key {
168 "FREQ" => {
169 has_frequency = true;
170 rule.frequency = match value {
171 "ONCE" => Frequency::Once,
172 "DAILY" => Frequency::Daily,
173 "WEEKLY" => Frequency::Weekly,
174 "MONTHLY" => Frequency::Monthly,
175 "YEARLY" => Frequency::Yearly,
176 _ => return None,
177 };
178 }
179 "INTERVAL" => rule.interval = value.parse::<u32>().ok()?.max(1),
180 "BYDAY" => {
181 for code in value.split(',') {
182 let day = WEEKDAY_CODES.iter().position(|c| *c == code)?;
183 rule.weekdays[day] = true;
184 }
185 }
186 "UNTIL" => {
187 let (year, month, day) = (value.get(..4)?, value.get(4..6)?, value.get(6..8)?);
188 let date = Date::new(year.parse().ok()?, month.parse().ok()?, day.parse().ok()?)?;
189 rule.end = RecurrenceEnd::Until(date);
190 }
191 "COUNT" => rule.end = RecurrenceEnd::After(value.parse().ok()?),
192 _ => return None,
193 }
194 }
195 has_frequency.then_some(rule)
196 }
197}
198
199impl fmt::Display for RecurrenceRule {
200 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201 f.write_str(&self.to_text())
202 }
203}
204
205struct Occurrences {
206 rule: RecurrenceRule,
207 start: Date,
208 step: i64,
210 weekday: u8,
212}
213
214fn months_after(start: Date, months: i64) -> Option<Date> {
216 let months = i32::try_from(months).ok()?;
217 let first = start.first_of_month().add_months(months);
218 let day = start.day().min(days_in_month(first.year(), first.month()));
219 Date::new(first.year(), first.month(), day)
220}
221
222impl Iterator for Occurrences {
223 type Item = Date;
224
225 fn next(&mut self) -> Option<Date> {
226 let interval = i64::from(self.rule.interval.max(1));
227 match self.rule.frequency {
228 Frequency::Once => {
229 self.step += 1;
230 (self.step == 1).then_some(self.start)
231 }
232 Frequency::Daily => {
233 let day = Date::from_days(self.start.to_days() + self.step * interval);
234 self.step += 1;
235 Some(day)
236 }
237 Frequency::Monthly | Frequency::Yearly => {
238 let per_step = if self.rule.frequency == Frequency::Yearly { 12 } else { 1 };
239 let day = months_after(self.start, self.step * interval * per_step);
240 self.step += 1;
241 day
242 }
243 Frequency::Weekly => {
244 let own = usize::from(self.start.weekday());
245 let chosen = self.rule.weekdays;
246 let any = chosen.contains(&true);
247 let week_start = self.start.to_days() - own as i64;
248 loop {
250 if self.weekday > 6 {
251 self.weekday = 0;
252 self.step += 1;
253 }
254 let weekday = usize::from(self.weekday);
255 self.weekday += 1;
256 if if any { chosen[weekday] } else { weekday == own } {
257 let day = Date::from_days(week_start + self.step * interval * 7 + weekday as i64);
258 if day >= self.start {
260 return Some(day);
261 }
262 }
263 }
264 }
265 }
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 fn date(y: i32, m: u8, d: u8) -> Date {
274 Date::new(y, m, d).unwrap_or_else(|| panic!("invalid date"))
275 }
276
277 fn in_a_year(rule: RecurrenceRule, start: Date) -> Vec<Date> {
278 rule.occurrences(start, start, start.add_months(12))
279 }
280
281 #[test]
282 fn once_is_only_the_first_day() {
283 let start = date(2026, 3, 10);
284 assert_eq!(in_a_year(RecurrenceRule::new(Frequency::Once), start), [start]);
285 }
286
287 #[test]
288 fn daily_counts_by_interval() {
289 let start = date(2026, 12, 30);
290 let days = RecurrenceRule::new(Frequency::Daily).every(2).occurrences(start, start, date(2027, 1, 3));
291 assert_eq!(days, [date(2026, 12, 30), date(2027, 1, 1), date(2027, 1, 3)]);
292 }
293
294 #[test]
295 fn weekly_falls_on_the_chosen_days_and_skips_weeks() {
296 let start = date(2026, 11, 4);
298 let rule = RecurrenceRule::new(Frequency::Weekly).every(2).on_weekdays([1, 5]);
299 let days = rule.occurrences(start, start, date(2026, 11, 30));
300 assert_eq!(days, [date(2026, 11, 6), date(2026, 11, 16), date(2026, 11, 20), date(2026, 11, 30)]);
301 }
302
303 #[test]
304 fn weekly_with_no_day_chosen_keeps_its_own() {
305 let start = date(2026, 11, 4);
306 let days = RecurrenceRule::new(Frequency::Weekly).occurrences(start, start, date(2026, 11, 18));
307 assert_eq!(days, [date(2026, 11, 4), date(2026, 11, 11), date(2026, 11, 18)]);
308 }
309
310 #[test]
311 fn a_long_month_day_returns_after_a_short_month() {
312 let start = date(2026, 1, 31);
313 let days = RecurrenceRule::new(Frequency::Monthly).occurrences(start, start, date(2026, 4, 30));
314 assert_eq!(days, [start, date(2026, 2, 28), date(2026, 3, 31), date(2026, 4, 30)]);
315 }
316
317 #[test]
318 fn february_29_comes_back_in_leap_years() {
319 let start = date(2024, 2, 29);
320 let days = RecurrenceRule::new(Frequency::Yearly).occurrences(start, start, date(2028, 3, 1));
321 assert_eq!(days, [start, date(2025, 2, 28), date(2026, 2, 28), date(2027, 2, 28), date(2028, 2, 29)]);
322 }
323
324 #[test]
325 fn the_end_counts_from_the_start_whatever_range_is_asked() {
326 let start = date(2026, 1, 10);
327 let rule = RecurrenceRule::new(Frequency::Monthly).end(RecurrenceEnd::After(3));
328 assert_eq!(rule.occurrences(start, date(2026, 3, 1), date(2027, 1, 1)), [date(2026, 3, 10)]);
329 assert!(rule.occurrences(start, date(2026, 4, 1), date(2027, 1, 1)).is_empty());
330 let until = RecurrenceRule::new(Frequency::Monthly).end(RecurrenceEnd::Until(date(2026, 2, 10)));
331 assert_eq!(in_a_year(until, start), [start, date(2026, 2, 10)]);
332 }
333
334 #[test]
335 fn nothing_falls_before_the_start() {
336 let start = date(2026, 6, 15);
337 let rule = RecurrenceRule::new(Frequency::Monthly);
338 assert_eq!(rule.occurrences(start, date(2026, 1, 1), date(2026, 6, 14)), []);
339 }
340
341 #[test]
342 fn rules_survive_being_stored_as_text() {
343 for rule in [
344 RecurrenceRule::new(Frequency::Once),
345 RecurrenceRule::new(Frequency::Daily).every(3),
346 RecurrenceRule::new(Frequency::Weekly).on_weekdays([0, 3, 6]).end(RecurrenceEnd::After(9)),
347 RecurrenceRule::new(Frequency::Monthly).every(2).end(RecurrenceEnd::Until(date(2030, 12, 31))),
348 RecurrenceRule::new(Frequency::Yearly),
349 ] {
350 assert_eq!(RecurrenceRule::parse(&rule.to_text()), Some(rule), "{rule}");
351 }
352 for broken in ["", "nope", "FREQ=SOMETIMES", "INTERVAL=2", "FREQ=DAILY;UNTIL=2026", "FREQ=DAILY;X=1"]
353 {
354 assert_eq!(RecurrenceRule::parse(broken), None, "{broken}");
355 }
356 }
357
358 #[test]
359 fn a_zero_interval_cannot_hang() {
360 let mut rule = RecurrenceRule::new(Frequency::Daily);
361 rule.interval = 0;
362 let start = date(2026, 1, 1);
363 assert_eq!(rule.occurrences(start, start, date(2026, 1, 3)).len(), 3);
364 }
365}