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 pub fn contains(&self, date: NaiveDate) -> bool {
134 date >= self.start_date && date <= self.end_date
135 }
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
140pub struct Schedule {
141 starts_on: NaiveDate,
142 frequency: Frequency,
143}
144
145impl Schedule {
146 pub fn new(starts_on: NaiveDate, frequency: Frequency) -> Self {
148 Self {
149 starts_on,
150 frequency,
151 }
152 }
153
154 pub fn starts_on(&self) -> NaiveDate {
156 self.starts_on
157 }
158
159 pub fn frequency(&self) -> Frequency {
161 self.frequency
162 }
163
164 pub fn occurrences_between(&self, range: DateRange) -> Vec<NaiveDate> {
166 match self.frequency {
167 Frequency::Daily => self.daily_occurrences(range),
168 Frequency::EveryNDays(interval) => self.every_n_days_occurrences(range, interval),
169 Frequency::Weekly(day_of_week) => self.weekly_occurrences(range, day_of_week),
170 Frequency::Monthly(day_of_month) => self.monthly_occurrences(range, day_of_month),
171 Frequency::SemiAnnually => self.every_n_months_occurrences(range, 6),
172 Frequency::Annually => self.every_n_months_occurrences(range, 12),
173 Frequency::Biennially => self.every_n_months_occurrences(range, 24),
174 }
175 }
176
177 fn daily_occurrences(&self, range: DateRange) -> Vec<NaiveDate> {
178 let start = larger_date(range.start_date, self.starts_on);
179 let Some(adjusted_range) = DateRange::new(start, range.end_date) else {
180 return Vec::new();
181 };
182 adjusted_range.all_days()
183 }
184
185 fn every_n_days_occurrences(&self, range: DateRange, interval: DayInterval) -> Vec<NaiveDate> {
186 let mut matches = Vec::new();
187 if range.end_date < self.starts_on {
188 return matches;
189 }
190 let interval = i64::from(interval.get());
191 let first_possible_date = larger_date(range.start_date, self.starts_on);
192 let days_since_start = first_possible_date
193 .signed_duration_since(self.starts_on)
194 .num_days();
195 let offset = if days_since_start <= 0 {
196 0
197 } else {
198 ((days_since_start + interval - 1) / interval) * interval
199 };
200 let Some(mut current) = self.starts_on.checked_add_signed(Duration::days(offset)) else {
201 return matches;
202 };
203 while current <= range.end_date {
204 matches.push(current);
205 match current.checked_add_signed(Duration::days(interval)) {
206 Some(next_date) => current = next_date,
207 None => break,
208 }
209 }
210 matches
211 }
212
213 fn weekly_occurrences(&self, range: DateRange, day_of_week: Weekday) -> Vec<NaiveDate> {
214 let mut matches = Vec::new();
215 if range.end_date < self.starts_on {
216 return matches;
217 }
218 let start = larger_date(range.start_date, self.starts_on);
219 let current_weekday = i64::from(start.weekday().num_days_from_monday());
220 let day_of_week = i64::from(day_of_week.num_days_from_monday());
221 let days_until_target = (7 + day_of_week - current_weekday) % 7;
222 let Some(mut current) = start.checked_add_signed(Duration::days(days_until_target)) else {
223 return matches;
224 };
225 while current <= range.end_date {
226 matches.push(current);
227 match current.checked_add_signed(Duration::days(7)) {
228 Some(next_date) => current = next_date,
229 None => break,
230 }
231 }
232 matches
233 }
234
235 fn monthly_occurrences(&self, range: DateRange, day_of_month: DayOfMonth) -> Vec<NaiveDate> {
236 let mut matches = Vec::new();
237 if range.end_date < self.starts_on {
238 return matches;
239 }
240 let start = larger_date(range.start_date, self.starts_on);
241 let mut year = start.year();
242 let mut month = start.month();
243 while let Some(candidate) = date_in_month(year, month, day_of_month.get())
244 && candidate <= range.end_date
245 {
246 if candidate >= start {
247 matches.push(candidate);
248 }
249 let Some((next_year, next_month)) = advance_month(year, month, 1) else {
250 break;
251 };
252 year = next_year;
253 month = next_month;
254 }
255 matches
256 }
257
258 fn every_n_months_occurrences(&self, range: DateRange, month_interval: u32) -> Vec<NaiveDate> {
259 let mut matches = Vec::new();
260 if range.end_date < self.starts_on {
261 return matches;
262 }
263 let mut intervals_elapsed = 0;
264 loop {
265 let months_to_add = intervals_elapsed * month_interval;
266 let Some(candidate) = add_months_preserving_anchor_day(self.starts_on, months_to_add)
267 else {
268 break;
269 };
270 if candidate > range.end_date {
271 break;
272 }
273 if candidate >= range.start_date {
274 matches.push(candidate);
275 }
276 intervals_elapsed += 1;
277 }
278 matches
279 }
280}
281
282fn larger_date(date_1: NaiveDate, date_2: NaiveDate) -> NaiveDate {
283 if date_1 >= date_2 { date_1 } else { date_2 }
284}
285
286fn date_in_month(year: i32, month: u32, queried_date: u8) -> Option<NaiveDate> {
287 let last_day = last_day_of_month(year, month)?;
288 let day = u32::from(queried_date).min(last_day);
289 NaiveDate::from_ymd_opt(year, month, day)
290}
291
292fn last_day_of_month(year: i32, month: u32) -> Option<u32> {
293 let (next_year, next_month) = if month == 12 {
294 (year.checked_add(1)?, 1)
295 } else {
296 (year, month + 1)
297 };
298 let first_day_of_next_month = NaiveDate::from_ymd_opt(next_year, next_month, 1)?;
299 let last_day = first_day_of_next_month.pred_opt()?;
300 Some(last_day.day())
301}
302
303fn advance_month(year: i32, month: u32, amount: u32) -> Option<(i32, u32)> {
304 if !(1..=12).contains(&month) {
305 return None;
306 }
307 let total_months = i64::from(year)
308 .checked_mul(12)?
309 .checked_add(i64::from(month - 1))?
310 .checked_add(i64::from(amount))?;
311 let new_year = total_months.div_euclid(12);
312 let new_month = total_months.rem_euclid(12) + 1;
313 if new_year < i64::from(i32::MIN) || new_year > i64::from(i32::MAX) {
314 return None;
315 }
316 Some((new_year as i32, new_month as u32))
317}
318
319fn add_months_preserving_anchor_day(date: NaiveDate, months: u32) -> Option<NaiveDate> {
320 let (year, month) = advance_month(date.year(), date.month(), months)?;
321 let last_day = last_day_of_month(year, month)?;
322 let day = date.day().min(last_day);
323 NaiveDate::from_ymd_opt(year, month, day)
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
331 NaiveDate::from_ymd_opt(year, month, day).unwrap()
332 }
333
334 #[test]
335 fn date_range_rejects_backwards_ranges() {
336 assert!(DateRange::new(date(2026, 1, 2), date(2026, 1, 1)).is_none());
337 }
338
339 #[test]
340 fn date_range_days_are_inclusive() {
341 let range = DateRange::new(date(2026, 1, 1), date(2026, 1, 3)).unwrap();
342 assert_eq!(
343 range.all_days(),
344 vec![date(2026, 1, 1), date(2026, 1, 2), date(2026, 1, 3),]
345 );
346 }
347
348 #[test]
349 fn daily_schedule_returns_each_day_in_range_after_start() {
350 let schedule = Schedule::new(date(2026, 1, 2), Frequency::Daily);
351 let range = DateRange::new(date(2026, 1, 1), date(2026, 1, 4)).unwrap();
352 assert_eq!(
353 schedule.occurrences_between(range),
354 vec![date(2026, 1, 2), date(2026, 1, 3), date(2026, 1, 4),]
355 );
356 }
357
358 #[test]
359 fn every_n_days_is_anchored_to_start_date() {
360 let schedule = Schedule::new(
361 date(2026, 1, 1),
362 Frequency::EveryNDays(DayInterval::new(3).unwrap()),
363 );
364 let range = DateRange::new(date(2026, 1, 2), date(2026, 1, 10)).unwrap();
365 assert_eq!(
366 schedule.occurrences_between(range),
367 vec![date(2026, 1, 4), date(2026, 1, 7), date(2026, 1, 10),]
368 );
369 }
370
371 #[test]
372 fn weekly_schedule_matches_requested_weekday() {
373 let schedule = Schedule::new(date(2026, 1, 1), Frequency::Weekly(Weekday::Mon));
374 let range = DateRange::new(date(2026, 1, 1), date(2026, 1, 15)).unwrap();
375 assert_eq!(
376 schedule.occurrences_between(range),
377 vec![date(2026, 1, 5), date(2026, 1, 12)]
378 );
379 }
380
381 #[test]
382 fn monthly_schedule_clamps_to_last_day_of_short_months() {
383 let schedule = Schedule::new(
384 date(2026, 1, 1),
385 Frequency::Monthly(DayOfMonth::new(31).unwrap()),
386 );
387 let range = DateRange::new(date(2026, 1, 1), date(2026, 3, 31)).unwrap();
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 assert_eq!(
399 schedule.occurrences_between(range),
400 vec![
401 date(2024, 2, 29),
402 date(2025, 2, 28),
403 date(2026, 2, 28),
404 date(2027, 2, 28),
405 date(2028, 2, 29),
406 ]
407 );
408 }
409
410 #[test]
411 fn biennially_uses_twenty_four_month_intervals() {
412 let schedule = Schedule::new(date(2026, 5, 10), Frequency::Biennially);
413 let range = DateRange::new(date(2026, 1, 1), date(2031, 1, 1)).unwrap();
414 assert_eq!(
415 schedule.occurrences_between(range),
416 vec![date(2026, 5, 10), date(2028, 5, 10), date(2030, 5, 10),]
417 );
418 }
419
420 #[test]
421 fn date_range_contains_is_inclusive() {
422 let range = DateRange::new(date(2026, 1, 10), date(2026, 1, 20)).unwrap();
423 assert!(range.contains(date(2026, 1, 10)));
424 assert!(range.contains(date(2026, 1, 15)));
425 assert!(range.contains(date(2026, 1, 20)));
426 assert!(!range.contains(date(2026, 1, 9)));
427 assert!(!range.contains(date(2026, 1, 21)));
428 }
429}