Skip to main content

cronp/modifier/
mod.rs

1//! Quartz's date predicates, which are not set members.
2//!
3//! `L`, `LW`, `L-n`, `nW` and `n#m` do not name values a field admits; they name a
4//! property of a date. "The last weekday of the month" is not day 28, 29, 30 or 31 — it
5//! is whichever of those the calendar produces, and `nW` can move the matched day into
6//! an adjacent week. None of that fits in a bitset, so it lives beside one.
7
8use crate::date::{CivilDateTime, Weekday};
9
10#[cfg(test)]
11mod tests;
12
13/// A predicate over the day of the month.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15#[non_exhaustive]
16pub enum DayOfMonthModifier {
17  /// `L` — the last day of the month.
18  Last,
19  /// `L-n` — `n` days before the last day of the month.
20  LastOffset {
21    /// How many days before the last, `1..=30`.
22    days: u8,
23  },
24  /// `LW` — the last Monday-to-Friday of the month.
25  LastWeekday,
26  /// `nW` — the weekday nearest day `n`, without leaving the month.
27  ///
28  /// If day `n` is a Saturday the match moves back a day, and if it is a Sunday it moves
29  /// forward a day — unless that would cross a month boundary, in which case it moves
30  /// the other way. `1W` in a month beginning on a Sunday matches the 2nd, which is in
31  /// the following week.
32  NearestWeekday {
33    /// The target day, `1..=31`.
34    day: u8,
35  },
36}
37
38impl DayOfMonthModifier {
39  /// Whether the date satisfies the predicate.
40  #[must_use]
41  pub fn matches(self, date: &CivilDateTime) -> bool {
42    let last = date.days_in_month();
43    match self {
44      Self::Last => date.day() == last,
45      Self::LastOffset { days } => match last.checked_sub(days) {
46        Some(target) if target >= 1 => date.day() == target,
47        // `L-31` in February names no day. Quartz treats that as never firing rather
48        // than as an error, because the same expression is satisfiable in a longer
49        // month.
50        _ => false,
51      },
52      Self::LastWeekday => match last_weekday_of_month(date) {
53        Some(target) => date.day() == target,
54        None => false,
55      },
56      Self::NearestWeekday { day } => match nearest_weekday(date, day) {
57        Some(target) => date.day() == target,
58        None => false,
59      },
60    }
61  }
62}
63
64/// A predicate over the day of the week.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
66#[non_exhaustive]
67pub enum DayOfWeekModifier {
68  /// `nL` — the last such weekday of the month.
69  Last {
70    /// The day of the week.
71    weekday: Weekday,
72  },
73  /// `n#m` — the `m`th such weekday of the month.
74  Nth {
75    /// The day of the week.
76    weekday: Weekday,
77    /// Which one, `1..=5`.
78    nth: u8,
79  },
80}
81
82impl DayOfWeekModifier {
83  /// Whether the date satisfies the predicate.
84  #[must_use]
85  #[inline(always)]
86  pub fn matches(self, date: &CivilDateTime) -> bool {
87    match self {
88      Self::Last { weekday } => {
89        date.weekday() == weekday && date.day().saturating_add(7) > date.days_in_month()
90      }
91      Self::Nth { weekday, nth } => {
92        date.weekday() == weekday && (date.day().saturating_sub(1) / 7).saturating_add(1) == nth
93      }
94    }
95  }
96}
97
98/// The last Monday-to-Friday of the date's month.
99fn last_weekday_of_month(date: &CivilDateTime) -> Option<u8> {
100  let mut day = date.days_in_month();
101  // At most two steps: the last three days of a month cannot all be weekend days.
102  while day >= 1 {
103    if date.weekday_of_day(day).is_weekday() {
104      return Some(day);
105    }
106    day = day.checked_sub(1)?;
107  }
108  None
109}
110
111/// The day Quartz's `nW` fires on, or `None` when the month is too short.
112fn nearest_weekday(date: &CivilDateTime, target: u8) -> Option<u8> {
113  let last = date.days_in_month();
114  if target < 1 || target > last {
115    return None;
116  }
117
118  match date.weekday_of_day(target) {
119    Weekday::Saturday => {
120      let back = target.checked_sub(1)?;
121      // `1W` on a Saturday cannot fire on the previous month's Friday, so it jumps
122      // forward to the Monday instead.
123      if back >= 1 {
124        Some(back)
125      } else {
126        let forward = target.checked_add(2)?;
127        (forward <= last).then_some(forward)
128      }
129    }
130    Weekday::Sunday => {
131      let forward = target.checked_add(1)?;
132      if forward <= last {
133        Some(forward)
134      } else {
135        let back = target.checked_sub(2)?;
136        (back >= 1).then_some(back)
137      }
138    }
139    _ => Some(target),
140  }
141}