Skip to main content

rapport_temporal/recurrence/
yearly.rs

1use nonempty::NonEmpty;
2
3use crate::DisplayExt;
4use crate::date::{Date, Month, Year};
5
6use super::{FrequencyToken, Interval, OrdinalMonthlyRecurrence, Schedule};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct YearlyRecurrenceSchedule {
10    interval: Interval,
11    months: NonEmpty<Month>,
12    ordinal: Option<OrdinalMonthlyRecurrence>,
13}
14
15impl YearlyRecurrenceSchedule {
16    pub fn new(
17        interval: Interval,
18        months: impl Into<NonEmpty<Month>>,
19        ordinal: Option<OrdinalMonthlyRecurrence>,
20    ) -> Self {
21        Self {
22            interval,
23            months: months.into(),
24            ordinal,
25        }
26    }
27
28    fn find_occurrence_in_year_month(
29        &self,
30        year: Year,
31        month: Month,
32        reference_date: Date,
33    ) -> Option<Date> {
34        let month_num = month.to_month_number();
35        let reference_day = reference_date.day();
36
37        if let Some(ordinal_recurrence) = &self.ordinal {
38            // Use ordinal-based calculation
39            let first_of_month = Date::from_ymd_opt(year, month_num, 1)?;
40            let value = ordinal_recurrence.find_occurrence_in_month(first_of_month);
41            tracing::debug!(
42                "ordinal occurrence in year: {year}, month: {month} for ordinal {ordinal_recurrence} is {}",
43                value.map(Date::into_iso_string).displayed()
44            );
45            value
46        } else {
47            // Use same day of month as reference date
48            let value = Date::from_ymd_opt(year, month_num, reference_day);
49            tracing::debug!(
50                "non-ordinal occurrence in year: {year}, month: {month} for date {} is {}",
51                reference_date.into_iso_string(),
52                value.map(Date::into_iso_string).displayed()
53            );
54            value
55        }
56    }
57
58    pub(super) fn yearly(starting: Date) -> Self {
59        Self::yearly_in_month(starting.month())
60    }
61
62    pub(super) fn yearly_in_month(month: Month) -> Self {
63        Self::new(Interval::one(), NonEmpty::singleton(month), None)
64    }
65}
66
67impl Schedule for YearlyRecurrenceSchedule {
68    fn interval(&self) -> Interval {
69        self.interval
70    }
71
72    fn next_occurrence_after(&self, value: Date) -> Date {
73        let current_year = value.year();
74
75        // Check if there's a valid occurrence in the current year after the given date
76        for month in self.months.iter() {
77            if let Some(candidate) = self.find_occurrence_in_year_month(current_year, *month, value)
78                && candidate > value
79            {
80                return candidate;
81            }
82        }
83
84        // Look forward in time for the next valid occurrence
85        for year_offset in 1..=100 {
86            // reasonable upper bound
87            let target_year =
88                current_year.saturating_add(year_offset * i32::from(self.interval.get()));
89
90            for month in self.months.iter() {
91                if let Some(candidate) =
92                    self.find_occurrence_in_year_month(target_year, *month, value)
93                {
94                    return candidate;
95                }
96            }
97        }
98
99        // Fallback
100        value
101    }
102
103    fn into_string(self, starting: Date) -> String {
104        let interval_text = if self.interval.get() == 1 {
105            FrequencyToken::Yearly.to_string()
106        } else {
107            format!("every {} years", self.interval)
108        };
109        let month_text = if self.months.len() == 1 && self.months.head == starting.month() {
110            tracing::debug!("not printing month since the month specified is the same as starting");
111            interval_text
112        } else {
113            tracing::debug!("printing month(s) since they differ from the starting month");
114            let month_text = super::print_elements(&self.months);
115            format!("{interval_text} {} {month_text}", super::GrammarToken::In)
116        };
117
118        if let Some(ordinal) = self.ordinal {
119            format!(
120                "{month_text} {} {} {ordinal}",
121                super::GrammarToken::On,
122                super::GrammarToken::The
123            )
124        } else {
125            month_text
126        }
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::YearlyRecurrenceSchedule;
133    use crate::date::Date;
134    use crate::recurrence::Schedule;
135    use crate::{
136        date::{Month, Weekday},
137        recurrence::{DaySpecification, Interval, Ordinal, OrdinalMonthlyRecurrence},
138    };
139    use claims::assert_some;
140    use nonempty::NonEmpty;
141    use rstest::rstest;
142    use tracing_test::traced_test;
143
144    #[traced_test]
145    #[rstest]
146    #[case::normal_date(
147        "2025-06-07",
148        YearlyRecurrenceSchedule::yearly_in_month(Month::June),
149        "2026-06-07"
150    )]
151    #[case::leap_year(
152        "2024-02-29",
153        YearlyRecurrenceSchedule::yearly_in_month(Month::February),
154        "2028-02-29"
155    )]
156    #[case::multiple_months(
157        "2025-06-07",
158        YearlyRecurrenceSchedule::new(
159            Interval::one(),
160            assert_some!(NonEmpty::from_slice(&[Month::April, Month::September])),
161            None),
162        "2025-09-07")]
163    #[case::every_two_years(
164        "2025-06-07",
165        YearlyRecurrenceSchedule { interval: Interval::two(), months: NonEmpty::singleton(Month::June), ordinal: None },
166        "2027-06-07"
167    )]
168    #[case::every_three_years(
169        "2025-03-15",
170        YearlyRecurrenceSchedule { interval: Interval::three(), months: NonEmpty::singleton(Month::March), ordinal: None },
171        "2028-03-15"
172    )]
173    #[case::multiple_months_after_all(
174        "2025-11-15",
175        YearlyRecurrenceSchedule { interval: Interval::one(), months: assert_some!(NonEmpty::from_slice(&[Month::March, Month::July])), ordinal: None },
176        "2026-03-15"
177    )]
178    #[case::multiple_months_before_all(
179        "2025-01-15",
180        YearlyRecurrenceSchedule { interval: Interval::one(), months: assert_some!(NonEmpty::from_slice(&[Month::June, Month::October])), ordinal: None },
181        "2025-06-15"
182    )]
183    #[case::end_of_year_transition(
184        "2025-12-31",
185        YearlyRecurrenceSchedule { interval: Interval::one(), months: NonEmpty::singleton(Month::January), ordinal: None },
186        "2026-01-31"
187    )]
188    #[case::leap_year_non_feb_date(
189        "2024-07-15",
190        YearlyRecurrenceSchedule { interval: Interval::one(), months: NonEmpty::singleton(Month::July), ordinal: None },
191        "2025-07-15"
192    )]
193    #[case::with_ordinal_first_monday(
194        "2025-06-07",
195        YearlyRecurrenceSchedule {
196            interval: Interval::one(),
197            months: NonEmpty::singleton(Month::July),
198            ordinal: Some(OrdinalMonthlyRecurrence::new(Ordinal::First, DaySpecification::Specific(Weekday::Monday)))
199        },
200        "2025-07-07"
201    )]
202    #[case::with_ordinal_last_friday_december(
203        "2025-06-07",
204        YearlyRecurrenceSchedule {
205            interval: Interval::one(),
206            months: NonEmpty::singleton(Month::December),
207            ordinal: Some(OrdinalMonthlyRecurrence::new(Ordinal::Last, DaySpecification::Specific(Weekday::Friday)))
208        },
209        "2025-12-26"
210    )]
211    #[case::with_ordinal_every_two_years_will_choose_next_occurrence(
212        "2025-06-07",
213        YearlyRecurrenceSchedule {
214            interval: Interval::two(),
215            months: NonEmpty::singleton(Month::June),
216            ordinal: Some(OrdinalMonthlyRecurrence::new(Ordinal::Second, DaySpecification::Specific(Weekday::Tuesday)))
217        },
218        "2025-06-10" // the second tuesday after the given date is just two days away!
219    )]
220    #[case::different_day_value(
221        "2025-05-01",
222        YearlyRecurrenceSchedule { interval: Interval::one(), months: NonEmpty::singleton(Month::May), ordinal: None },
223        "2026-05-01"
224    )]
225    #[case::february_non_leap_to_leap(
226        "2025-02-28",
227        YearlyRecurrenceSchedule { interval: Interval::one(), months: NonEmpty::singleton(Month::February), ordinal: None },
228        "2026-02-28"
229    )]
230    fn yearly_next_occurrence_after(
231        #[case] from: &str,
232        #[case] schedule: YearlyRecurrenceSchedule,
233        #[case] expected: &str,
234    ) {
235        let from = Date::from_str_unchecked(from);
236        let expected = Date::from_str_unchecked(expected);
237        let actual = schedule.next_occurrence_after(from);
238
239        assert_eq!(actual, expected);
240    }
241}