1use nonempty::NonEmpty;
2
3use crate::date::{Date, DayOfMonth};
4
5use super::{DaySpecification, Interval, Ordinal, Schedule, TemporalUnit};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct MonthlyRecurrenceSchedule {
9 interval: Interval,
10 matching: MonthlySpecification,
11}
12
13impl MonthlyRecurrenceSchedule {
14 #[must_use]
15 pub fn new(interval: Interval, matching: MonthlySpecification) -> Self {
16 Self { interval, matching }
17 }
18
19 #[must_use]
20 pub fn take(self) -> (Interval, MonthlySpecification) {
21 (self.interval, self.matching)
22 }
23
24 pub(crate) fn monthly(starting: Date) -> MonthlyRecurrenceSchedule {
25 Self::monthly_each(starting.day_of_month())
26 }
27
28 pub fn monthly_each(days: impl Into<NonEmpty<DayOfMonth>>) -> Self {
29 Self::each(Interval::one(), days)
30 }
31
32 pub(super) fn each(interval: Interval, days: impl Into<NonEmpty<DayOfMonth>>) -> Self {
33 Self::new(interval, MonthlySpecification::Each(days.into()))
34 }
35
36 pub(crate) fn quarterly(starting: Date) -> MonthlyRecurrenceSchedule {
37 Self::each(Interval::three(), starting.day_of_month())
38 }
39}
40
41impl Schedule for MonthlyRecurrenceSchedule {
42 fn next_occurrence_after(&self, from: Date) -> Date {
43 std::iter::successors(Some(from), |&date| {
44 Some(self.matching.next_occurrence_after(date))
45 })
46 .nth(self.interval.get().into())
47 .unwrap_or_default()
48 }
49
50 fn into_string(self, starting: Date) -> String {
51 let Self { interval, matching } = self;
52 let printed = TemporalUnit::Month.print_interval(interval);
53 match matching {
54 MonthlySpecification::Each(days) => {
55 if days.len() == 1 && days.head == starting.day_of_month() {
56 printed
57 } else {
58 let days_description = super::print_elements(&days);
59 format!(
60 "{printed} {} {} {days_description}",
61 super::GrammarToken::On,
62 super::GrammarToken::The
63 )
64 }
65 }
66 MonthlySpecification::OnThe(ordinal) => {
67 let ordinal_description = ordinal.to_string();
68 format!(
69 "{printed} {} {} {ordinal_description}",
70 super::GrammarToken::On,
71 super::GrammarToken::The
72 )
73 }
74 }
75 }
76
77 fn interval(&self) -> Interval {
78 self.interval
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum MonthlySpecification {
84 Each(NonEmpty<DayOfMonth>),
85 OnThe(OrdinalMonthlyRecurrence),
86}
87
88impl MonthlySpecification {
89 pub fn each(days: impl Into<NonEmpty<DayOfMonth>>) -> Self {
90 let days = days.into();
91 Self::Each(days)
92 }
93
94 fn next_occurrence_after(&self, value: Date) -> Date {
95 match self {
96 MonthlySpecification::Each(days) => {
97 next_occurrence_within_month_inclusive(value.day_after(), days).unwrap_or_else(
98 || {
99 next_occurrence_within_month_inclusive(value.first_of_next_month(), days)
100 .unwrap_or_else(|| value.last_day_of_next_month())
101 },
102 )
103 }
104 MonthlySpecification::OnThe(recurrence) => recurrence.next_occurrence_after(value),
105 }
106 }
107
108 #[must_use]
109 pub fn into_each(self) -> Option<NonEmpty<DayOfMonth>> {
110 match self {
111 MonthlySpecification::Each(value) => Some(value),
112 MonthlySpecification::OnThe(_) => None,
113 }
114 }
115
116 #[must_use]
117 pub fn into_on_the(self) -> Option<OrdinalMonthlyRecurrence> {
118 match self {
119 MonthlySpecification::OnThe(value) => Some(value),
120 MonthlySpecification::Each(_) => None,
121 }
122 }
123}
124
125fn next_occurrence_within_month_inclusive(date: Date, days: &NonEmpty<DayOfMonth>) -> Option<Date> {
126 let current_day = date.day_of_month().to_value();
127 tracing::debug!(
128 "evaluting next occurrence inclusive of {} for days {}",
129 date.into_iso_string(),
130 days.iter()
131 .map(ToString::to_string)
132 .collect::<Vec<_>>()
133 .join(",")
134 );
135 days.iter()
136 .filter(|d| d.to_value() >= current_day)
137 .min()
138 .and_then(|d| date.with_day(*d))
139}
140
141#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, derive_more::Display)]
142#[display("{ordinal} {day}")]
143pub struct OrdinalMonthlyRecurrence {
144 pub ordinal: Ordinal,
145 pub day: DaySpecification,
146}
147
148impl OrdinalMonthlyRecurrence {
149 #[must_use]
150 pub fn new(ordinal: Ordinal, day: DaySpecification) -> Self {
151 Self { ordinal, day }
152 }
153
154 fn next_occurrence_after(self, value: Date) -> Date {
155 let mut current_month = value.first_of_month();
156 for _ in 0..6 {
157 if let Some(occurrence) = self.find_occurrence_in_month(current_month)
159 && occurrence > value
160 {
161 return occurrence;
162 }
163 current_month = current_month.first_of_next_month();
164 }
165 tracing::error!(
167 "next occurrence after {} could not be calculated, falling back to given date",
168 value.into_iso_string()
169 );
170 value
171 }
172
173 pub(super) fn find_occurrence_in_month(self, first_day_of_month: Date) -> Option<Date> {
174 let mut matching_days = Vec::new();
175 let year = first_day_of_month.year();
176 let month = first_day_of_month.month();
177 for day in DayOfMonth::range() {
179 if let Some(date) = Date::from_ymd_opt(year, month.to_month_number(), day)
180 && self.day.is_satisfied_by(date)
181 {
182 matching_days.push(date);
183 }
184 }
185
186 tracing::debug!(
187 "{self} matching days for {}: {}",
188 first_day_of_month.into_iso_string(),
189 matching_days
190 .iter()
191 .map(ToString::to_string)
192 .collect::<Vec<_>>()
193 .join(",")
194 );
195
196 match self.ordinal {
198 Ordinal::First => matching_days.first().copied(),
199 Ordinal::Second => matching_days.get(1).copied(),
200 Ordinal::Third => matching_days.get(2).copied(),
201 Ordinal::Fourth => matching_days.get(3).copied(),
202 Ordinal::Fifth => matching_days.get(4).copied(),
203 Ordinal::NextToLast => matching_days.iter().rev().nth(1).copied(),
204 Ordinal::Last => matching_days.last().copied(),
205 }
206 }
207
208 #[must_use]
209 pub fn with_another_ordinal(self, ordinal: Ordinal) -> Self {
210 let Self { ordinal: _, day } = self;
211 Self { ordinal, day }
212 }
213
214 #[must_use]
215 pub fn with_another_day_specification(self, day: DaySpecification) -> Self {
216 let Self { ordinal, day: _ } = self;
217 Self { ordinal, day }
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use rstest::rstest;
225 use tracing_test::traced_test;
226
227 use super::MonthlyRecurrenceSchedule;
228 use crate::recurrence::MonthlySpecification;
229 use crate::{
230 date::{DayOfMonth, Weekday},
231 recurrence::{DaySpecification, Interval, Ordinal},
232 };
233 use claims::assert_some;
234 use nonempty::NonEmpty;
235
236 #[traced_test]
237 #[rstest]
238 #[case::first_specific_sunday(
240 "2025-06-02",
241 Ordinal::First,
242 DaySpecification::Specific(Weekday::Sunday),
243 "2025-07-06"
244 )]
245 #[case::first_any("2025-06-02", Ordinal::First, DaySpecification::Any, "2025-07-01")]
246 #[case::first_weekday("2025-06-02", Ordinal::First, DaySpecification::Weekday, "2025-07-01")]
247 #[case::first_weekend("2025-06-02", Ordinal::First, DaySpecification::Weekend, "2025-07-05")]
248 #[case::second_specific_sunday(
250 "2025-06-02",
251 Ordinal::Second,
252 DaySpecification::Specific(Weekday::Sunday),
253 "2025-06-08"
254 )]
255 #[case::second_any("2025-06-02", Ordinal::Second, DaySpecification::Any, "2025-07-02")]
256 #[case::second_weekday_should_be_first_weekday_available_that_is_second(
257 "2025-06-02",
258 Ordinal::Second,
259 DaySpecification::Weekday,
260 "2025-06-03"
261 )]
262 #[case::second_weekend_should_be_first_weekend_available_that_is_second(
263 "2025-06-02",
264 Ordinal::Second,
265 DaySpecification::Weekend,
266 "2025-06-07"
267 )]
268 #[case::third_specific_sunday(
270 "2025-06-02",
271 Ordinal::Third,
272 DaySpecification::Specific(Weekday::Sunday),
273 "2025-06-15"
274 )]
275 #[case::third_any("2025-06-02", Ordinal::Third, DaySpecification::Any, "2025-06-03")]
276 #[case::third_weekday("2025-06-02", Ordinal::Third, DaySpecification::Weekday, "2025-06-04")]
277 #[case::third_weekend("2025-06-02", Ordinal::Third, DaySpecification::Weekend, "2025-06-08")]
278 #[case::fourth_specific_sunday(
280 "2025-06-02",
281 Ordinal::Fourth,
282 DaySpecification::Specific(Weekday::Sunday),
283 "2025-06-22"
284 )]
285 #[case::fourth_any("2025-06-02", Ordinal::Fourth, DaySpecification::Any, "2025-06-04")]
286 #[case::fourth_weekday("2025-06-02", Ordinal::Fourth, DaySpecification::Weekday, "2025-06-05")]
287 #[case::fourth_weekend("2025-06-02", Ordinal::Fourth, DaySpecification::Weekend, "2025-06-14")]
288 #[case::fifth_specific_monday(
290 "2025-06-02",
291 Ordinal::Fifth,
292 DaySpecification::Specific(Weekday::Monday),
293 "2025-06-30"
294 )]
295 #[case::fifth_any("2025-06-02", Ordinal::Fifth, DaySpecification::Any, "2025-06-05")]
296 #[case::fifth_weekday("2025-06-02", Ordinal::Fifth, DaySpecification::Weekday, "2025-06-06")]
297 #[case::fifth_weekend("2025-06-02", Ordinal::Fifth, DaySpecification::Weekend, "2025-06-15")]
298 #[case::next_to_last_specific_sunday(
300 "2025-06-02",
301 Ordinal::NextToLast,
302 DaySpecification::Specific(Weekday::Sunday),
303 "2025-06-22"
304 )]
305 #[case::next_to_last_any(
306 "2025-06-02",
307 Ordinal::NextToLast,
308 DaySpecification::Any,
309 "2025-06-29"
310 )]
311 #[case::next_to_last_weekday(
312 "2025-06-02",
313 Ordinal::NextToLast,
314 DaySpecification::Weekday,
315 "2025-06-27"
316 )]
317 #[case::next_to_last_weekend(
318 "2025-06-02",
319 Ordinal::NextToLast,
320 DaySpecification::Weekend,
321 "2025-06-28"
322 )]
323 #[case::last_specific_sunday(
325 "2025-06-02",
326 Ordinal::Last,
327 DaySpecification::Specific(Weekday::Sunday),
328 "2025-06-29"
329 )]
330 #[case::last_any("2025-06-02", Ordinal::Last, DaySpecification::Any, "2025-06-30")]
331 #[case::last_weekday("2025-06-02", Ordinal::Last, DaySpecification::Weekday, "2025-06-30")]
332 #[case::last_weekend("2025-06-02", Ordinal::Last, DaySpecification::Weekend, "2025-06-29")]
333 #[case::skip_when_fifth_day_does_not_exist(
334 "2025-06-02",
335 Ordinal::Fifth,
336 DaySpecification::Specific(Weekday::Tuesday),
337 "2025-07-29"
338 )]
339 #[case::extreme_fifth_use_case(
340 "2025-01-31",
341 Ordinal::Fifth,
342 DaySpecification::Specific(Weekday::Wednesday),
343 "2025-04-30"
344 )]
345 fn monthly_next_occurrence_ordinal(
346 #[case] from: &str,
347 #[case] ordinal: Ordinal,
348 #[case] day: DaySpecification,
349 #[case] expected: &str,
350 ) {
351 let from = Date::from_str_unchecked(from);
352 let expected = Date::from_str_unchecked(expected);
353
354 let recurrence = MonthlyRecurrenceSchedule::new(
355 Interval::one(),
356 MonthlySpecification::OnThe(OrdinalMonthlyRecurrence { ordinal, day }),
357 );
358
359 let actual = recurrence.next_occurrence_after(from);
360
361 assert_eq!(actual, expected);
362 }
363
364 #[traced_test]
365 #[rstest]
366 #[case::same_day("2025-06-07",Interval::one(), &[DayOfMonth::Seventh], "2025-07-07")]
367 #[case::next_day("2025-06-07",Interval::one(), &[DayOfMonth::Eighth], "2025-06-08")]
368 #[case::next_month("2025-06-07",Interval::one(), &[DayOfMonth::First], "2025-07-01")]
369 #[case::skip_months_without_num_days("2025-06-07",Interval::one(), &[DayOfMonth::ThirtyFirst], "2025-07-31")]
370 #[case::multiple_days("2025-06-07",Interval::one(), &[DayOfMonth::Tenth, DayOfMonth::Eleventh, DayOfMonth::Twelfth], "2025-06-10")]
371 #[case::next_year("2025-12-20", Interval::one(), &[DayOfMonth::Fifth], "2026-01-05")]
372 #[case::every_two_months("2025-06-07", Interval::two(), &[DayOfMonth::Fifth], "2025-08-05")]
373 #[case::every_three_months("2025-06-07", Interval::three(), &[DayOfMonth::Seventh], "2025-09-07")]
374 #[case::day_31_falls_back_to_last_day("2025-08-31", Interval::one(), &[DayOfMonth::ThirtyFirst], "2025-09-30")]
375 #[case::quarterly_from_31st("2025-08-31", Interval::three(), &[DayOfMonth::ThirtyFirst], "2025-11-30")]
376 fn monthly_next_occurrence_each(
377 #[case] from: &str,
378 #[case] interval: Interval,
379 #[case] days: &[DayOfMonth],
380 #[case] expected: &str,
381 ) {
382 let from = Date::from_str_unchecked(from);
383 let expected = Date::from_str_unchecked(expected);
384 let days = assert_some!(NonEmpty::from_slice(days));
385
386 let recurrence = MonthlyRecurrenceSchedule::new(interval, MonthlySpecification::Each(days));
387
388 let actual = recurrence.next_occurrence_after(from);
389
390 assert_eq!(actual, expected);
391 }
392
393 #[test]
394 fn ordinal_monthly_recurrence_should_default() {
395 let recurrence = OrdinalMonthlyRecurrence::default();
396
397 assert_eq!(
398 recurrence,
399 OrdinalMonthlyRecurrence::new(Ordinal::First, DaySpecification::Any),
400 "expecting the ordinal recurrence to default to the first day"
401 );
402 }
403}