rapport_temporal/recurrence/
yearly.rs1use 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 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 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 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 for year_offset in 1..=100 {
86 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 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" )]
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}