1mod 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 #[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
99pub trait Schedule {
101 fn interval(&self) -> Interval;
103
104 fn next_occurrence_after(&self, from: Date) -> Date;
106
107 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 pub fn parse(value: &str, starting: Date) -> Result<Self, Error> {
149 parser::parse(value, starting)
150 }
151
152 #[must_use]
154 pub fn validate(value: &str) -> bool {
155 let validation_date = Date::MIN;
158 Self::parse(value, validation_date).is_ok()
159 }
160
161 #[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 #[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 #[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 if let Some(first) = take_first(&mut tail) {
287 head = first;
290 }
291 } else if let Some(index) = tail.iter().position(|v| v == &value) {
293 tail.remove(index);
295 } else {
296 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 #[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 #[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 #[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 #[display("next to last")]
439 NextToLast,
440 #[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, Some(c) => !c.is_alphabetic(), })
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}