1use chrono::{Datelike, Duration, NaiveDate, Weekday};
16use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20pub enum Frequency {
21 Daily,
23
24 EveryNDays(DayInterval),
26
27 Weekly(Weekday),
29
30 Monthly(DayOfMonth),
35
36 SemiAnnually,
38
39 Annually,
41
42 Biennially,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
48pub struct DayInterval(u16);
49
50impl DayInterval {
51 pub fn new(value: u16) -> Option<Self> {
55 if value == 0 { None } else { Some(Self(value)) }
56 }
57
58 pub fn get(self) -> u16 {
60 self.0
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
66pub struct DayOfMonth(u8);
67
68impl DayOfMonth {
69 pub fn new(value: u8) -> Option<Self> {
73 if (1..=31).contains(&value) {
74 Some(Self(value))
75 } else {
76 None
77 }
78 }
79
80 pub fn get(self) -> u8 {
82 self.0
83 }
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
88pub struct DateRange {
89 start_date: NaiveDate,
90 end_date: NaiveDate,
91}
92
93impl DateRange {
94 pub fn new(start_date: NaiveDate, end_date: NaiveDate) -> Option<Self> {
98 if end_date < start_date {
99 None
100 } else {
101 Some(Self {
102 start_date,
103 end_date,
104 })
105 }
106 }
107
108 pub fn start_date(self) -> NaiveDate {
110 self.start_date
111 }
112
113 pub fn end_date(self) -> NaiveDate {
115 self.end_date
116 }
117
118 pub fn all_days(self) -> Vec<NaiveDate> {
120 let mut days = Vec::new();
121 let mut current = self.start_date;
122 while current <= self.end_date {
123 days.push(current);
124 match current.checked_add_signed(Duration::days(1)) {
125 Some(next_day) => current = next_day,
126 None => break,
127 }
128 }
129 days
130 }
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
135pub struct Schedule {
136 starts_on: NaiveDate,
137 frequency: Frequency,
138}
139
140impl Schedule {
141 pub fn new(starts_on: NaiveDate, frequency: Frequency) -> Self {
143 Self {
144 starts_on,
145 frequency,
146 }
147 }
148
149 pub fn starts_on(self) -> NaiveDate {
151 self.starts_on
152 }
153
154 pub fn frequency(self) -> Frequency {
156 self.frequency
157 }
158
159 pub fn occurrences_between(self, range: DateRange) -> Vec<NaiveDate> {
161 match self.frequency {
162 Frequency::Daily => self.daily_occurrences(range),
163 Frequency::EveryNDays(interval) => self.every_n_days_occurrences(range, interval),
164 Frequency::Weekly(day_of_week) => self.weekly_occurrences(range, day_of_week),
165 Frequency::Monthly(day_of_month) => self.monthly_occurrences(range, day_of_month),
166 Frequency::SemiAnnually => self.every_n_months_occurrences(range, 6),
167 Frequency::Annually => self.every_n_months_occurrences(range, 12),
168 Frequency::Biennially => self.every_n_months_occurrences(range, 24),
169 }
170 }
171
172 fn daily_occurrences(self, range: DateRange) -> Vec<NaiveDate> {
173 let start = larger_date(range.start_date, self.starts_on);
174 let Some(adjusted_range) = DateRange::new(start, range.end_date) else {
175 return Vec::new();
176 };
177 adjusted_range.all_days()
178 }
179
180 fn every_n_days_occurrences(self, range: DateRange, interval: DayInterval) -> Vec<NaiveDate> {
181 let mut matches = Vec::new();
182 if range.end_date < self.starts_on {
183 return matches;
184 }
185 let interval = i64::from(interval.get());
186 let first_possible_date = larger_date(range.start_date, self.starts_on);
187 let days_since_start = first_possible_date
188 .signed_duration_since(self.starts_on)
189 .num_days();
190 let offset = if days_since_start <= 0 {
191 0
192 } else {
193 ((days_since_start + interval - 1) / interval) * interval
194 };
195 let Some(mut current) = self.starts_on.checked_add_signed(Duration::days(offset)) else {
196 return matches;
197 };
198 while current <= range.end_date {
199 matches.push(current);
200 match current.checked_add_signed(Duration::days(interval)) {
201 Some(next_date) => current = next_date,
202 None => break,
203 }
204 }
205 matches
206 }
207
208 fn weekly_occurrences(self, range: DateRange, day_of_week: Weekday) -> Vec<NaiveDate> {
209 let mut matches = Vec::new();
210 if range.end_date < self.starts_on {
211 return matches;
212 }
213 let start = larger_date(range.start_date, self.starts_on);
214 let current_weekday = i64::from(start.weekday().num_days_from_monday());
215 let day_of_week = i64::from(day_of_week.num_days_from_monday());
216 let days_until_target = (7 + day_of_week - current_weekday) % 7;
217 let Some(mut current) = start.checked_add_signed(Duration::days(days_until_target)) else {
218 return matches;
219 };
220 while current <= range.end_date {
221 matches.push(current);
222 match current.checked_add_signed(Duration::days(7)) {
223 Some(next_date) => current = next_date,
224 None => break,
225 }
226 }
227 matches
228 }
229
230 fn monthly_occurrences(self, range: DateRange, day_of_month: DayOfMonth) -> Vec<NaiveDate> {
231 let mut matches = Vec::new();
232 if range.end_date < self.starts_on {
233 return matches;
234 }
235 let start = larger_date(range.start_date, self.starts_on);
236 let mut year = start.year();
237 let mut month = start.month();
238 while let Some(candidate) = date_in_month(year, month, day_of_month.get())
239 && candidate <= range.end_date
240 {
241 if candidate >= start {
242 matches.push(candidate);
243 }
244 let Some((next_year, next_month)) = advance_month(year, month, 1) else {
245 break;
246 };
247 year = next_year;
248 month = next_month;
249 }
250 matches
251 }
252
253 fn every_n_months_occurrences(self, range: DateRange, month_interval: u32) -> Vec<NaiveDate> {
254 let mut matches = Vec::new();
255 if range.end_date < self.starts_on {
256 return matches;
257 }
258 let mut intervals_elapsed = 0;
259 loop {
260 let months_to_add = intervals_elapsed * month_interval;
261 let Some(candidate) = add_months_preserving_anchor_day(self.starts_on, months_to_add)
262 else {
263 break;
264 };
265 if candidate > range.end_date {
266 break;
267 }
268 if candidate >= range.start_date {
269 matches.push(candidate);
270 }
271 intervals_elapsed += 1;
272 }
273 matches
274 }
275}
276
277fn larger_date(date_1: NaiveDate, date_2: NaiveDate) -> NaiveDate {
278 if date_1 >= date_2 { date_1 } else { date_2 }
279}
280
281fn date_in_month(year: i32, month: u32, queried_date: u8) -> Option<NaiveDate> {
282 let last_day = last_day_of_month(year, month)?;
283 let day = u32::from(queried_date).min(last_day);
284 NaiveDate::from_ymd_opt(year, month, day)
285}
286
287fn last_day_of_month(year: i32, month: u32) -> Option<u32> {
288 let (next_year, next_month) = if month == 12 {
289 (year.checked_add(1)?, 1)
290 } else {
291 (year, month + 1)
292 };
293 let first_day_of_next_month = NaiveDate::from_ymd_opt(next_year, next_month, 1)?;
294 let last_day = first_day_of_next_month.pred_opt()?;
295 Some(last_day.day())
296}
297
298fn advance_month(year: i32, month: u32, amount: u32) -> Option<(i32, u32)> {
299 if !(1..=12).contains(&month) {
300 return None;
301 }
302 let total_months = i64::from(year)
303 .checked_mul(12)?
304 .checked_add(i64::from(month - 1))?
305 .checked_add(i64::from(amount))?;
306 let new_year = total_months.div_euclid(12);
307 let new_month = total_months.rem_euclid(12) + 1;
308 if new_year < i64::from(i32::MIN) || new_year > i64::from(i32::MAX) {
309 return None;
310 }
311 Some((new_year as i32, new_month as u32))
312}
313
314fn add_months_preserving_anchor_day(date: NaiveDate, months: u32) -> Option<NaiveDate> {
315 let (year, month) = advance_month(date.year(), date.month(), months)?;
316 let last_day = last_day_of_month(year, month)?;
317 let day = date.day().min(last_day);
318 NaiveDate::from_ymd_opt(year, month, day)
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324
325 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
326 NaiveDate::from_ymd_opt(year, month, day).unwrap()
327 }
328
329 #[test]
330 fn date_range_rejects_backwards_ranges() {
331 assert!(DateRange::new(date(2026, 1, 2), date(2026, 1, 1)).is_none());
332 }
333
334 #[test]
335 fn date_range_days_are_inclusive() {
336 let range = DateRange::new(date(2026, 1, 1), date(2026, 1, 3)).unwrap();
337
338 assert_eq!(
339 range.all_days(),
340 vec![date(2026, 1, 1), date(2026, 1, 2), date(2026, 1, 3),]
341 );
342 }
343
344 #[test]
345 fn daily_schedule_returns_each_day_in_range_after_start() {
346 let schedule = Schedule::new(date(2026, 1, 2), Frequency::Daily);
347 let range = DateRange::new(date(2026, 1, 1), date(2026, 1, 4)).unwrap();
348
349 assert_eq!(
350 schedule.occurrences_between(range),
351 vec![date(2026, 1, 2), date(2026, 1, 3), date(2026, 1, 4),]
352 );
353 }
354
355 #[test]
356 fn every_n_days_is_anchored_to_start_date() {
357 let schedule = Schedule::new(
358 date(2026, 1, 1),
359 Frequency::EveryNDays(DayInterval::new(3).unwrap()),
360 );
361 let range = DateRange::new(date(2026, 1, 2), date(2026, 1, 10)).unwrap();
362
363 assert_eq!(
364 schedule.occurrences_between(range),
365 vec![date(2026, 1, 4), date(2026, 1, 7), date(2026, 1, 10),]
366 );
367 }
368
369 #[test]
370 fn weekly_schedule_matches_requested_weekday() {
371 let schedule = Schedule::new(date(2026, 1, 1), Frequency::Weekly(Weekday::Mon));
372 let range = DateRange::new(date(2026, 1, 1), date(2026, 1, 15)).unwrap();
373
374 assert_eq!(
375 schedule.occurrences_between(range),
376 vec![date(2026, 1, 5), date(2026, 1, 12)]
377 );
378 }
379
380 #[test]
381 fn monthly_schedule_clamps_to_last_day_of_short_months() {
382 let schedule = Schedule::new(
383 date(2026, 1, 1),
384 Frequency::Monthly(DayOfMonth::new(31).unwrap()),
385 );
386 let range = DateRange::new(date(2026, 1, 1), date(2026, 3, 31)).unwrap();
387
388 assert_eq!(
389 schedule.occurrences_between(range),
390 vec![date(2026, 1, 31), date(2026, 2, 28), date(2026, 3, 31),]
391 );
392 }
393
394 #[test]
395 fn annually_preserves_anchor_day_when_possible() {
396 let schedule = Schedule::new(date(2024, 2, 29), Frequency::Annually);
397 let range = DateRange::new(date(2024, 1, 1), date(2028, 12, 31)).unwrap();
398
399 assert_eq!(
400 schedule.occurrences_between(range),
401 vec![
402 date(2024, 2, 29),
403 date(2025, 2, 28),
404 date(2026, 2, 28),
405 date(2027, 2, 28),
406 date(2028, 2, 29),
407 ]
408 );
409 }
410
411 #[test]
412 fn biennially_uses_twenty_four_month_intervals() {
413 let schedule = Schedule::new(date(2026, 5, 10), Frequency::Biennially);
414 let range = DateRange::new(date(2026, 1, 1), date(2031, 1, 1)).unwrap();
415
416 assert_eq!(
417 schedule.occurrences_between(range),
418 vec![date(2026, 5, 10), date(2028, 5, 10), date(2030, 5, 10),]
419 );
420 }
421}