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