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}