Skip to main content

rapport_temporal/recurrence/
mod.rs

1//! Manage an event that repeats without losing your mind.
2mod daily;
3
4mod monthly;
5mod parser;
6mod weekly;
7mod yearly;
8
9pub use daily::DailyRecurrenceSchedule;
10pub use monthly::{MonthlyRecurrenceSchedule, MonthlySpecification, OrdinalMonthlyRecurrence};
11pub use weekly::WeeklyRecurrenceSchedule;
12pub use yearly::YearlyRecurrenceSchedule;
13
14use std::{
15    fmt::Display,
16    num::{NonZeroU16, ParseIntError, TryFromIntError},
17    str::FromStr,
18};
19
20use nonempty::NonEmpty;
21use serde::{Deserialize, Serialize};
22use strum::VariantArray;
23
24use crate::{
25    Error,
26    date::{Date, Weekday},
27};
28use parser::{FrequencyToken, GrammarToken};
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct RecurrenceRule {
32    starting: Date,
33    schedule: RecurrenceSchedule,
34}
35
36impl RecurrenceRule {
37    #[must_use]
38    pub fn new(starting: Date, schedule: RecurrenceSchedule) -> Self {
39        Self { starting, schedule }
40    }
41
42    #[must_use]
43    pub fn take(self) -> (Date, RecurrenceSchedule) {
44        (self.starting, self.schedule)
45    }
46
47    #[must_use]
48    pub fn next_occurrence_after(&self, value: Date) -> Date {
49        let mut next_value = self.schedule.next_occurrence_after(self.starting);
50        while next_value <= value {
51            next_value = self.schedule.next_occurrence_after(next_value);
52        }
53        next_value
54    }
55
56    /// Returns the next occurrence from this point forward.
57    #[must_use]
58    pub fn next_occurrence_from(&self, from: Date) -> Date {
59        if self.starting >= from {
60            self.starting
61        } else {
62            self.next_occurrence_after(from.sub_days(1))
63        }
64    }
65
66    #[must_use]
67    pub fn starting(&self) -> Date {
68        self.starting
69    }
70
71    #[must_use]
72    pub fn schedule(&self) -> &RecurrenceSchedule {
73        &self.schedule
74    }
75
76    #[must_use]
77    pub fn into_string(self) -> String {
78        let Self { starting, schedule } = self;
79        schedule.into_string(starting)
80    }
81
82    #[must_use]
83    pub fn weekly(starting: Date) -> Self {
84        Self {
85            starting,
86            schedule: RecurrenceSchedule::weekly(starting),
87        }
88    }
89
90    #[must_use]
91    pub fn daily(starting: Date) -> Self {
92        Self {
93            starting,
94            schedule: RecurrenceSchedule::daily(),
95        }
96    }
97}
98
99/// A schedule for calculating a regular recurrence of dates.
100pub trait Schedule {
101    /// The interval for this schedule
102    fn interval(&self) -> Interval;
103
104    /// Provides the next scheduled date on this schedule after the given date.
105    fn next_occurrence_after(&self, from: Date) -> Date;
106
107    /// Converts this schedule into a string that can be parsed, given the same starting date, into
108    /// the same schedule.
109    fn into_string(self, starting: Date) -> String;
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub enum RecurrenceSchedule {
114    Daily(DailyRecurrenceSchedule),
115    Weekly(WeeklyRecurrenceSchedule),
116    Monthly(MonthlyRecurrenceSchedule),
117    Yearly(YearlyRecurrenceSchedule),
118}
119
120impl RecurrenceSchedule {
121    #[must_use]
122    pub fn inner(&self) -> &dyn Schedule {
123        match self {
124            Self::Daily(schedule) => schedule,
125            Self::Weekly(schedule) => schedule,
126            Self::Monthly(schedule) => schedule,
127            Self::Yearly(schedule) => schedule,
128        }
129    }
130
131    #[must_use]
132    pub fn inner_mut(&mut self) -> &mut dyn Schedule {
133        match self {
134            Self::Daily(schedule) => schedule,
135            Self::Weekly(schedule) => schedule,
136            Self::Monthly(schedule) => schedule,
137            Self::Yearly(schedule) => schedule,
138        }
139    }
140
141    /// Parses the given string in relation to the given starting date. The starting date is
142    /// important in situations where the weekday or day is implied, like `every 2 weeks` will
143    /// check the `starting` for the day of the week on which the schedule fallse.
144    ///
145    /// # Errors
146    ///
147    /// When given a string not in the expected format.
148    pub fn parse(value: &str, starting: Date) -> Result<Self, Error> {
149        parser::parse(value, starting)
150    }
151
152    /// Validates whether the given string is a valid recurrence schedule.
153    #[must_use]
154    pub fn validate(value: &str) -> bool {
155        // the date does not matter because we are validating the text and the
156        // parsing only takes the day of week or month.
157        let validation_date = Date::MIN;
158        Self::parse(value, validation_date).is_ok()
159    }
160
161    /// Creates a schedule that is every two weeks from the starting date.
162    ///
163    /// # Panics
164    ///
165    /// Should not panic; panic is used when the internal hard coded value does not fit the invariants.
166    #[allow(clippy::expect_used)]
167    #[must_use]
168    pub fn every_two_weeks(starting: Date) -> Self {
169        Self::Weekly(WeeklyRecurrenceSchedule::every_two_weeks(starting))
170    }
171
172    #[must_use]
173    pub fn daily() -> Self {
174        Self::Daily(DailyRecurrenceSchedule::daily())
175    }
176
177    #[must_use]
178    pub fn weekly(starting: Date) -> Self {
179        let day_of_week = starting.weekday();
180        Self::weekly_on(day_of_week)
181    }
182
183    #[must_use]
184    pub fn weekly_on(day: Weekday) -> Self {
185        Self::Weekly(WeeklyRecurrenceSchedule::weekly_on(day))
186    }
187
188    #[must_use]
189    pub fn weekly_on_same_day_as(date: Date) -> Self {
190        Self::Weekly(WeeklyRecurrenceSchedule::weekly_on_same_day_as(date))
191    }
192
193    #[must_use]
194    pub fn monthly(starting: Date) -> Self {
195        Self::Monthly(MonthlyRecurrenceSchedule::monthly(starting))
196    }
197
198    /// Creates a recurrence schedule that is quarterly after each starting date.
199    ///
200    /// # Panics
201    ///
202    /// When the literal values used inside this function aren't allowed as invariants.
203    #[allow(clippy::expect_used)]
204    #[must_use]
205    pub fn quarterly(starting: Date) -> Self {
206        Self::Monthly(MonthlyRecurrenceSchedule::quarterly(starting))
207    }
208
209    #[must_use]
210    pub fn yearly(starting: Date) -> Self {
211        Self::Yearly(YearlyRecurrenceSchedule::yearly(starting))
212    }
213
214    /// The common set of recurrence schedules based on the given starting date.
215    #[must_use]
216    pub fn common(starting: Date) -> Vec<RecurrenceSchedule> {
217        vec![
218            Self::daily(),
219            Self::weekly(starting),
220            Self::every_two_weeks(starting),
221            Self::monthly(starting),
222            Self::quarterly(starting),
223            Self::yearly(starting),
224        ]
225    }
226
227    #[must_use]
228    pub fn default_for_unit(value: TemporalUnit, starting: Date) -> Self {
229        match value {
230            TemporalUnit::Day => Self::daily(),
231            TemporalUnit::Week => Self::weekly(starting),
232            TemporalUnit::Month => Self::monthly(starting),
233            TemporalUnit::Year => Self::yearly(starting),
234        }
235    }
236
237    #[must_use]
238    pub fn into_weekly(self) -> Option<WeeklyRecurrenceSchedule> {
239        match self {
240            Self::Weekly(weekly) => Some(weekly),
241            _ => None,
242        }
243    }
244    #[must_use]
245    pub fn into_monthly(self) -> Option<MonthlyRecurrenceSchedule> {
246        match self {
247            Self::Monthly(monthly) => Some(monthly),
248            _ => None,
249        }
250    }
251}
252
253impl Schedule for RecurrenceSchedule {
254    fn interval(&self) -> Interval {
255        self.inner().interval()
256    }
257
258    fn next_occurrence_after(&self, from: Date) -> Date {
259        self.inner().next_occurrence_after(from)
260    }
261
262    fn into_string(self, starting: Date) -> String {
263        match self {
264            RecurrenceSchedule::Daily(schedule) => schedule.into_string(starting),
265            RecurrenceSchedule::Weekly(schedule) => schedule.into_string(starting),
266            RecurrenceSchedule::Monthly(schedule) => schedule.into_string(starting),
267            RecurrenceSchedule::Yearly(schedule) => schedule.into_string(starting),
268        }
269    }
270}
271
272impl From<WeeklyRecurrenceSchedule> for RecurrenceSchedule {
273    fn from(value: WeeklyRecurrenceSchedule) -> Self {
274        RecurrenceSchedule::Weekly(value)
275    }
276}
277
278pub fn toggle_value<T>(from: NonEmpty<T>, value: T) -> NonEmpty<T>
279where
280    T: PartialEq,
281{
282    let NonEmpty { mut head, mut tail } = from;
283
284    if head == value {
285        // asking for head to be toggled
286        if let Some(first) = take_first(&mut tail) {
287            // since there is a first element in tail, set it to the new head which replaces
288            // the head
289            head = first;
290        }
291        // else do nothing because the head is all there is
292    } else if let Some(index) = tail.iter().position(|v| v == &value) {
293        // remove the weekday in the tail
294        tail.remove(index);
295    } else {
296        // weekday not found anywhere, add it to tail
297        tail.push(value);
298    }
299
300    NonEmpty { head, tail }
301}
302
303fn take_first<T>(vec: &mut Vec<T>) -> Option<T> {
304    if vec.is_empty() {
305        None
306    } else {
307        Some(vec.remove(0))
308    }
309}
310
311#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)]
312#[display("{_0}")]
313pub struct Interval(NonZeroU16);
314
315impl Interval {
316    #[must_use]
317    pub fn one() -> Self {
318        Self(NonZeroU16::MIN)
319    }
320
321    /// An interval of `2`
322    ///
323    /// # Panics
324    ///
325    /// This should not panic, because 2 is a hard coded `NonZeroU16`
326    #[must_use]
327    #[allow(clippy::expect_used)]
328    pub fn two() -> Self {
329        Self(NonZeroU16::new(2).expect("expecting literal 2 to be a valid non zero u16"))
330    }
331
332    /// An interval of `3`
333    ///
334    /// # Panics
335    ///
336    /// This should not panic, because 3 is a hard coded `NonZeroU16`
337    #[must_use]
338    #[allow(clippy::expect_used)]
339    pub fn three() -> Self {
340        Self(NonZeroU16::new(3).expect("expecting literal 3 to be a valid non zero u16"))
341    }
342
343    /// An interval of `4`
344    ///
345    /// # Panics
346    ///
347    /// This should not panic, because 4 is a hard coded `NonZeroU16`
348    #[must_use]
349    #[allow(clippy::expect_used)]
350    pub fn four() -> Self {
351        Self(NonZeroU16::new(4).expect("expecting literal 4 to be a valid non zero u16"))
352    }
353
354    #[must_use]
355    pub fn minus_one(self) -> u16 {
356        self.0.get() - 1
357    }
358
359    #[must_use]
360    pub fn get(self) -> u16 {
361        self.0.get()
362    }
363
364    #[must_use]
365    pub fn is_many(self) -> bool {
366        self.0 > NonZeroU16::MIN
367    }
368}
369
370impl From<NonZeroU16> for Interval {
371    fn from(value: NonZeroU16) -> Self {
372        Self(value)
373    }
374}
375
376impl From<Interval> for u32 {
377    fn from(value: Interval) -> Self {
378        value.0.get().into()
379    }
380}
381
382impl From<Interval> for usize {
383    fn from(value: Interval) -> Self {
384        value.0.get().into()
385    }
386}
387
388impl TryFrom<u16> for Interval {
389    type Error = TryFromIntError;
390
391    fn try_from(value: u16) -> Result<Self, Self::Error> {
392        let value = NonZeroU16::try_from(value)?;
393        Ok(Interval::from(value))
394    }
395}
396
397impl Default for Interval {
398    fn default() -> Self {
399        Self::one()
400    }
401}
402
403impl FromStr for Interval {
404    type Err = ParseIntError;
405
406    fn from_str(s: &str) -> Result<Self, ParseIntError> {
407        let value = NonZeroU16::from_str(s)?;
408        Ok(Self(value))
409    }
410}
411
412#[derive(
413    Debug,
414    Clone,
415    Copy,
416    Default,
417    Serialize,
418    Deserialize,
419    PartialEq,
420    Eq,
421    derive_more::Display,
422    derive_more::FromStr,
423    strum::VariantArray,
424)]
425pub enum Ordinal {
426    #[display("first")]
427    #[default]
428    First,
429    #[display("second")]
430    Second,
431    #[display("third")]
432    Third,
433    #[display("fourth")]
434    Fourth,
435    #[display("fifth")]
436    Fifth,
437    /// Second to last occurrence
438    #[display("next to last")]
439    NextToLast,
440    /// Last occurrence
441    #[display("last")]
442    Last,
443}
444
445impl Ordinal {
446    fn try_parse(input: &str) -> Option<(Ordinal, &str)> {
447        tracing::debug!("parsing `{input}` into ordinal and remaining string");
448        Self::VARIANTS.iter().find_map(|&ordinal| {
449            let ordinal_str = ordinal.to_string();
450            input
451                .strip_prefix(&ordinal_str)
452                .filter(|remaining| match remaining.chars().next() {
453                    None => true,                  // accept the end of string
454                    Some(c) => !c.is_alphabetic(), // only accept if at a word boundary
455                })
456                .inspect(|remaining| {
457                    tracing::debug!("parsed ordinal {ordinal} with remaining: {remaining}");
458                })
459                .map(|remaining| (ordinal, remaining))
460        })
461    }
462}
463
464#[derive(
465    Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, derive_more::Display,
466)]
467pub enum DaySpecification {
468    #[display("{_0}")]
469    Specific(Weekday),
470    #[display("day")]
471    #[default]
472    Any,
473    #[display("weekday")]
474    Weekday,
475    #[display("weekend day")]
476    Weekend,
477}
478
479impl DaySpecification {
480    pub const VARIANTS: [DaySpecification; 10] = [
481        DaySpecification::Specific(Weekday::Monday),
482        DaySpecification::Specific(Weekday::Tuesday),
483        DaySpecification::Specific(Weekday::Wednesday),
484        DaySpecification::Specific(Weekday::Thursday),
485        DaySpecification::Specific(Weekday::Friday),
486        DaySpecification::Specific(Weekday::Saturday),
487        DaySpecification::Specific(Weekday::Sunday),
488        DaySpecification::Any,
489        DaySpecification::Weekday,
490        DaySpecification::Weekend,
491    ];
492
493    fn is_satisfied_by(self, value: Date) -> bool {
494        match self {
495            DaySpecification::Specific(weekday) => weekday == value.weekday(),
496            DaySpecification::Any => true,
497            DaySpecification::Weekday => value.weekday().is_weekday(),
498            DaySpecification::Weekend => value.weekday().is_weekend(),
499        }
500    }
501}
502
503impl FromStr for DaySpecification {
504    type Err = Error;
505
506    fn from_str(s: &str) -> Result<Self, Error> {
507        Self::VARIANTS
508            .iter()
509            .find(|v| v.to_string() == s)
510            .copied()
511            .ok_or_else(|| {
512                Error::InvalidRecurrence(format!("{s} is not a valid day specification"))
513            })
514    }
515}
516
517fn print_elements<T: Display>(non_empty: &NonEmpty<T>) -> String {
518    let mut iter = non_empty.iter().peekable();
519    let mut result = String::new();
520    let mut is_first = true;
521
522    while let Some(element) = iter.next() {
523        if !is_first {
524            match iter.peek() {
525                Some(_) => result.push_str(", "),
526                None => result.push_str(" and "),
527            }
528        }
529
530        result.push_str(&element.to_string());
531        is_first = false;
532    }
533
534    result
535}
536
537#[derive(
538    Debug,
539    Clone,
540    Copy,
541    Default,
542    Serialize,
543    Deserialize,
544    PartialEq,
545    Eq,
546    derive_more::Display,
547    strum::VariantArray,
548)]
549pub enum TemporalUnit {
550    #[display("day")]
551    Day,
552    #[display("week")]
553    #[default]
554    Week,
555    #[display("month")]
556    Month,
557    #[display("year")]
558    Year,
559}
560
561impl TemporalUnit {
562    #[must_use]
563    pub fn print(self, interval: Interval) -> String {
564        let mut value = self.to_string();
565        if interval.is_many() {
566            value.push('s');
567        }
568        value
569    }
570
571    #[must_use]
572    pub fn print_interval(self, interval: Interval) -> String {
573        if interval == Interval::one() {
574            let value = match self {
575                Self::Day => FrequencyToken::Daily,
576                Self::Week => FrequencyToken::Weekly,
577                Self::Month => FrequencyToken::Monthly,
578                Self::Year => FrequencyToken::Yearly,
579            };
580            value.to_string()
581        } else {
582            format!(
583                "{} {interval} {}",
584                FrequencyToken::Every,
585                self.print(interval)
586            )
587        }
588    }
589}
590
591impl FromStr for TemporalUnit {
592    type Err = Error;
593
594    fn from_str(s: &str) -> Result<Self, Self::Err> {
595        match s {
596            "day" | "days" => Ok(TemporalUnit::Day),
597            "week" | "weeks" => Ok(TemporalUnit::Week),
598            "month" | "months" => Ok(TemporalUnit::Month),
599            "year" | "years" => Ok(TemporalUnit::Year),
600            other => Err(Error::InvalidRecurrence(format!(
601                "expecting a unit token (day(s), week(s), month(s), year(s), got: {other}"
602            ))),
603        }
604    }
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610
611    use crate::date::{DayOfMonth, Month};
612    use claims::{assert_ok, assert_some};
613    use nonempty::NonEmpty;
614    use pretty_assertions::assert_eq;
615    use rstest::rstest;
616    use tracing_test::traced_test;
617
618    #[rstest]
619    #[case::from_last_month("2025-06-01", "2025-07-01", RecurrenceSchedule::daily(), "2025-07-02")]
620    #[case::years_ago("2020-06-01", "2025-07-01", RecurrenceSchedule::daily(), "2025-07-02")]
621    fn recurrence_rule_next_occurrence_after(
622        #[case] starting: &str,
623        #[case] from: &str,
624        #[case] schedule: RecurrenceSchedule,
625        #[case] expected: &str,
626    ) {
627        let starting = Date::from_str_unchecked(starting);
628        let from = Date::from_str_unchecked(from);
629        let expected = Date::from_str_unchecked(expected);
630
631        let rule = RecurrenceRule { starting, schedule };
632        let actual = rule.next_occurrence_after(from);
633
634        assert_eq!(actual, expected);
635    }
636
637    #[rstest]
638    #[case("2025-06-02", DaySpecification::Any, true)]
639    #[case("2025-06-02", DaySpecification::Specific(Weekday::Monday), true)]
640    #[case("2025-06-02", DaySpecification::Specific(Weekday::Tuesday), false)]
641    #[case("2025-06-02", DaySpecification::Weekday, true)]
642    #[case("2025-06-02", DaySpecification::Weekend, false)]
643    #[case("2025-06-01", DaySpecification::Any, true)]
644    #[case("2025-06-01", DaySpecification::Specific(Weekday::Sunday), true)]
645    #[case("2025-06-01", DaySpecification::Specific(Weekday::Wednesday), false)]
646    #[case("2025-06-01", DaySpecification::Weekday, false)]
647    #[case("2025-06-01", DaySpecification::Weekend, true)]
648    fn day_specification_is_satisfied_by(
649        #[case] from: &str,
650        #[case] day: DaySpecification,
651        #[case] expected: bool,
652    ) {
653        let from = Date::from_str_unchecked(from);
654
655        let actual = day.is_satisfied_by(from);
656
657        assert_eq!(actual, expected);
658    }
659
660    #[traced_test]
661    #[rstest]
662    #[case::monthly(
663        RecurrenceSchedule::Monthly(MonthlyRecurrenceSchedule::monthly_each(DayOfMonth::Ninth)),
664        "2025-06-09",
665        "monthly"
666    )]
667    #[case::every_two_months(
668        RecurrenceSchedule::Monthly(MonthlyRecurrenceSchedule::each(
669            Interval::two(),
670            DayOfMonth::Ninth
671        )),
672        "2025-08-09",
673        "every 2 months"
674    )]
675    #[case::monthly_on_alternate_day(
676        RecurrenceSchedule::Monthly(MonthlyRecurrenceSchedule::monthly_each(
677            DayOfMonth::Fifteenth
678        )),
679        "2025-06-09",
680        "monthly on the 15th"
681    )]
682    #[case::yearly(
683        RecurrenceSchedule::Yearly(YearlyRecurrenceSchedule::yearly_in_month(Month::August)),
684        "2025-08-09",
685        "yearly"
686    )]
687    #[case::yearly_with_month_and_ordinal(
688        RecurrenceSchedule::Yearly(YearlyRecurrenceSchedule::new(
689            Interval::one(),
690            Month::January,
691            Some(OrdinalMonthlyRecurrence::new(Ordinal::First, DaySpecification::Weekday))
692        )),
693        "2025-08-09",
694        "yearly in January on the first weekday"
695    )]
696    #[case::every_two_years(
697        RecurrenceSchedule::Yearly(YearlyRecurrenceSchedule::new(
698            Interval::two(),
699            Month::August,
700            None
701        )),
702        "2025-08-09",
703        "every 2 years"
704    )]
705    #[case::every_two_years_alternative_month(
706        RecurrenceSchedule::Yearly(YearlyRecurrenceSchedule::new(
707            Interval::two(),
708            Month::September,
709            None
710        )),
711        "2025-08-09",
712        "every 2 years in September"
713    )]
714    #[case::every_two_years_alternative_multiple_months(
715        RecurrenceSchedule::Yearly(YearlyRecurrenceSchedule::new(
716            Interval::two(),
717            assert_some!(NonEmpty::from_slice(&[Month::September, Month::October])),
718            None)),
719        "2025-08-09",
720        "every 2 years in September and October"
721    )]
722    fn test_into_string(
723        #[case] input: RecurrenceSchedule,
724        #[case] starting: &str,
725        #[case] expected: &str,
726    ) {
727        let starting = Date::from_str_unchecked(starting);
728        let actual = input.into_string(starting);
729        assert_eq!(actual, expected);
730    }
731
732    #[rstest]
733    #[case::starting_is_from("2025-07-01", "2025-07-01", RecurrenceSchedule::daily(), "2025-07-01")]
734    #[case::starting_is_after_from(
735        "2025-07-10",
736        "2025-07-01",
737        RecurrenceSchedule::daily(),
738        "2025-07-10"
739    )]
740    #[case::from_is_after_starting(
741        "2025-06-01",
742        "2025-07-01",
743        RecurrenceSchedule::daily(),
744        "2025-07-01"
745    )]
746    #[case::weekly_on_starting_day(
747        "2025-07-07",
748        "2025-07-07",
749        RecurrenceSchedule::weekly(Date::from_str_unchecked("2025-07-07")),
750        "2025-07-07"
751    )]
752    #[case::weekly_after_starting(
753        "2025-07-07",
754        "2025-07-09",
755        RecurrenceSchedule::weekly(Date::from_str_unchecked("2025-07-07")),
756        "2025-07-14"
757    )]
758    fn recurrence_rule_next_occurrence_from(
759        #[case] starting: &str,
760        #[case] from: &str,
761        #[case] schedule: RecurrenceSchedule,
762        #[case] expected: &str,
763    ) {
764        let starting = Date::from_str_unchecked(starting);
765        let from = Date::from_str_unchecked(from);
766        let expected = Date::from_str_unchecked(expected);
767
768        let rule = RecurrenceRule { starting, schedule };
769        let actual = rule.next_occurrence_from(from);
770
771        assert_eq!(actual, expected);
772    }
773
774    #[rstest]
775    #[case(&["apple"], "apple")]
776    #[case(&["apple", "banana"], "apple and banana")]
777    #[case(&["apple", "banana", "cherry"], "apple, banana and cherry")]
778    #[case(&["apple", "banana", "cherry", "date"], "apple, banana, cherry and date")]
779    #[case(&["apple", "banana", "cherry", "date", "elderberry"], "apple, banana, cherry, date and elderberry")]
780    #[case(&["first", "second", "third", "fourth", "fifth", "sixth"], "first, second, third, fourth, fifth and sixth")]
781    fn test_print_elements(#[case] elements: &[&str], #[case] expected: &str) {
782        let elements = assert_some!(
783            NonEmpty::from_slice(elements),
784            "precondition: non empty list of elements to print"
785        );
786        assert_eq!(super::print_elements(&elements), expected);
787    }
788
789    #[traced_test]
790    #[rstest]
791    #[case::first_monday("first Monday", Some((Ordinal::First, " Monday")))]
792    #[case::second_tuesday("second Tuesday", Some((Ordinal::Second, " Tuesday")))]
793    #[case::third_friday("third Friday", Some((Ordinal::Third, " Friday")))]
794    #[case::fourth_thursday("fourth Thursday", Some((Ordinal::Fourth, " Thursday")))]
795    #[case::fifth_wednesday("fifth Wednesday", Some((Ordinal::Fifth, " Wednesday")))]
796    #[case::last_friday("last Friday", Some((Ordinal::Last, " Friday")))]
797    #[case::next_to_last_monday("next to last Monday", Some((Ordinal::NextToLast, " Monday")))]
798    #[case::first_weekday("first weekday", Some((Ordinal::First, " weekday")))]
799    #[case::last_weekend("last weekend", Some((Ordinal::Last, " weekend")))]
800    #[case::second_any("second any", Some((Ordinal::Second, " any")))]
801    #[case::first_only_no_day("first", Some((Ordinal::First, "")))]
802    #[case::last_only_no_day("last", Some((Ordinal::Last, "")))]
803    #[case::next_to_last_only_no_day("next to last", Some((Ordinal::NextToLast, "")))]
804    #[case::numeric_ordinal_no_match("5th Monday", None)]
805    #[case::random_word_no_match("random Tuesday", None)]
806    #[case::empty_string_no_match("", None)]
807    #[case::just_day_no_match("Monday", None)]
808    #[case::partial_match_firstly_should_fail("firstly Monday", None)]
809    #[case::partial_match_lasting_should_fail("lasting Friday", None)]
810    #[case::partial_match_secondary_should_fail("secondary Tuesday", None)]
811    #[case::first_with_multiple_spaces("first  Monday", Some((Ordinal::First, "  Monday")))]
812    #[case::next_to_last_with_multiple_spaces("next to last  Friday", Some((Ordinal::NextToLast, "  Friday")))]
813    fn test_try_parse_ordinal_prefix(
814        #[case] input: &str,
815        #[case] expected: Option<(Ordinal, &str)>,
816    ) {
817        let actual = Ordinal::try_parse(input);
818
819        assert_eq!(actual, expected);
820    }
821
822    #[rstest]
823    #[case::monday("Monday", DaySpecification::Specific(Weekday::Monday))]
824    #[case::tuesday("Tuesday", DaySpecification::Specific(Weekday::Tuesday))]
825    #[case::wednesday("Wednesday", DaySpecification::Specific(Weekday::Wednesday))]
826    #[case::thursday("Thursday", DaySpecification::Specific(Weekday::Thursday))]
827    #[case::friday("Friday", DaySpecification::Specific(Weekday::Friday))]
828    #[case::saturday("Saturday", DaySpecification::Specific(Weekday::Saturday))]
829    #[case::sunday("Sunday", DaySpecification::Specific(Weekday::Sunday))]
830    #[case::weekday("weekday", DaySpecification::Weekday)]
831    #[case::weekend("weekend day", DaySpecification::Weekend)]
832    #[case::any("day", DaySpecification::Any)]
833    fn test_day_specification_from_str(#[case] input: &str, #[case] expected: DaySpecification) {
834        let actual = assert_ok!(DaySpecification::from_str(input));
835
836        assert_eq!(actual, expected);
837    }
838}