1use crate::time::{self, DateTime, SECONDS_PER_DAY};
22use rustlavel_core::{Error, Result};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Weekday {
28 Sunday,
29 Monday,
30 Tuesday,
31 Wednesday,
32 Thursday,
33 Friday,
34 Saturday,
35}
36
37impl Weekday {
38 pub fn number(self) -> u32 {
40 self as u32
41 }
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49struct Field {
50 bits: u64,
51}
52
53impl Field {
54 fn contains(self, value: u32) -> bool {
55 value < 64 && self.bits & (1 << value) != 0
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct Cron {
62 minute: Field,
63 hour: Field,
64 day_of_month: Field,
65 month: Field,
66 day_of_week: Field,
67 dom_restricted: bool,
69 dow_restricted: bool,
70 expression: String,
71}
72
73impl Cron {
74 pub fn parse(expression: &str) -> Result<Cron> {
76 let fields: Vec<&str> = expression.split_whitespace().collect();
77
78 if fields.len() != 5 {
79 return Err(Error::msg(format!(
80 "`{expression}` has {} field(s); a cron expression needs exactly 5: \
81 minute hour day-of-month month day-of-week",
82 fields.len()
83 )));
84 }
85
86 Ok(Cron {
87 minute: parse_field(fields[0], 0, 59, "minute")?,
88 hour: parse_field(fields[1], 0, 23, "hour")?,
89 day_of_month: parse_field(fields[2], 1, 31, "day of month")?,
90 month: parse_field(fields[3], 1, 12, "month")?,
91 day_of_week: parse_weekday_field(fields[4])?,
92 dom_restricted: fields[2] != "*",
93 dow_restricted: fields[4] != "*",
94 expression: fields.join(" "),
95 })
96 }
97
98 pub fn expression(&self) -> &str {
100 &self.expression
101 }
102
103 pub fn matches(&self, at: &DateTime) -> bool {
107 self.date_matches(at) && self.hour.contains(at.hour) && self.minute.contains(at.minute)
108 }
109
110 pub fn next_after(&self, after: i64) -> Option<i64> {
116 let mut candidate = time::start_of_minute(after) + 60;
117
118 let limit = candidate + 4 * 366 * SECONDS_PER_DAY;
121
122 while candidate < limit {
123 let at = time::from_unix(candidate);
124
125 if !self.date_matches(&at) {
126 candidate = time::start_of_day(candidate) + SECONDS_PER_DAY;
129 continue;
130 }
131 if !self.hour.contains(at.hour) {
132 candidate = time::start_of_day(candidate) + i64::from(at.hour + 1) * 3600;
133 continue;
134 }
135 if self.minute.contains(at.minute) {
136 return Some(candidate);
137 }
138 candidate += 60;
139 }
140
141 None
142 }
143
144 fn date_matches(&self, at: &DateTime) -> bool {
146 if !self.month.contains(at.month) {
147 return false;
148 }
149
150 let by_day = self.day_of_month.contains(at.day);
151 let by_weekday = self.day_of_week.contains(at.weekday);
152
153 match (self.dom_restricted, self.dow_restricted) {
158 (true, true) => by_day || by_weekday,
159 (true, false) => by_day,
160 (false, true) => by_weekday,
161 (false, false) => true,
162 }
163 }
164}
165
166fn parse_weekday_field(spec: &str) -> Result<Field> {
168 let field = parse_field(spec, 0, 7, "day of week")?;
169 let bits = if field.contains(7) { (field.bits & !(1 << 7)) | 1 } else { field.bits };
170 Ok(Field { bits })
171}
172
173fn parse_field(spec: &str, min: u32, max: u32, label: &str) -> Result<Field> {
175 if spec.is_empty() {
176 return Err(invalid(spec, label, min, max));
177 }
178
179 let mut bits = 0u64;
180
181 for part in spec.split(',') {
182 bits |= parse_part(part, min, max, label)?;
183 }
184
185 Ok(Field { bits })
186}
187
188fn parse_part(part: &str, min: u32, max: u32, label: &str) -> Result<u64> {
190 let (range_spec, step) = match part.split_once('/') {
191 Some((range_spec, step_spec)) => {
192 let step: u32 = step_spec.parse().map_err(|_| invalid(part, label, min, max))?;
193 if step == 0 {
194 return Err(Error::msg(format!(
195 "`{part}` is not a valid {label} field: a step of 0 would select nothing"
196 )));
197 }
198 (range_spec, step)
199 }
200 None => (part, 1),
201 };
202
203 let (low, high) = if range_spec == "*" {
206 (min, max)
207 } else if let Some((start, end)) = range_spec.split_once('-') {
208 let start: u32 = start.parse().map_err(|_| invalid(part, label, min, max))?;
209 let end: u32 = end.parse().map_err(|_| invalid(part, label, min, max))?;
210 if start > end {
211 return Err(Error::msg(format!(
212 "`{part}` is not a valid {label} field: the range starts after it ends"
213 )));
214 }
215 (start, end)
216 } else {
217 let value: u32 = range_spec.parse().map_err(|_| invalid(part, label, min, max))?;
218 if part.contains('/') { (value, max) } else { (value, value) }
219 };
220
221 if low < min || high > max {
222 return Err(invalid(part, label, min, max));
223 }
224
225 let mut bits = 0u64;
226 let mut value = low;
227 while value <= high {
228 bits |= 1 << value;
229 value += step;
230 }
231 Ok(bits)
232}
233
234fn invalid(part: &str, label: &str, min: u32, max: u32) -> Error {
235 Error::msg(format!(
236 "`{part}` is not a valid {label} field. Use `*`, a number between {min} and {max}, \
237 a list (`1,15`), a range (`1-5`), or a step (`*/5`)."
238 ))
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use crate::time::to_unix;
245
246 fn next(expression: &str, from: i64) -> i64 {
247 Cron::parse(expression)
248 .unwrap_or_else(|e| panic!("{expression} should parse: {e}"))
249 .next_after(from)
250 .unwrap_or_else(|| panic!("{expression} should have a next run"))
251 }
252
253 fn minutes_of(expression: &str) -> Vec<u32> {
255 let cron = Cron::parse(expression).unwrap();
256 (0..60).filter(|m| cron.minute.contains(*m)).collect()
257 }
258
259 #[test]
260 fn a_star_field_selects_everything() {
261 assert_eq!(minutes_of("* * * * *"), (0..60).collect::<Vec<_>>());
262 }
263
264 #[test]
265 fn a_step_selects_every_nth_value() {
266 assert_eq!(minutes_of("*/5 * * * *"), vec![0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55]);
267 assert_eq!(minutes_of("*/20 * * * *"), vec![0, 20, 40]);
268 }
269
270 #[test]
271 fn a_range_selects_its_endpoints_too() {
272 assert_eq!(minutes_of("1-5 * * * *"), vec![1, 2, 3, 4, 5]);
273 assert_eq!(minutes_of("0-0 * * * *"), vec![0]);
274 }
275
276 #[test]
277 fn a_list_selects_each_of_its_members() {
278 assert_eq!(minutes_of("1,15 * * * *"), vec![1, 15]);
279 assert_eq!(minutes_of("30,0,30 * * * *"), vec![0, 30], "duplicates collapse");
280 }
281
282 #[test]
283 fn a_step_can_be_applied_to_a_range_or_a_start() {
284 assert_eq!(minutes_of("0-30/10 * * * *"), vec![0, 10, 20, 30]);
285 assert_eq!(minutes_of("50/5 * * * *"), vec![50, 55], "`a/n` runs to the end of the field");
286 }
287
288 #[test]
289 fn lists_ranges_and_steps_combine_in_one_field() {
290 assert_eq!(minutes_of("0,10-12,*/30 * * * *"), vec![0, 10, 11, 12, 30]);
291 }
292
293 #[test]
294 fn nonsense_expressions_are_rejected_with_an_explanation() {
295 for expression in [
296 "* * * *", "* * * * * *", "", "60 * * * *", "* 24 * * *", "* * 0 * *", "* * 32 * *", "* * * 13 *", "* * * * 8", "*/0 * * * *", "5-1 * * * *", "abc * * * *", "*/x * * * *", "1,, * * * *", ] {
311 assert!(
312 Cron::parse(expression).is_err(),
313 "{expression:?} should have been rejected"
314 );
315 }
316 }
317
318 #[test]
319 fn an_error_says_what_a_field_may_contain() {
320 let message = Cron::parse("70 * * * *").unwrap_err().to_string();
321 assert!(message.contains("minute"), "{message}");
322 assert!(message.contains("between 0 and 59"), "{message}");
323
324 let count = Cron::parse("* * *").unwrap_err().to_string();
325 assert!(count.contains("needs exactly 5"), "{count}");
326 }
327
328 #[test]
329 fn every_minute_advances_by_a_minute() {
330 let start = to_unix(2026, 8, 29, 13, 0, 30);
331 assert_eq!(next("* * * * *", start), to_unix(2026, 8, 29, 13, 1, 0));
332 }
333
334 #[test]
335 fn the_next_run_is_strictly_after_the_moment_asked_about() {
336 let on_the_hour = to_unix(2026, 8, 29, 13, 0, 0);
339 assert_eq!(next("0 * * * *", on_the_hour), to_unix(2026, 8, 29, 14, 0, 0));
340 }
341
342 #[test]
343 fn a_daily_schedule_lands_on_the_configured_time() {
344 let morning = to_unix(2026, 8, 29, 9, 0, 0);
345 assert_eq!(next("0 13 * * *", morning), to_unix(2026, 8, 29, 13, 0, 0));
346
347 let evening = to_unix(2026, 8, 29, 18, 0, 0);
348 assert_eq!(next("0 13 * * *", evening), to_unix(2026, 8, 30, 13, 0, 0));
349 }
350
351 #[test]
352 fn a_five_minute_step_wraps_across_the_hour() {
353 assert_eq!(
354 next("*/5 * * * *", to_unix(2026, 8, 29, 13, 57, 10)),
355 to_unix(2026, 8, 29, 14, 0, 0)
356 );
357 assert_eq!(
358 next("*/5 * * * *", to_unix(2026, 8, 29, 13, 2, 0)),
359 to_unix(2026, 8, 29, 13, 5, 0)
360 );
361 }
362
363 #[test]
364 fn a_weekday_schedule_skips_the_weekend() {
365 let saturday = to_unix(2026, 8, 29, 10, 0, 0);
367 assert_eq!(next("0 9 * * 1-5", saturday), to_unix(2026, 8, 31, 9, 0, 0));
368 }
369
370 #[test]
371 fn sunday_can_be_spelled_zero_or_seven() {
372 let saturday = to_unix(2026, 8, 29, 10, 0, 0);
373 let sunday = to_unix(2026, 8, 30, 0, 0, 0);
374
375 assert_eq!(next("0 0 * * 0", saturday), sunday);
376 assert_eq!(next("0 0 * * 7", saturday), sunday);
377 }
378
379 #[test]
380 fn both_day_fields_together_mean_or_not_and() {
381 let cron = Cron::parse("0 0 1 * 1").unwrap();
383
384 assert!(cron.matches(&time::from_unix(to_unix(2026, 9, 1, 0, 0, 0))));
386 assert!(cron.matches(&time::from_unix(to_unix(2026, 9, 7, 0, 0, 0))));
388 assert!(!cron.matches(&time::from_unix(to_unix(2026, 9, 2, 0, 0, 0))));
390 }
391
392 #[test]
393 fn a_yearly_schedule_crosses_the_year_boundary() {
394 assert_eq!(
395 next("0 0 1 1 *", to_unix(2026, 8, 29, 0, 0, 0)),
396 to_unix(2027, 1, 1, 0, 0, 0)
397 );
398 }
399
400 #[test]
401 fn a_leap_day_schedule_waits_for_a_leap_year() {
402 assert_eq!(
403 next("0 0 29 2 *", to_unix(2026, 3, 1, 0, 0, 0)),
404 to_unix(2028, 2, 29, 0, 0, 0)
405 );
406 }
407
408 #[test]
409 fn a_date_that_never_happens_has_no_next_run() {
410 assert_eq!(Cron::parse("0 0 30 2 *").unwrap().next_after(0), None);
411 assert_eq!(Cron::parse("0 0 31 4 *").unwrap().next_after(0), None);
412 }
413
414 #[test]
415 fn matching_and_next_run_agree_with_each_other() {
416 let cron = Cron::parse("*/7 2-4 * * *").unwrap();
417 let mut at = to_unix(2026, 1, 1, 0, 0, 0);
418
419 for _ in 0..200 {
420 let run = cron.next_after(at).expect("this expression always has a next run");
421 assert!(run > at);
422 assert!(cron.matches(&time::from_unix(run)), "next_after returned a non-matching time");
423
424 let mut between = time::start_of_minute(at) + 60;
426 while between < run {
427 assert!(
428 !cron.matches(&time::from_unix(between)),
429 "skipped a matching minute at {between}"
430 );
431 between += 60;
432 }
433 at = run;
434 }
435 }
436
437 #[test]
438 fn the_expression_is_kept_in_normalised_form() {
439 assert_eq!(Cron::parse(" */5 * * * * ").unwrap().expression(), "*/5 * * * *");
440 }
441
442 #[test]
443 fn weekday_numbers_follow_cron_not_iso() {
444 assert_eq!(Weekday::Sunday.number(), 0);
445 assert_eq!(Weekday::Monday.number(), 1);
446 assert_eq!(Weekday::Saturday.number(), 6);
447 }
448}