1#![forbid(unsafe_code)]
2use use_date::{CalendarDate, add_days};
22use use_month::days_in_month;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum RecurrenceFrequency {
26 Daily,
27 Weekly,
28 Monthly,
29 Yearly,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct RecurrenceRule {
34 start: CalendarDate,
35 frequency: RecurrenceFrequency,
36 interval: usize,
37 count: usize,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum RecurrenceError {
42 InvalidInterval,
43 InvalidDate,
44 DateOverflow,
45}
46
47fn add_months_clamped(date: CalendarDate, months: usize) -> Result<CalendarDate, RecurrenceError> {
48 let total_months = i64::from(date.year()) * 12
49 + i64::from(date.month() - 1)
50 + i64::try_from(months).map_err(|_| RecurrenceError::DateOverflow)?;
51 let new_year = total_months.div_euclid(12);
52 let new_month = total_months.rem_euclid(12) + 1;
53 let new_year = i32::try_from(new_year).map_err(|_| RecurrenceError::DateOverflow)?;
54 let new_month = u8::try_from(new_month).map_err(|_| RecurrenceError::DateOverflow)?;
55 let max_day = days_in_month(new_year, new_month).map_err(|_| RecurrenceError::InvalidDate)?;
56 let new_day = date.day().min(max_day);
57
58 CalendarDate::new(new_year, new_month, new_day).map_err(|_| RecurrenceError::InvalidDate)
59}
60
61fn add_years_clamped(date: CalendarDate, years: usize) -> Result<CalendarDate, RecurrenceError> {
62 let delta = i32::try_from(years).map_err(|_| RecurrenceError::DateOverflow)?;
63 let new_year = date
64 .year()
65 .checked_add(delta)
66 .ok_or(RecurrenceError::DateOverflow)?;
67 let max_day =
68 days_in_month(new_year, date.month()).map_err(|_| RecurrenceError::InvalidDate)?;
69 let new_day = date.day().min(max_day);
70
71 CalendarDate::new(new_year, date.month(), new_day).map_err(|_| RecurrenceError::InvalidDate)
72}
73
74fn advance_date(
75 start: CalendarDate,
76 frequency: RecurrenceFrequency,
77 interval: usize,
78 occurrence_index: usize,
79) -> Result<CalendarDate, RecurrenceError> {
80 let total = interval
81 .checked_mul(occurrence_index)
82 .ok_or(RecurrenceError::DateOverflow)?;
83
84 match frequency {
85 RecurrenceFrequency::Daily => {
86 let days = i64::try_from(total).map_err(|_| RecurrenceError::DateOverflow)?;
87 Ok(add_days(start, days))
88 }
89 RecurrenceFrequency::Weekly => {
90 let days = total.checked_mul(7).ok_or(RecurrenceError::DateOverflow)?;
91 let days = i64::try_from(days).map_err(|_| RecurrenceError::DateOverflow)?;
92 Ok(add_days(start, days))
93 }
94 RecurrenceFrequency::Monthly => add_months_clamped(start, total),
95 RecurrenceFrequency::Yearly => add_years_clamped(start, total),
96 }
97}
98
99impl RecurrenceRule {
100 pub fn new(
101 start: CalendarDate,
102 frequency: RecurrenceFrequency,
103 interval: usize,
104 count: usize,
105 ) -> Result<Self, RecurrenceError> {
106 if interval == 0 {
107 return Err(RecurrenceError::InvalidInterval);
108 }
109
110 Ok(Self {
111 start,
112 frequency,
113 interval,
114 count,
115 })
116 }
117
118 pub fn dates(&self) -> Result<Vec<CalendarDate>, RecurrenceError> {
119 recurring_dates(self.start, self.frequency, self.interval, self.count)
120 }
121}
122
123pub fn next_date(
124 date: CalendarDate,
125 frequency: RecurrenceFrequency,
126 interval: usize,
127) -> Result<CalendarDate, RecurrenceError> {
128 if interval == 0 {
129 return Err(RecurrenceError::InvalidInterval);
130 }
131
132 advance_date(date, frequency, interval, 1)
133}
134
135pub fn recurring_dates(
136 start: CalendarDate,
137 frequency: RecurrenceFrequency,
138 interval: usize,
139 count: usize,
140) -> Result<Vec<CalendarDate>, RecurrenceError> {
141 if interval == 0 {
142 return Err(RecurrenceError::InvalidInterval);
143 }
144
145 let mut dates = Vec::with_capacity(count);
146
147 for occurrence_index in 0..count {
148 dates.push(advance_date(start, frequency, interval, occurrence_index)?);
149 }
150
151 Ok(dates)
152}
153
154#[cfg(test)]
155mod tests {
156 use super::{RecurrenceError, RecurrenceFrequency, RecurrenceRule, next_date, recurring_dates};
157 use use_date::CalendarDate;
158
159 #[test]
160 fn generates_daily_and_weekly_recurrences() {
161 let start = CalendarDate::new(2024, 5, 17).unwrap();
162 let daily = recurring_dates(start, RecurrenceFrequency::Daily, 1, 3).unwrap();
163 let weekly = RecurrenceRule::new(start, RecurrenceFrequency::Weekly, 2, 3)
164 .unwrap()
165 .dates()
166 .unwrap();
167
168 assert_eq!(daily[0], CalendarDate::new(2024, 5, 17).unwrap());
169 assert_eq!(daily[2], CalendarDate::new(2024, 5, 19).unwrap());
170 assert_eq!(weekly[1], CalendarDate::new(2024, 5, 31).unwrap());
171 assert_eq!(
172 next_date(start, RecurrenceFrequency::Weekly, 1).unwrap(),
173 CalendarDate::new(2024, 5, 24).unwrap()
174 );
175 }
176
177 #[test]
178 fn clamps_monthly_end_of_month_recurrences() {
179 let start = CalendarDate::new(2024, 1, 31).unwrap();
180 let dates = recurring_dates(start, RecurrenceFrequency::Monthly, 1, 4).unwrap();
181
182 assert_eq!(dates[0], CalendarDate::new(2024, 1, 31).unwrap());
183 assert_eq!(dates[1], CalendarDate::new(2024, 2, 29).unwrap());
184 assert_eq!(dates[2], CalendarDate::new(2024, 3, 31).unwrap());
185 assert_eq!(dates[3], CalendarDate::new(2024, 4, 30).unwrap());
186 assert_eq!(
187 next_date(start, RecurrenceFrequency::Monthly, 1).unwrap(),
188 CalendarDate::new(2024, 2, 29).unwrap()
189 );
190 }
191
192 #[test]
193 fn rejects_invalid_intervals_and_allows_empty_count() {
194 let start = CalendarDate::new(2024, 2, 29).unwrap();
195
196 assert_eq!(
197 RecurrenceRule::new(start, RecurrenceFrequency::Daily, 0, 1),
198 Err(RecurrenceError::InvalidInterval)
199 );
200 assert_eq!(
201 recurring_dates(start, RecurrenceFrequency::Yearly, 1, 0).unwrap(),
202 Vec::<CalendarDate>::new()
203 );
204 assert_eq!(
205 next_date(start, RecurrenceFrequency::Yearly, 1).unwrap(),
206 CalendarDate::new(2025, 2, 28).unwrap()
207 );
208 }
209}