Skip to main content

rustlavel_queue/
cron.rs

1//! A cron expression parser and next-run calculator, written from scratch.
2//!
3//! Five fields, in the order every crontab has used since 1975:
4//!
5//! ```text
6//! ┌───────────── minute        0-59
7//! │ ┌─────────── hour          0-23
8//! │ │ ┌───────── day of month  1-31
9//! │ │ │ ┌─────── month         1-12
10//! │ │ │ │ ┌───── day of week   0-7  (0 and 7 are both Sunday)
11//! * * * * *
12//! ```
13//!
14//! Each field accepts `*`, a number, a list (`1,15`), a range (`1-5`), a step
15//! over the whole field (`*/5`) or over a range (`0-30/10`), and Vixie cron's
16//! `5/10`, which means "from 5 to the end of the field, every 10".
17//!
18//! Anything else is an error rather than a silent default. A schedule that
19//! quietly runs at the wrong time is worse than one that refuses to start.
20
21use crate::time::{self, DateTime, SECONDS_PER_DAY};
22use rustlavel_core::{Error, Result};
23
24/// A day of the week, so `weekly_on` reads as English rather than as a number
25/// the caller has to remember the base of.
26#[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    /// Cron's numbering: 0 is Sunday.
39    pub fn number(self) -> u32 {
40        self as u32
41    }
42}
43
44/// One field of an expression, as a bitmask over its allowed values.
45///
46/// A mask rather than a list because matching is then a single shift and test,
47/// and because it collapses `1,1,1-3` to the set it actually describes.
48#[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/// A parsed cron expression.
60#[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    /// Whether the day fields were narrowed, which decides how they combine.
68    dom_restricted: bool,
69    dow_restricted: bool,
70    expression: String,
71}
72
73impl Cron {
74    /// Parse a five-field expression.
75    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    /// The normalised expression this was parsed from.
99    pub fn expression(&self) -> &str {
100        &self.expression
101    }
102
103    /// Whether a given moment is one this expression selects.
104    ///
105    /// Seconds are ignored: cron's resolution is a minute.
106    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    /// The first matching moment strictly after `after`, in epoch seconds.
111    ///
112    /// `None` when the expression describes a date that never occurs — `0 0 30
113    /// 2 *`, the thirtieth of February. Answering rather than looping forever
114    /// is the whole reason this returns an `Option`.
115    pub fn next_after(&self, after: i64) -> Option<i64> {
116        let mut candidate = time::start_of_minute(after) + 60;
117
118        // Four years covers a full leap cycle, so any date that can occur at
119        // all occurs within the window; anything that does not is impossible.
120        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                // Skip the rest of a day whose date can never match, instead of
127                // testing its other 1439 minutes one at a time.
128                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    /// Month plus the day-of-month / day-of-week rule.
145    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        // Cron's oldest quirk: when *both* day fields are narrowed they are
154        // combined with OR, not AND. `0 0 1 * 1` fires on the first of the
155        // month *and* on every Monday. Faithfully reproduced here because an
156        // expression copied from a crontab must mean what it meant there.
157        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
166/// Day of week, with 7 folded onto 0 so both spellings of Sunday work.
167fn 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
173/// Parse one comma-separated field into its bitmask.
174fn 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
188/// One element of a list: `*`, `n`, `a-b`, or any of those with a `/step`.
189fn 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    // A bare `a/n` means "from a to the end of the field", which is why the
204    // upper bound here is `max` rather than `a`.
205    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    /// The minutes an expression selects, so a mask can be asserted directly.
254    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            "* * * *",           // four fields
297            "* * * * * *",       // six fields
298            "",                  // nothing at all
299            "60 * * * *",        // minute out of range
300            "* 24 * * *",        // hour out of range
301            "* * 0 * *",         // there is no day zero
302            "* * 32 * *",        // nor a thirty-second
303            "* * * 13 *",        // nor a thirteenth month
304            "* * * * 8",         // nor an eighth weekday
305            "*/0 * * * *",       // a step of zero selects nothing
306            "5-1 * * * *",       // backwards range
307            "abc * * * *",       // not a number
308            "*/x * * * *",       // not a step
309            "1,, * * * *",       // empty list member
310        ] {
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        // Standing exactly on a matching minute must give the *following* one,
337        // or a scheduler that asks "what is next?" would run the same tick twice.
338        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        // 29 August 2026 is a Saturday, so "weekdays at 09:00" waits for Monday.
366        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        // The first of the month, or any Monday.
382        let cron = Cron::parse("0 0 1 * 1").unwrap();
383
384        // 1 September 2026 is a Tuesday: matches on day of month alone.
385        assert!(cron.matches(&time::from_unix(to_unix(2026, 9, 1, 0, 0, 0))));
386        // 7 September 2026 is a Monday: matches on weekday alone.
387        assert!(cron.matches(&time::from_unix(to_unix(2026, 9, 7, 0, 0, 0))));
388        // 2 September 2026 is neither.
389        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            // Nothing between `at` and `run` may match, or the answer was late.
425            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}