Skip to main content

use_recurrence/
lib.rs

1#![forbid(unsafe_code)]
2//! Primitive recurrence helpers.
3//!
4//! The first pass supports simple daily, weekly, monthly, and yearly rules
5//! without timezone handling.
6//!
7//! # Examples
8//!
9//! ```rust
10//! use use_date::CalendarDate;
11//! use use_recurrence::{RecurrenceFrequency, recurring_dates};
12//!
13//! let start = CalendarDate::new(2024, 1, 31).unwrap();
14//! let dates = recurring_dates(start, RecurrenceFrequency::Monthly, 1, 4).unwrap();
15//!
16//! assert_eq!(dates[1], CalendarDate::new(2024, 2, 29).unwrap());
17//! assert_eq!(dates[2], CalendarDate::new(2024, 3, 31).unwrap());
18//! assert_eq!(dates[3], CalendarDate::new(2024, 4, 30).unwrap());
19//! ```
20
21use 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}