Skip to main content

aven_core/recurrence/
rule.rs

1use std::fmt;
2use std::str::FromStr;
3
4use chrono::{Datelike, Weekday};
5use serde::{Deserialize, Deserializer, Serialize, Serializer};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum RecurrenceFrequency {
10    Daily,
11    Weekly,
12    Monthly,
13}
14
15impl RecurrenceFrequency {
16    pub const fn as_str(self) -> &'static str {
17        match self {
18            Self::Daily => "daily",
19            Self::Weekly => "weekly",
20            Self::Monthly => "monthly",
21        }
22    }
23
24    pub fn parse(value: &str) -> Result<Self, InvalidRecurrenceFrequency> {
25        match value {
26            "daily" => Ok(Self::Daily),
27            "weekly" => Ok(Self::Weekly),
28            "monthly" => Ok(Self::Monthly),
29            _ => Err(InvalidRecurrenceFrequency(value.to_string())),
30        }
31    }
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct InvalidRecurrenceFrequency(String);
36
37impl fmt::Display for InvalidRecurrenceFrequency {
38    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
39        write!(formatter, "invalid recurrence frequency: {}", self.0)
40    }
41}
42
43impl std::error::Error for InvalidRecurrenceFrequency {}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
46pub struct WeekdaySet(u8);
47
48impl WeekdaySet {
49    const MONDAY: u8 = 1 << 0;
50    const TUESDAY: u8 = 1 << 1;
51    const WEDNESDAY: u8 = 1 << 2;
52    const THURSDAY: u8 = 1 << 3;
53    const FRIDAY: u8 = 1 << 4;
54    const SATURDAY: u8 = 1 << 5;
55    const SUNDAY: u8 = 1 << 6;
56
57    pub fn from_weekdays(weekdays: impl IntoIterator<Item = Weekday>) -> Self {
58        let mut bits = 0;
59        for weekday in weekdays {
60            bits |= Self::bit(weekday);
61        }
62        Self(bits)
63    }
64
65    pub fn weekdays() -> Self {
66        Self(Self::MONDAY | Self::TUESDAY | Self::WEDNESDAY | Self::THURSDAY | Self::FRIDAY)
67    }
68
69    pub fn contains(self, weekday: Weekday) -> bool {
70        self.0 & Self::bit(weekday) != 0
71    }
72
73    pub fn is_empty(self) -> bool {
74        self.0 == 0
75    }
76
77    pub fn iter(self) -> impl Iterator<Item = Weekday> {
78        [
79            Weekday::Mon,
80            Weekday::Tue,
81            Weekday::Wed,
82            Weekday::Thu,
83            Weekday::Fri,
84            Weekday::Sat,
85            Weekday::Sun,
86        ]
87        .into_iter()
88        .filter(move |weekday| self.contains(*weekday))
89    }
90
91    const fn bit(weekday: Weekday) -> u8 {
92        match weekday {
93            Weekday::Mon => Self::MONDAY,
94            Weekday::Tue => Self::TUESDAY,
95            Weekday::Wed => Self::WEDNESDAY,
96            Weekday::Thu => Self::THURSDAY,
97            Weekday::Fri => Self::FRIDAY,
98            Weekday::Sat => Self::SATURDAY,
99            Weekday::Sun => Self::SUNDAY,
100        }
101    }
102}
103
104impl fmt::Display for WeekdaySet {
105    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
106        let mut separator = "";
107        for weekday in self.iter() {
108            formatter.write_str(separator)?;
109            formatter.write_str(weekday_name(weekday))?;
110            separator = ",";
111        }
112        Ok(())
113    }
114}
115
116impl FromStr for WeekdaySet {
117    type Err = &'static str;
118
119    fn from_str(value: &str) -> Result<Self, Self::Err> {
120        parse_weekday_set(value)
121    }
122}
123
124impl Serialize for WeekdaySet {
125    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
126    where
127        S: Serializer,
128    {
129        serializer.serialize_str(&self.to_string())
130    }
131}
132
133impl<'de> Deserialize<'de> for WeekdaySet {
134    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
135    where
136        D: Deserializer<'de>,
137    {
138        let value = String::deserialize(deserializer)?;
139        parse_weekday_set(&value).map_err(serde::de::Error::custom)
140    }
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
144pub struct RecurrenceRule {
145    frequency: RecurrenceFrequency,
146    interval: u32,
147    weekdays: WeekdaySet,
148}
149
150impl RecurrenceRule {
151    pub fn new(
152        frequency: RecurrenceFrequency,
153        interval: u32,
154        weekdays: WeekdaySet,
155    ) -> Result<Self, InvalidRecurrenceRule> {
156        match frequency {
157            RecurrenceFrequency::Daily if interval != 1 => {
158                return Err(InvalidRecurrenceRule::DailyInterval);
159            }
160            RecurrenceFrequency::Daily if !weekdays.is_empty() => {
161                return Err(InvalidRecurrenceRule::DailyWeekdays);
162            }
163            RecurrenceFrequency::Weekly if interval == 0 => {
164                return Err(InvalidRecurrenceRule::ZeroInterval);
165            }
166            RecurrenceFrequency::Weekly if weekdays.is_empty() => {
167                return Err(InvalidRecurrenceRule::EmptyWeekdays);
168            }
169            RecurrenceFrequency::Monthly if interval != 1 => {
170                return Err(InvalidRecurrenceRule::MonthlyInterval);
171            }
172            RecurrenceFrequency::Monthly if !weekdays.is_empty() => {
173                return Err(InvalidRecurrenceRule::MonthlyWeekdays);
174            }
175            _ => {}
176        }
177        Ok(Self {
178            frequency,
179            interval,
180            weekdays,
181        })
182    }
183
184    pub fn daily() -> Self {
185        Self {
186            frequency: RecurrenceFrequency::Daily,
187            interval: 1,
188            weekdays: WeekdaySet::default(),
189        }
190    }
191
192    pub fn weekdays() -> Self {
193        Self {
194            frequency: RecurrenceFrequency::Weekly,
195            interval: 1,
196            weekdays: WeekdaySet::weekdays(),
197        }
198    }
199
200    pub fn monthly() -> Self {
201        Self {
202            frequency: RecurrenceFrequency::Monthly,
203            interval: 1,
204            weekdays: WeekdaySet::default(),
205        }
206    }
207
208    pub fn weekly(weekday: Weekday) -> Self {
209        Self::weekly_on([weekday]).expect("one weekday is a valid weekly rule")
210    }
211
212    pub fn weekly_on(
213        weekdays: impl IntoIterator<Item = Weekday>,
214    ) -> Result<Self, InvalidRecurrenceRule> {
215        Self::every_n_weeks_on(1, weekdays)
216    }
217
218    pub fn every_n_weeks_on(
219        interval: u32,
220        weekdays: impl IntoIterator<Item = Weekday>,
221    ) -> Result<Self, InvalidRecurrenceRule> {
222        Self::new(
223            RecurrenceFrequency::Weekly,
224            interval,
225            WeekdaySet::from_weekdays(weekdays),
226        )
227    }
228
229    pub fn frequency(self) -> RecurrenceFrequency {
230        self.frequency
231    }
232
233    pub fn interval(self) -> u32 {
234        self.interval
235    }
236
237    pub fn weekdays_set(self) -> WeekdaySet {
238        self.weekdays
239    }
240
241    pub(crate) fn matches(self, start_on: chrono::NaiveDate, date: chrono::NaiveDate) -> bool {
242        if date < start_on {
243            return false;
244        }
245        match self.frequency {
246            RecurrenceFrequency::Daily => true,
247            RecurrenceFrequency::Weekly => {
248                let days_from_anchor = i64::from(start_on.weekday().num_days_from_monday())
249                    + date.signed_duration_since(start_on).num_days();
250                let weeks = days_from_anchor / 7;
251                weeks % i64::from(self.interval) == 0 && self.weekdays.contains(date.weekday())
252            }
253            RecurrenceFrequency::Monthly => {
254                date.day() == start_on.day().min(last_day_of_month(date))
255            }
256        }
257    }
258}
259
260impl<'de> Deserialize<'de> for RecurrenceRule {
261    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
262    where
263        D: Deserializer<'de>,
264    {
265        #[derive(Deserialize)]
266        struct RuleWire {
267            frequency: RecurrenceFrequency,
268            interval: u32,
269            weekdays: WeekdaySet,
270        }
271
272        let wire = RuleWire::deserialize(deserializer)?;
273        Self::new(wire.frequency, wire.interval, wire.weekdays).map_err(serde::de::Error::custom)
274    }
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub enum InvalidRecurrenceRule {
279    ZeroInterval,
280    EmptyWeekdays,
281    DailyInterval,
282    DailyWeekdays,
283    MonthlyInterval,
284    MonthlyWeekdays,
285}
286
287impl fmt::Display for InvalidRecurrenceRule {
288    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
289        formatter.write_str(match self {
290            Self::ZeroInterval => "weekly recurrence interval must be greater than zero",
291            Self::EmptyWeekdays => "weekly recurrence must contain at least one weekday",
292            Self::DailyInterval => "daily recurrence interval must be one",
293            Self::DailyWeekdays => "daily recurrence cannot contain a weekday set",
294            Self::MonthlyInterval => "monthly recurrence interval must be one",
295            Self::MonthlyWeekdays => "monthly recurrence cannot contain a weekday set",
296        })
297    }
298}
299
300impl std::error::Error for InvalidRecurrenceRule {}
301
302fn last_day_of_month(date: chrono::NaiveDate) -> u32 {
303    (28..=31)
304        .rev()
305        .find(|day| date.with_day(*day).is_some())
306        .expect("every calendar month has at least 28 days")
307}
308
309fn parse_weekday_set(value: &str) -> Result<WeekdaySet, &'static str> {
310    if value.is_empty() {
311        return Ok(WeekdaySet::default());
312    }
313    let mut weekdays = Vec::new();
314    for part in value.split(',') {
315        let weekday = match part {
316            "mon" => Weekday::Mon,
317            "tue" => Weekday::Tue,
318            "wed" => Weekday::Wed,
319            "thu" => Weekday::Thu,
320            "fri" => Weekday::Fri,
321            "sat" => Weekday::Sat,
322            "sun" => Weekday::Sun,
323            _ => return Err("weekday set must use canonical comma-separated weekday names"),
324        };
325        if weekdays.contains(&weekday) {
326            return Err("weekday set cannot contain duplicate weekdays");
327        }
328        weekdays.push(weekday);
329    }
330    let set = WeekdaySet::from_weekdays(weekdays);
331    if set.to_string() != value {
332        return Err("weekday set must be ordered from Monday through Sunday");
333    }
334    Ok(set)
335}
336
337const fn weekday_name(weekday: Weekday) -> &'static str {
338    match weekday {
339        Weekday::Mon => "mon",
340        Weekday::Tue => "tue",
341        Weekday::Wed => "wed",
342        Weekday::Thu => "thu",
343        Weekday::Fri => "fri",
344        Weekday::Sat => "sat",
345        Weekday::Sun => "sun",
346    }
347}