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}