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