Skip to main content

ravenrs_report_server/
cron.rs

1//! Cron schedules: five fields (minute, hour, day of month, month, day of week),
2//! with `*`, lists, ranges, steps and English month and day names.
3//!
4//! As in standard cron, when both day fields are restricted a day matches if
5//! either does: `0 8 13 * FRI` runs on every 13th and on every Friday.
6
7use jiff::civil::Date;
8use jiff::{ToSpan, Zoned};
9
10/// How far ahead to look for a run; a schedule with none in this time never runs.
11const SEARCH_DAYS: u32 = 366 * 8 + 2;
12
13const MONTHS: [&str; 12] =
14    ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
15const DAYS: [&str; 7] = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
16
17/// A parsed schedule.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub struct Schedule {
20    minutes: u64,
21    hours: u32,
22    days: u32,
23    months: u16,
24    weekdays: u8,
25    any_day: bool,
26    any_weekday: bool,
27}
28
29impl Schedule {
30    /// Parses five cron fields.
31    pub fn parse(text: &str) -> Result<Self, String> {
32        let fields: Vec<&str> = text.split_whitespace().collect();
33        let [minute, hour, day, month, weekday] = fields[..] else {
34            return Err(format!("'{text}' must have five fields: minute hour day month weekday"));
35        };
36        // Sunday is both 0 and 7.
37        let weekdays = field(weekday, 0, 7, &DAYS, 0)?;
38        let weekdays = (weekdays & 0x7f) | (weekdays >> 7);
39        Ok(Self {
40            minutes: field(minute, 0, 59, &[], 0)?,
41            hours: field(hour, 0, 23, &[], 0)? as u32,
42            days: field(day, 1, 31, &[], 0)? as u32,
43            months: field(month, 1, 12, &MONTHS, 1)? as u16,
44            weekdays: weekdays as u8,
45            any_day: day.starts_with('*'),
46            any_weekday: weekday.starts_with('*'),
47        })
48    }
49
50    fn matches_date(&self, date: Date) -> bool {
51        if self.months & (1 << date.month()) == 0 {
52            return false;
53        }
54        let day = self.days & (1 << date.day()) != 0;
55        let weekday = self.weekdays & (1 << date.weekday().to_sunday_zero_offset()) != 0;
56        match (self.any_day, self.any_weekday) {
57            (false, false) => day || weekday,
58            _ => day && weekday,
59        }
60    }
61
62    /// The first run after `after`, in the same time zone.
63    ///
64    /// A local time that clocks skip over runs at the same moment shifted
65    /// forward, and one that clocks repeat runs once, at its first occurrence.
66    pub fn next_after(&self, after: &Zoned) -> Option<Zoned> {
67        let zone = after.time_zone();
68        let start = after.datetime();
69        let mut date = start.date();
70        for _ in 0..SEARCH_DAYS {
71            if self.matches_date(date) {
72                for hour in 0..24_i8 {
73                    if self.hours & (1 << hour) == 0 {
74                        continue;
75                    }
76                    for minute in 0..60_i8 {
77                        if self.minutes & (1 << minute) == 0 {
78                            continue;
79                        }
80                        let candidate = date.at(hour, minute, 0, 0);
81                        if candidate < start.date().at(start.hour(), start.minute(), 0, 0) {
82                            continue;
83                        }
84                        let zoned = zone.to_ambiguous_zoned(candidate).compatible().ok()?;
85                        if zoned.timestamp() > after.timestamp() {
86                            return Some(zoned);
87                        }
88                    }
89                }
90            }
91            date = date.checked_add(1.day()).ok()?;
92        }
93        None
94    }
95}
96
97/// One field as a bit set. `offset` is what the first name stands for.
98fn field(text: &str, min: u8, max: u8, names: &[&str], offset: u8) -> Result<u64, String> {
99    let mut bits = 0_u64;
100    for part in text.split(',') {
101        let (range, step) = match part.split_once('/') {
102            Some((range, step)) => {
103                let step: u8 = step.parse().map_err(|_| format!("bad step in '{part}'"))?;
104                if step == 0 {
105                    return Err(format!("the step in '{part}' cannot be zero"));
106                }
107                (range, step)
108            }
109            None => (part, 1),
110        };
111        let (low, high) = if range == "*" {
112            (min, max)
113        } else if let Some((low, high)) = range.split_once('-') {
114            (value(low, names, offset)?, value(high, names, offset)?)
115        } else {
116            let low = value(range, names, offset)?;
117            // `5/15` means from 5 to the end, every 15.
118            (low, if part.contains('/') { max } else { low })
119        };
120        if low < min || high > max || low > high {
121            return Err(format!("'{part}' is outside {min}-{max}"));
122        }
123        let mut current = low;
124        while current <= high {
125            bits |= 1 << current;
126            match current.checked_add(step) {
127                Some(next) => current = next,
128                None => break,
129            }
130        }
131    }
132    Ok(bits)
133}
134
135fn value(text: &str, names: &[&str], offset: u8) -> Result<u8, String> {
136    if let Ok(number) = text.parse() {
137        return Ok(number);
138    }
139    let upper = text.to_ascii_uppercase();
140    names
141        .iter()
142        .position(|name| *name == upper)
143        .map(|i| i as u8 + offset)
144        .ok_or_else(|| format!("'{text}' is not a value of this field"))
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    fn at(text: &str) -> Zoned {
152        text.parse().unwrap_or_else(|e| panic!("{text}: {e}"))
153    }
154
155    fn next(schedule: &str, after: &str) -> String {
156        let next = Schedule::parse(schedule).unwrap_or_else(|e| panic!("{e}")).next_after(&at(after));
157        next.map_or("never".to_owned(), |z| z.strftime("%Y-%m-%d %H:%M %:z").to_string())
158    }
159
160    const RECIFE: &str = "[America/Recife]";
161
162    #[test]
163    fn simple_schedules() {
164        let now = format!("2026-11-01T10:15:30-03:00{RECIFE}");
165        assert_eq!(next("* * * * *", &now), "2026-11-01 10:16 -03:00");
166        assert_eq!(next("0 8 * * *", &now), "2026-11-02 08:00 -03:00");
167        assert_eq!(next("30 10 * * *", &now), "2026-11-01 10:30 -03:00");
168        assert_eq!(next("*/20 * * * *", &now), "2026-11-01 10:20 -03:00");
169        assert_eq!(next("0 8 1 * *", &now), "2026-12-01 08:00 -03:00");
170    }
171
172    #[test]
173    fn a_run_at_this_exact_minute_is_not_repeated() {
174        assert_eq!(
175            next("15 10 * * *", &format!("2026-11-01T10:15:00-03:00{RECIFE}")),
176            "2026-11-02 10:15 -03:00"
177        );
178        // But it still runs when the clock has not reached it.
179        assert_eq!(
180            next("15 10 * * *", &format!("2026-11-01T10:14:59-03:00{RECIFE}")),
181            "2026-11-01 10:15 -03:00"
182        );
183    }
184
185    #[test]
186    fn names_ranges_lists_and_sunday_as_seven() {
187        // 2026-11-01 is a Sunday.
188        let now = format!("2026-11-01T10:00:00-03:00{RECIFE}");
189        assert_eq!(next("0 8 * * MON", &now), "2026-11-02 08:00 -03:00");
190        assert_eq!(next("0 8 * * mon-fri", &now), "2026-11-02 08:00 -03:00");
191        assert_eq!(next("0 12 * * 7", &now), "2026-11-01 12:00 -03:00");
192        assert_eq!(next("0 12 * * 0", &now), "2026-11-01 12:00 -03:00");
193        assert_eq!(next("0 8 1 JAN,JUL *", &now), "2027-01-01 08:00 -03:00");
194        assert_eq!(next("5/15 10 * * *", &now), "2026-11-01 10:05 -03:00");
195    }
196
197    #[test]
198    fn restricted_day_fields_match_either() {
199        // The 13th is a Friday in Nov 2026; the next Monday comes first.
200        let now = format!("2026-11-03T10:00:00-03:00{RECIFE}");
201        assert_eq!(next("0 8 13 * MON", &now), "2026-11-09 08:00 -03:00");
202    }
203
204    #[test]
205    fn leap_days_and_impossible_dates() {
206        let now = format!("2026-11-01T10:00:00-03:00{RECIFE}");
207        assert_eq!(next("0 0 29 2 *", &now), "2028-02-29 00:00 -03:00");
208        assert_eq!(next("0 0 31 2 *", &now), "never");
209    }
210
211    #[test]
212    fn a_local_time_clocks_skip_runs_shifted_forward() {
213        // On 2026-03-08 clocks in New York go from 02:00 to 03:00.
214        let now = "2026-03-08T01:00:00-05:00[America/New_York]";
215        assert_eq!(next("30 2 * * *", now), "2026-03-08 03:30 -04:00");
216    }
217
218    #[test]
219    fn a_local_time_clocks_repeat_runs_once() {
220        // On 2026-11-01 clocks in New York go from 02:00 back to 01:00.
221        let first = "2026-11-01T00:00:00-04:00[America/New_York]";
222        assert_eq!(next("30 1 * * *", first), "2026-11-01 01:30 -04:00");
223        let after_first = "2026-11-01T01:30:00-04:00[America/New_York]";
224        assert_eq!(next("30 1 * * *", after_first), "2026-11-02 01:30 -05:00");
225    }
226
227    #[test]
228    fn bad_schedules_are_refused() {
229        for bad in [
230            "",
231            "* * * *",
232            "* * * * * *",
233            "60 * * * *",
234            "* 24 * * *",
235            "* * 0 * *",
236            "* * * 13 *",
237            "*/0 * * * *",
238            "5-1 * * * *",
239            "x * * * *",
240            "* * * * FUNDAY",
241        ] {
242            assert!(Schedule::parse(bad).is_err(), "{bad}");
243        }
244    }
245}