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    /// Returns true if the given date falls inside the range.
133    pub fn contains(&self, date: NaiveDate) -> bool {
134        date >= self.start_date && date <= self.end_date
135    }
136}
137
138/// A recurring schedule anchored to a start date.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
140pub struct Schedule {
141    starts_on: NaiveDate,
142    frequency: Frequency,
143}
144
145impl Schedule {
146    /// Creates a new schedule.
147    pub fn new(starts_on: NaiveDate, frequency: Frequency) -> Self {
148        Self {
149            starts_on,
150            frequency,
151        }
152    }
153
154    /// Returns the schedule's anchor date.
155    pub fn starts_on(&self) -> NaiveDate {
156        self.starts_on
157    }
158
159    /// Returns the schedule frequency.
160    pub fn frequency(&self) -> Frequency {
161        self.frequency
162    }
163
164    /// Returns all occurrence dates inside `range` that match the given frequency.
165    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}