Skip to main content

rapport_temporal/recurrence/
weekly.rs

1use nonempty::NonEmpty;
2use strum::EnumCount;
3
4use crate::date::{Date, Weekday};
5
6use super::{Interval, Schedule, TemporalUnit};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct WeeklyRecurrenceSchedule {
10    interval: Interval,
11    days: NonEmpty<Weekday>,
12}
13
14impl WeeklyRecurrenceSchedule {
15    pub fn new(interval: Interval, days: impl Into<NonEmpty<Weekday>>) -> Self {
16        let days = days.into();
17        Self { interval, days }
18    }
19
20    #[must_use]
21    pub fn for_weekday(interval: Interval, day: Weekday) -> Self {
22        Self::new(interval, NonEmpty::singleton(day))
23    }
24
25    /// Creates a schedule that is every two weeks from the starting date.
26    ///
27    /// # Panics
28    ///
29    /// Should not panic; panic is used when the internal hard coded value does not fit the invariants.
30    #[allow(clippy::expect_used)]
31    #[must_use]
32    pub fn every_two_weeks(starting: Date) -> Self {
33        Self::new(Interval::two(), starting.weekday())
34    }
35
36    #[must_use]
37    pub fn weekly_on(day: Weekday) -> Self {
38        Self::for_weekday(Interval::one(), day)
39    }
40
41    pub(crate) fn weekly_on_same_day_as(date: Date) -> WeeklyRecurrenceSchedule {
42        Self::weekly_on(date.weekday())
43    }
44
45    /// Toggles the weekday to be included in the schedule if it's not included, and excluded if it
46    /// is already included. If the given weekday is the last weekday in the schedule, nothing will
47    /// hapen.
48    #[must_use]
49    pub fn with_weekday_toggled(self, weekday: Weekday) -> Self {
50        let Self { interval, days } = self;
51        let days = super::toggle_value(days, weekday);
52        Self { interval, days }
53    }
54}
55
56impl Schedule for WeeklyRecurrenceSchedule {
57    fn interval(&self) -> Interval {
58        self.interval
59    }
60
61    fn next_occurrence_after(&self, from: Date) -> Date {
62        // count next as one interval
63        let next = Weekday::next_occurrence_after_days(&self.days, from);
64        // remaining intervals
65        let remaining_intervals = self.interval.minus_one();
66        // remaining days
67        let remaining_days = Weekday::COUNT.saturating_mul(remaining_intervals.into());
68        next.add_days(remaining_days)
69    }
70
71    fn into_string(self, starting: Date) -> String {
72        let WeeklyRecurrenceSchedule { interval, days } = self;
73        let printed = TemporalUnit::Week.print_interval(interval);
74        if days.len() == 1 && days.head == starting.weekday() {
75            printed
76        } else {
77            tracing::debug!(
78                "writing out days for days: {} and starting weekday: {}",
79                days.iter()
80                    .map(ToString::to_string)
81                    .collect::<Vec<_>>()
82                    .join(", "),
83                starting.weekday()
84            );
85            let days_description = super::print_elements(&days);
86            format!(
87                "{printed} {} {days_description}",
88                super::parser::GrammarToken::On
89            )
90        }
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use rstest::rstest;
98
99    use crate::date::Weekday;
100    use claims::assert_some;
101    use nonempty::NonEmpty;
102
103    #[rstest]
104    #[case::weekly("2025-06-07", Interval::one(), &[Weekday::Saturday], "2025-06-14")]
105    #[case::different_day("2025-06-07", Interval::one(), &[Weekday::Tuesday], "2025-06-10")]
106    #[case::every_two_weeks("2025-06-07", Interval::two(), &[Weekday::Saturday], "2025-06-21")]
107    #[case::every_two_weeks_different_day("2025-06-07", Interval::two(), &[Weekday::Tuesday], "2025-06-17")]
108    #[case::multiple_days("2025-06-07", Interval::one(), &[Weekday::Monday, Weekday::Tuesday], "2025-06-09")]
109    fn next_occurrence_should_calculate_weekly(
110        #[case] from: &str,
111        #[case] interval: Interval,
112        #[case] days: &[Weekday],
113        #[case] expected: &str,
114    ) {
115        let recurrence =
116            WeeklyRecurrenceSchedule::new(interval, assert_some!(NonEmpty::from_slice(days)));
117
118        let from = Date::from_str_unchecked(from);
119        let expected_next = Date::from_str_unchecked(expected);
120
121        let actual_next = recurrence.next_occurrence_after(from);
122
123        assert_eq!(actual_next, expected_next);
124    }
125
126    #[rstest]
127    #[case::weekly_on_single_day("2025-06-07", Interval::one(), &[Weekday::Tuesday], "2025-06-10")]
128    #[case::every_two_weeks("2025-06-07", Interval::two(), &[Weekday::Tuesday], "2025-06-17")]
129    #[case::multiple_days_this_week("2025-06-07",Interval::one(), &[Weekday::Monday, Weekday::Tuesday], "2025-06-09")]
130    #[case::multiple_days_next_week("2025-06-07",Interval::one(), &[Weekday::Friday, Weekday::Thursday], "2025-06-12")]
131    #[case::multiple_days_every_two_weeks("2025-06-07",Interval::two(), &[Weekday::Friday, Weekday::Thursday], "2025-06-19")]
132    #[case::same_day_included("2025-06-07", Interval::one(),&[Weekday::Saturday, Weekday::Monday], "2025-06-09")]
133    #[case::all_next_week("2025-06-07", Interval::one(), &[Weekday::Wednesday, Weekday::Thursday, Weekday::Friday], "2025-06-11")]
134    #[case::next_week_is_next_month("2025-06-30", Interval::one(), &[Weekday::Monday], "2025-07-07")]
135    #[case::next_week_isnext_year("2025-12-30", Interval::one(), &[Weekday::Monday], "2026-01-05")]
136    fn weekly_next_occurrence_after(
137        #[case] from: &str,
138        #[case] interval: Interval,
139        #[case] days: &[Weekday],
140        #[case] expected: &str,
141    ) {
142        let from = Date::from_str_unchecked(from);
143        let expected = Date::from_str_unchecked(expected);
144        let days = assert_some!(NonEmpty::from_slice(days));
145
146        let recurrence = WeeklyRecurrenceSchedule::new(interval, days);
147
148        let actual = recurrence.next_occurrence_after(from);
149
150        assert_eq!(actual, expected);
151    }
152
153    #[rstest]
154    #[case::weekly(
155        WeeklyRecurrenceSchedule::weekly_on(Weekday::Monday),
156        "2025-06-09",
157        "weekly"
158    )]
159    #[case::every_three_weeks(
160        WeeklyRecurrenceSchedule::new(Interval::three(), NonEmpty::singleton(Weekday::Monday)),
161        "2025-06-09",
162        "every 3 weeks"
163    )]
164    #[case::every_three_weeks_on_another_day(
165        WeeklyRecurrenceSchedule::weekly_on(Weekday::Tuesday),
166        "2025-06-09",
167        "weekly on Tuesday"
168    )]
169    #[case::every_two_weeks(
170        WeeklyRecurrenceSchedule::new(Interval::two(), Weekday::Monday),
171        "2025-06-09",
172        "every 2 weeks"
173    )]
174    #[case::every_two_weeks_on_another_day(
175        WeeklyRecurrenceSchedule::new(Interval::two(), Weekday::Wednesday),
176        "2025-06-09",
177        "every 2 weeks on Wednesday"
178    )]
179    fn test_into_string(
180        #[case] schedule: WeeklyRecurrenceSchedule,
181        #[case] starting: &str,
182        #[case] expected: &str,
183    ) {
184        let starting = Date::from_str_unchecked(starting);
185
186        let actual = schedule.into_string(starting);
187
188        assert_eq!(actual, expected);
189    }
190
191    #[rstest]
192    #[case::toggle_on(&[Weekday::Monday], Weekday::Tuesday, &[Weekday::Monday, Weekday::Tuesday])]
193    #[case::toggle_on_from_multiple_days(&[Weekday::Monday, Weekday::Tuesday], Weekday::Wednesday, &[Weekday::Monday, Weekday::Tuesday, Weekday::Wednesday])]
194    #[case::toggle_off(&[Weekday::Monday, Weekday::Tuesday], Weekday::Tuesday, &[Weekday::Monday])]
195    #[case::toggle_does_nothing_for_singleton(&[Weekday::Monday], Weekday::Monday, &[Weekday::Monday])]
196    fn test_toggle_weekday(
197        #[case] from: &[Weekday],
198        #[case] toggle: Weekday,
199        #[case] expected: &[Weekday],
200    ) {
201        let from = assert_some!(NonEmpty::from_slice(from));
202        let expected = assert_some!(NonEmpty::from_slice(expected));
203
204        let schedule = WeeklyRecurrenceSchedule::new(Interval::one(), from);
205        let schedule = schedule.with_weekday_toggled(toggle);
206
207        let actual = schedule.days;
208
209        assert_eq!(
210            actual, expected,
211            "expecting toggle to have the expected effect"
212        );
213    }
214}