Skip to main content

date_recurrence/
lib.rs

1// Copyright (C) 2026  Sisyphus1813
2//
3// This program is free software: you can redistribute it and/or modify
4// it under the terms of the GNU General Public License as published by
5// the Free Software Foundation, either version 3 of the License, or
6// (at your option) any later version.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11// GNU General Public License for more details.
12// You should have received a copy of the GNU General Public License
13// along with this program.  If not, see <https://www.gnu.org/licenses/>.
14
15use chrono::{Datelike, Duration, NaiveDate, Weekday};
16use serde::{Deserialize, Serialize};
17
18/// A recurrence frequency for calendar dates.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20pub enum Frequency {
21    /// Every calendar day.
22    Daily,
23
24    /// Every N days, anchored to the schedule's start date.
25    EveryNDays(DayInterval),
26
27    /// Every week on the given day.
28    Weekly(Weekday),
29
30    /// Every month on the given day of the month.
31    ///
32    /// Months that do not contain that day are clamped to the last valid day
33    /// of the month. For example, the 31st becomes February 28th or 29th.
34    Monthly(DayOfMonth),
35
36    /// Every six months, anchored to the schedule's start date.
37    SemiAnnually,
38
39    /// Every twelve months, anchored to the schedule's start date.
40    Annually,
41
42    /// Every twenty-four months, anchored to the schedule's start date.
43    Biennially,
44}
45
46/// A positive interval measured in days.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
48pub struct DayInterval(u16);
49
50impl DayInterval {
51    /// Creates a day interval.
52    ///
53    /// Returns `None` if `value` is zero.
54    pub fn new(value: u16) -> Option<Self> {
55        if value == 0 { None } else { Some(Self(value)) }
56    }
57
58    /// Returns the interval as a raw number of days.
59    pub fn get(self) -> u16 {
60        self.0
61    }
62}
63
64/// A day of the month from 1 through 31.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
66pub struct DayOfMonth(u8);
67
68impl DayOfMonth {
69    /// Creates a day of the month.
70    ///
71    /// Returns `None` if `value` is not between 1 - 31.
72    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    /// Returns the raw day of the month.
81    pub fn get(self) -> u8 {
82        self.0
83    }
84}
85
86/// An inclusive date range.
87#[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    /// Creates an inclusive date range.
95    ///
96    /// Returns `None` if `end_date` is before `start_date`.
97    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    /// Returns the first date in the range.
109    pub fn start_date(self) -> NaiveDate {
110        self.start_date
111    }
112
113    /// Returns the last date in the range.
114    pub fn end_date(self) -> NaiveDate {
115        self.end_date
116    }
117
118    /// Returns every date in the range.
119    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/// A recurring schedule anchored to a start date.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
135pub struct Schedule {
136    starts_on: NaiveDate,
137    frequency: Frequency,
138}
139
140impl Schedule {
141    /// Creates a new schedule.
142    pub fn new(starts_on: NaiveDate, frequency: Frequency) -> Self {
143        Self {
144            starts_on,
145            frequency,
146        }
147    }
148
149    /// Returns the schedule's anchor date.
150    pub fn starts_on(self) -> NaiveDate {
151        self.starts_on
152    }
153
154    /// Returns the schedule frequency.
155    pub fn frequency(self) -> Frequency {
156        self.frequency
157    }
158
159    /// Returns all occurrence dates inside `range` that match the given frequency.
160    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}