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 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 #[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
100pub trait Schedule {
102 fn interval(&self) -> Interval;
104
105 fn next_occurrence_after(&self, from: Date) -> Date;
107
108 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 pub fn parse(value: &str, starting: Date) -> Result<Self, Error> {
150 parser::parse(value, starting)
151 }
152
153 #[must_use]
155 pub fn validate(value: &str) -> bool {
156 let validation_date = Date::MIN;
159 Self::parse(value, validation_date).is_ok()
160 }
161
162 #[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 #[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 #[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 if let Some(first) = take_first(&mut tail) {
288 head = first;
291 }
292 } else if let Some(index) = tail.iter().position(|v| v == &value) {
294 tail.remove(index);
296 } else {
297 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#[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 #[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 #[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 #[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 #[display("next to last")]
444 NextToLast,
445 #[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, Some(c) => !c.is_alphabetic(), })
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}