Skip to main content

rapport_temporal/recurrence/
monthly.rs

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            // the fifth of any pattern will be found within 6 months
158            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        // fallback
166        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        // Check each possible day in the month
178        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        // Select the appropriate occurrence based on the ordinal
197        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    // First ordinal tests
239    #[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    // Second ordinal tests
249    #[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    // Third ordinal tests
269    #[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    // Fourth ordinal tests
279    #[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    // Fifth ordinal tests (note: no 5th Sunday in July 2025)
289    #[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    // NextToLast ordinal tests
299    #[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    // Last ordinal tests
324    #[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}