1use super::RoutineError;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub struct LocalTime {
5 pub minute: u8,
6 pub hour: u8,
7 pub day_of_month: u8,
8 pub month: u8,
9 pub day_of_week: u8,
10}
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13struct Field {
14 allowed: Vec<bool>,
15 restricted: bool,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct CronSchedule {
20 minute: Field,
21 hour: Field,
22 day_of_month: Field,
23 month: Field,
24 day_of_week: Field,
25}
26
27impl CronSchedule {
28 pub fn parse(input: &str) -> Result<Self, RoutineError> {
29 let parts: Vec<_> = input.split_whitespace().collect();
30 if parts.len() != 5 {
31 return Err(invalid("cron must contain exactly five fields"));
32 }
33 Ok(Self {
34 minute: parse_field(parts[0], 0, 59, false)?,
35 hour: parse_field(parts[1], 0, 23, false)?,
36 day_of_month: parse_field(parts[2], 1, 31, false)?,
37 month: parse_field(parts[3], 1, 12, false)?,
38 day_of_week: parse_field(parts[4], 0, 7, true)?,
39 })
40 }
41
42 pub fn matches(&self, t: LocalTime) -> bool {
43 let basic = self.minute.allowed[t.minute as usize]
44 && self.hour.allowed[t.hour as usize]
45 && self.month.allowed[t.month as usize];
46 let dom = self.day_of_month.allowed[t.day_of_month as usize];
47 let dow = self.day_of_week.allowed[t.day_of_week as usize];
48 let day = if self.day_of_month.restricted && self.day_of_week.restricted {
49 dom || dow
50 } else {
51 dom && dow
52 };
53 basic && day
54 }
55
56 pub fn next_run_after(&self, epoch: i64) -> Option<i64> {
59 self.next_run_after_counted(epoch).0
60 }
61
62 fn next_run_after_counted(&self, epoch: i64) -> (Option<i64>, usize) {
63 let mut candidate = (epoch / 60 + 1) * 60;
64 let deadline = candidate + 8 * 366 * 24 * 60 * 60;
67 let mut probes = 0;
68 while candidate < deadline {
69 let local = local_time(candidate);
70 probes += 1;
71 if self.matches(local) {
72 return (Some(candidate), probes);
73 }
74 let date_allowed = self.month.allowed[local.month as usize]
75 && self.day_matches(local.day_of_month, local.day_of_week);
76 let minutes = if !date_allowed || !self.hour.allowed[local.hour as usize] {
77 60 - i64::from(local.minute)
78 } else {
79 self.minute
80 .allowed
81 .iter()
82 .enumerate()
83 .skip(usize::from(local.minute) + 1)
84 .find_map(|(minute, allowed)| allowed.then_some(minute as i64))
85 .map(|minute| minute - i64::from(local.minute))
86 .unwrap_or_else(|| 60 - i64::from(local.minute))
87 };
88 candidate += minutes.max(1) * 60;
89 }
90 (None, probes)
91 }
92
93 fn day_matches(&self, day_of_month: u8, day_of_week: u8) -> bool {
94 let dom = self.day_of_month.allowed[day_of_month as usize];
95 let dow = self.day_of_week.allowed[day_of_week as usize];
96 if self.day_of_month.restricted && self.day_of_week.restricted {
97 dom || dow
98 } else {
99 dom && dow
100 }
101 }
102}
103
104fn local_time(epoch: i64) -> LocalTime {
105 let timestamp = epoch as libc::time_t;
106 let mut out = std::mem::MaybeUninit::<libc::tm>::uninit();
107 unsafe {
108 libc::localtime_r(×tamp, out.as_mut_ptr());
109 let out = out.assume_init();
110 LocalTime {
111 minute: out.tm_min as u8,
112 hour: out.tm_hour as u8,
113 day_of_month: out.tm_mday as u8,
114 month: (out.tm_mon + 1) as u8,
115 day_of_week: out.tm_wday as u8,
116 }
117 }
118}
119
120fn invalid(message: impl Into<String>) -> RoutineError {
121 RoutineError::Validation(message.into())
122}
123
124fn parse_field(text: &str, min: u8, max: u8, sunday: bool) -> Result<Field, RoutineError> {
125 if text.is_empty() {
126 return Err(invalid("empty cron field"));
127 }
128 let mut allowed = vec![false; max as usize + 1];
129 let mut wildcard = false;
130 for item in text.split(',') {
131 if item.is_empty() {
132 return Err(invalid(format!("empty cron list item in '{text}'")));
133 }
134 let (base, step, stepped) = match item.split_once('/') {
135 Some((base, step)) if !base.is_empty() && !step.is_empty() && !step.contains('/') => {
136 let step = step
137 .parse::<u8>()
138 .map_err(|_| invalid(format!("invalid step in '{item}'")))?;
139 if step == 0 {
140 return Err(invalid("cron step must be positive"));
141 }
142 (base, step, true)
143 }
144 Some(_) => return Err(invalid(format!("invalid stepped field '{item}'"))),
145 None => (item, 1, false),
146 };
147 let (start, end) = if base == "*" {
148 wildcard = true;
149 (min, max)
150 } else if let Some((a, b)) = base.split_once('-') {
151 if b.contains('-') {
152 return Err(invalid(format!("invalid range '{base}'")));
153 }
154 (number(a, min, max)?, number(b, min, max)?)
155 } else {
156 let value = number(base, min, max)?;
157 (value, if stepped { max } else { value })
158 };
159 if start > end {
160 return Err(invalid(format!("descending range '{base}'")));
161 }
162 for value in (start..=end).step_by(step as usize) {
163 allowed[value as usize] = true;
164 }
165 }
166 if sunday && allowed[7] {
167 allowed[0] = true;
168 allowed[7] = false;
169 }
170 Ok(Field {
171 allowed,
172 restricted: !wildcard,
173 })
174}
175
176fn number(text: &str, min: u8, max: u8) -> Result<u8, RoutineError> {
177 let value = text
178 .parse::<u8>()
179 .map_err(|_| invalid(format!("invalid cron number '{text}'")))?;
180 if !(min..=max).contains(&value) {
181 return Err(invalid(format!(
182 "cron number {value} outside {min}..={max}"
183 )));
184 }
185 Ok(value)
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 fn time(minute: u8, hour: u8, dom: u8, month: u8, dow: u8) -> LocalTime {
193 LocalTime {
194 minute,
195 hour,
196 day_of_month: dom,
197 month,
198 day_of_week: dow,
199 }
200 }
201
202 #[test]
203 fn supports_wildcard_lists_ranges_steps_and_sunday_seven() {
204 let cron = CronSchedule::parse("*/15 1,3 10-12/2 * 7").unwrap();
205 assert!(cron.matches(time(30, 3, 10, 6, 0)));
206 assert!(!cron.matches(time(31, 3, 10, 6, 0)));
207 let numeric_step = CronSchedule::parse("5/20 * * * *").unwrap();
208 assert!(numeric_step.matches(time(25, 0, 1, 1, 1)));
209 }
210
211 #[test]
212 fn restricted_dom_and_dow_use_vixie_or() {
213 let cron = CronSchedule::parse("0 0 1 * 2").unwrap();
214 assert!(cron.matches(time(0, 0, 9, 1, 2)));
215 assert!(cron.matches(time(0, 0, 1, 1, 4)));
216 assert!(!cron.matches(time(0, 0, 9, 1, 4)));
217 }
218
219 #[test]
220 fn rejects_invalid_boundaries_and_steps() {
221 for value in [
222 "60 * * * *",
223 "* 24 * * *",
224 "* * 0 * *",
225 "* * * 13 *",
226 "* * * * 8",
227 "*/0 * * * *",
228 "1,,2 * * * *",
229 "4-2 * * * *",
230 ] {
231 assert!(CronSchedule::parse(value).is_err(), "{value}");
232 }
233 }
234
235 #[test]
236 fn sparse_annual_schedule_projects_once_into_the_view_model() {
237 let schedule = CronSchedule::parse("0 0 1 1 *").unwrap();
238 let start = 1_767_225_600; let (next, probes) = schedule.next_run_after_counted(start);
240 let next = next.unwrap();
241 assert!(next > start);
242 assert!(next <= start + 366 * 24 * 60 * 60);
243 assert!(schedule.matches(local_time(next)));
244 assert!(probes < 10_000, "sparse projection used {probes} probes");
245 }
246
247 #[test]
248 fn leap_day_schedule_beyond_one_year_still_projects() {
249 let schedule = CronSchedule::parse("0 0 29 2 *").unwrap();
250 let start = 1_767_225_600; let next = schedule.next_run_after(start).unwrap();
252 assert!(next > start + 366 * 24 * 60 * 60);
253 assert!(next <= start + 8 * 366 * 24 * 60 * 60);
254 assert!(schedule.matches(local_time(next)));
255 }
256}