Skip to main content

isb_core/
cron.rs

1//! Cron schedules: the five-field form (`minute hour day-of-month month
2//! day-of-week`) and the `@hourly`-style aliases, evaluated in UTC or a
3//! fixed offset from it.
4//!
5//! Fields take `*`, numbers, ranges `a-b`, steps `*/n`, `a-b/n` and `a/n`
6//! (from `a` to the field's end), and comma lists; months and weekdays take
7//! names (`jan`, `mon`; any case). Day of week is 0-7, both 0 and 7 Sunday.
8//! As in Vixie cron, when both day of month and day of week are restricted
9//! a day matching either fires.
10//!
11//! Times are whole minutes in Unix seconds. There is no DST: a schedule's
12//! offset is fixed, so every day has every minute exactly once.
13
14use crate::error::{Error, Result};
15
16/// A parsed schedule.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Schedule {
19    minutes: u64,
20    hours: u32,
21    /// Bits 1-31.
22    doms: u32,
23    /// Bits 1-12.
24    months: u16,
25    /// Bits 0-6 (Sunday 0).
26    dows: u8,
27    dom_star: bool,
28    dow_star: bool,
29    /// Seconds east of UTC the fields are read in.
30    offset: i32,
31    text: String,
32}
33
34const MONTHS: [&str; 12] = [
35    "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
36];
37const DAYS: [&str; 7] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
38
39/// Parse one field into a bitset of the values in `lo..=hi`. `star` is set
40/// when the field is `*` or `*/1` (every value).
41fn field(s: &str, lo: u32, hi: u32, names: &[&str], what: &str) -> Result<(u64, bool)> {
42    let bad = |why: &str| Error::invalid(format!("cron {what} {s:?}: {why}"));
43    let num = |t: &str| -> Result<u32> {
44        if let Ok(n) = t.parse::<u32>() {
45            return Ok(n);
46        }
47        let l = t.to_ascii_lowercase();
48        names
49            .iter()
50            .position(|n| *n == l)
51            .map(|i| i as u32 + if names.len() == 12 { 1 } else { 0 })
52            .ok_or_else(|| bad(&format!("{t:?} is not a number or a name")))
53    };
54    let mut bits = 0u64;
55    let mut star = false;
56    for part in s.split(',') {
57        if part.is_empty() {
58            return Err(bad("empty list item"));
59        }
60        let (range, step) = match part.split_once('/') {
61            Some((r, st)) => {
62                let n: u32 = st
63                    .parse()
64                    .map_err(|_| bad(&format!("step {st:?} is not a number")))?;
65                if n == 0 {
66                    return Err(bad("a step of 0"));
67                }
68                (r, Some(n))
69            }
70            None => (part, None),
71        };
72        let (a, b) = if range == "*" {
73            if step.unwrap_or(1) == 1 {
74                star = true;
75            }
76            (lo, hi)
77        } else if let Some((a, b)) = range.split_once('-') {
78            (num(a)?, num(b)?)
79        } else {
80            let a = num(range)?;
81            // `a/n` runs from a to the field's end.
82            (a, if step.is_some() { hi } else { a })
83        };
84        if a < lo || b > hi || a > b {
85            return Err(bad(&format!("{a}-{b} is outside {lo}-{hi}")));
86        }
87        let step = step.unwrap_or(1);
88        let mut v = a;
89        while v <= b {
90            bits |= 1 << v;
91            v += step;
92        }
93    }
94    Ok((bits, star))
95}
96
97/// Parse a fixed UTC offset: `UTC`, `Z`, `+05:30`, `-0800`, `+2`.
98pub fn parse_offset(s: &str) -> Result<i32> {
99    let t = s.trim();
100    if t.is_empty() || t.eq_ignore_ascii_case("utc") || t == "Z" {
101        return Ok(0);
102    }
103    let bad = || {
104        Error::invalid(format!(
105            "timezone {s:?}: UTC or a fixed offset such as +02:00 (named zones are not supported)"
106        ))
107    };
108    let t = t
109        .strip_prefix("UTC")
110        .or_else(|| t.strip_prefix("utc"))
111        .unwrap_or(t);
112    let (sign, rest) = match t.as_bytes().first() {
113        Some(b'+') => (1, &t[1..]),
114        Some(b'-') => (-1, &t[1..]),
115        _ => return Err(bad()),
116    };
117    let (h, m) = match rest.split_once(':') {
118        Some((h, m)) => (h, m),
119        None if rest.len() == 4 => rest.split_at(2),
120        None => (rest, "0"),
121    };
122    let h: i32 = h.parse().map_err(|_| bad())?;
123    let m: i32 = m.parse().map_err(|_| bad())?;
124    if h > 14 || m > 59 {
125        return Err(bad());
126    }
127    Ok(sign * (h * 3600 + m * 60))
128}
129
130impl Schedule {
131    /// Parse `expr` (five fields or an alias), read in UTC.
132    pub fn parse(expr: &str) -> Result<Schedule> {
133        Schedule::parse_in(expr, 0)
134    }
135
136    /// Parse `expr`, its fields read at `offset` seconds east of UTC.
137    pub fn parse_in(expr: &str, offset: i32) -> Result<Schedule> {
138        let text = expr.trim();
139        let expanded = match text.to_ascii_lowercase().as_str() {
140            "@yearly" | "@annually" => "0 0 1 1 *",
141            "@monthly" => "0 0 1 * *",
142            "@weekly" => "0 0 * * 0",
143            "@daily" | "@midnight" => "0 0 * * *",
144            "@hourly" => "0 * * * *",
145            a if a.starts_with('@') => {
146                return Err(Error::invalid(format!(
147                    "cron {text:?}: aliases are @yearly, @monthly, @weekly, @daily and @hourly"
148                )));
149            }
150            _ => text,
151        };
152        let f: Vec<&str> = expanded.split_whitespace().collect();
153        if f.len() != 5 {
154            return Err(Error::invalid(format!(
155                "cron {text:?}: five fields (minute hour day-of-month month day-of-week) or an alias such as @daily"
156            )));
157        }
158        let (minutes, _) = field(f[0], 0, 59, &[], "minute")?;
159        let (hours, _) = field(f[1], 0, 23, &[], "hour")?;
160        let (doms, dom_star) = field(f[2], 1, 31, &[], "day of month")?;
161        let (months, _) = field(f[3], 1, 12, &MONTHS, "month")?;
162        let (mut dows, dow_star) = field(f[4], 0, 7, &DAYS, "day of week")?;
163        if dows & (1 << 7) != 0 {
164            dows = (dows | 1) & 0x7f;
165        }
166        let s = Schedule {
167            minutes,
168            hours: hours as u32,
169            doms: doms as u32,
170            months: months as u16,
171            dows: dows as u8,
172            dom_star,
173            dow_star,
174            offset,
175            text: text.to_string(),
176        };
177        // `30 2 31 2 *` parses and never fires: say so now.
178        if s.next_after(946_684_800).is_none() {
179            return Err(Error::invalid(format!("cron {text:?}: never fires")));
180        }
181        Ok(s)
182    }
183
184    /// The expression as given.
185    pub fn as_str(&self) -> &str {
186        &self.text
187    }
188
189    fn day_matches(&self, y: i64, m: u32, d: u32) -> bool {
190        if self.months & (1 << m) == 0 {
191            return false;
192        }
193        let dom = self.doms & (1 << d) != 0;
194        let dow = self.dows & (1 << weekday(y, m, d)) != 0;
195        match (self.dom_star, self.dow_star) {
196            (true, true) => true,
197            (false, true) => dom,
198            (true, false) => dow,
199            (false, false) => dom || dow,
200        }
201    }
202
203    /// The first firing time strictly after `t` (Unix seconds), or `None`
204    /// if none comes within eight years (the schedule never fires).
205    pub fn next_after(&self, t: i64) -> Option<i64> {
206        let local = t + self.offset as i64;
207        // The next whole minute after t.
208        let start = local.div_euclid(60) * 60 + 60;
209        let first_day = start.div_euclid(86_400);
210        // Eight years covers every leap-day and weekday combination.
211        for day in first_day..first_day + 366 * 8 {
212            let (y, m, d) = civil_from_days(day);
213            if !self.day_matches(y, m, d) {
214                continue;
215            }
216            let from = if day == first_day {
217                start.rem_euclid(86_400) / 60
218            } else {
219                0
220            };
221            for mm in from..1440 {
222                let (h, mi) = (mm / 60, mm % 60);
223                if self.hours & (1 << h) != 0 && self.minutes & (1 << mi) != 0 {
224                    return Some(day * 86_400 + mm * 60 - self.offset as i64);
225                }
226            }
227        }
228        None
229    }
230}
231
232impl std::fmt::Display for Schedule {
233    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234        f.write_str(&self.text)
235    }
236}
237
238/// (year, month 1-12, day 1-31) of a day number (days since 1970-01-01).
239/// Howard Hinnant's algorithm.
240pub fn civil_from_days(z: i64) -> (i64, u32, u32) {
241    let z = z + 719_468;
242    let era = z.div_euclid(146_097);
243    let doe = z.rem_euclid(146_097);
244    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
245    let y = yoe + era * 400;
246    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
247    let mp = (5 * doy + 2) / 153;
248    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
249    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
250    (if m <= 2 { y + 1 } else { y }, m, d)
251}
252
253/// Days since 1970-01-01 of a civil date.
254pub fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
255    let y = if m <= 2 { y - 1 } else { y };
256    let era = y.div_euclid(400);
257    let yoe = y.rem_euclid(400);
258    let m = m as i64;
259    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d as i64 - 1;
260    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
261    era * 146_097 + doe - 719_468
262}
263
264/// 0 = Sunday.
265fn weekday(y: i64, m: u32, d: u32) -> u32 {
266    // 1970-01-01 was a Thursday.
267    (days_from_civil(y, m, d) + 4).rem_euclid(7) as u32
268}
269
270/// `YYYYMMDDTHHMMSSZ` for Unix seconds `t` (UTC).
271pub fn compact_utc(t: i64) -> String {
272    let (y, m, d) = civil_from_days(t.div_euclid(86_400));
273    let s = t.rem_euclid(86_400);
274    format!(
275        "{y:04}{m:02}{d:02}T{:02}{:02}{:02}Z",
276        s / 3600,
277        s % 3600 / 60,
278        s % 60
279    )
280}
281
282/// `YYYY-MM-DDTHH:MM:SSZ` for Unix seconds `t`.
283pub fn rfc3339(t: i64) -> String {
284    let (y, m, d) = civil_from_days(t.div_euclid(86_400));
285    let s = t.rem_euclid(86_400);
286    format!(
287        "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
288        s / 3600,
289        s % 3600 / 60,
290        s % 60
291    )
292}
293
294/// Parse [`compact_utc`] back to Unix seconds.
295pub fn parse_compact_utc(s: &str) -> Option<i64> {
296    let b = s.as_bytes();
297    if b.len() != 16 || b[8] != b'T' || b[15] != b'Z' {
298        return None;
299    }
300    let n = |r: std::ops::Range<usize>| s.get(r)?.parse::<i64>().ok();
301    let (y, mo, d) = (n(0..4)?, n(4..6)?, n(6..8)?);
302    let (h, mi, se) = (n(9..11)?, n(11..13)?, n(13..15)?);
303    if !(1..=12).contains(&mo) || !(1..=31).contains(&d) || h > 23 || mi > 59 || se > 59 {
304        return None;
305    }
306    Some(days_from_civil(y, mo as u32, d as u32) * 86_400 + h * 3600 + mi * 60 + se)
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    /// Unix seconds of a UTC civil time.
314    fn at(y: i64, mo: u32, d: u32, h: i64, mi: i64) -> i64 {
315        days_from_civil(y, mo, d) * 86_400 + h * 3600 + mi * 60
316    }
317
318    fn next(expr: &str, t: i64) -> i64 {
319        Schedule::parse(expr).unwrap().next_after(t).unwrap()
320    }
321
322    #[test]
323    fn civil_round_trip() {
324        assert_eq!(civil_from_days(0), (1970, 1, 1));
325        assert_eq!(days_from_civil(2000, 3, 1), 11_017);
326        for z in [-1000, 0, 10_957, 11_016, 11_017, 19_782, 20_000, 60_000] {
327            let (y, m, d) = civil_from_days(z);
328            assert_eq!(days_from_civil(y, m, d), z);
329        }
330        assert_eq!(weekday(1970, 1, 1), 4);
331        assert_eq!(weekday(2026, 10, 3), 6, "a Saturday");
332        assert_eq!(compact_utc(at(2026, 10, 3, 4, 5) + 6), "20261003T040506Z");
333        assert_eq!(
334            parse_compact_utc("20261003T040506Z"),
335            Some(at(2026, 10, 3, 4, 5) + 6)
336        );
337        assert_eq!(parse_compact_utc("20261003T0405Z"), None);
338        assert_eq!(rfc3339(0), "1970-01-01T00:00:00Z");
339    }
340
341    #[test]
342    fn every_minute_and_steps() {
343        let t = at(2026, 10, 3, 12, 0);
344        assert_eq!(next("* * * * *", t), t + 60);
345        // Strictly after: from 12:00:30 the next is 12:01.
346        assert_eq!(next("* * * * *", t + 30), t + 60);
347        assert_eq!(next("*/15 * * * *", t), at(2026, 10, 3, 12, 15));
348        assert_eq!(
349            next("*/15 * * * *", at(2026, 10, 3, 12, 50)),
350            at(2026, 10, 3, 13, 0)
351        );
352        assert_eq!(next("5/20 * * * *", t), at(2026, 10, 3, 12, 5));
353        assert_eq!(
354            next("5/20 * * * *", at(2026, 10, 3, 12, 45)),
355            at(2026, 10, 3, 13, 5)
356        );
357        assert_eq!(
358            next("10-20/5 * * * *", at(2026, 10, 3, 12, 16)),
359            at(2026, 10, 3, 12, 20)
360        );
361        assert_eq!(next("0,30 */6 * * *", t), at(2026, 10, 3, 12, 30));
362        assert_eq!(
363            next("0,30 */6 * * *", at(2026, 10, 3, 12, 30)),
364            at(2026, 10, 3, 18, 0)
365        );
366        assert_eq!(
367            next("59 23 * * *", at(2026, 12, 31, 23, 59)),
368            at(2027, 1, 1, 23, 59)
369        );
370    }
371
372    #[test]
373    fn aliases() {
374        let t = at(2026, 10, 3, 12, 34);
375        assert_eq!(next("@hourly", t), at(2026, 10, 3, 13, 0));
376        assert_eq!(next("@daily", t), at(2026, 10, 4, 0, 0));
377        assert_eq!(next("@midnight", t), at(2026, 10, 4, 0, 0));
378        assert_eq!(
379            next("@weekly", t),
380            at(2026, 10, 4, 0, 0),
381            "the 4th is a Sunday"
382        );
383        assert_eq!(next("@monthly", t), at(2026, 11, 1, 0, 0));
384        assert_eq!(next("@yearly", t), at(2027, 1, 1, 0, 0));
385        assert_eq!(next("@annually", t), at(2027, 1, 1, 0, 0));
386        assert!(Schedule::parse("@reboot").is_err());
387    }
388
389    #[test]
390    fn month_ends_and_leap_years() {
391        // The 31st skips months without one.
392        assert_eq!(
393            next("0 0 31 * *", at(2026, 4, 1, 0, 0)),
394            at(2026, 5, 31, 0, 0)
395        );
396        assert_eq!(
397            next("0 0 31 * *", at(2026, 5, 31, 0, 0)),
398            at(2026, 7, 31, 0, 0)
399        );
400        // February 29th: the next leap year.
401        assert_eq!(
402            next("0 12 29 2 *", at(2026, 3, 1, 0, 0)),
403            at(2028, 2, 29, 12, 0)
404        );
405        // 2100 is not a leap year; 2000 was.
406        assert_eq!(
407            next("0 0 29 2 *", at(2096, 3, 1, 0, 0)),
408            at(2104, 2, 29, 0, 0)
409        );
410        assert_eq!(
411            next("0 0 29 2 *", at(1999, 1, 1, 0, 0)),
412            at(2000, 2, 29, 0, 0)
413        );
414        // The last day of February, any year: 28 or 29.
415        assert_eq!(
416            next("0 0 28,29 2 *", at(2026, 2, 28, 0, 0)),
417            at(2027, 2, 28, 0, 0)
418        );
419        // Year end.
420        assert_eq!(
421            next("0 0 1 1 *", at(2026, 12, 31, 23, 59)),
422            at(2027, 1, 1, 0, 0)
423        );
424        // Impossible dates are refused, not looped over.
425        assert!(Schedule::parse("0 0 30 2 *").is_err());
426        assert!(Schedule::parse("0 0 31 4,6,9,11 *").is_err());
427    }
428
429    #[test]
430    fn days_of_week_and_names() {
431        let sat = at(2026, 10, 3, 9, 0);
432        assert_eq!(next("0 9 * * mon-fri", sat), at(2026, 10, 5, 9, 0));
433        assert_eq!(next("0 9 * * 1-5", sat), at(2026, 10, 5, 9, 0));
434        // 7 and 0 are both Sunday.
435        assert_eq!(next("0 9 * * 7", sat), at(2026, 10, 4, 9, 0));
436        assert_eq!(next("0 9 * * SUN", sat), at(2026, 10, 4, 9, 0));
437        assert_eq!(next("0 0 1 JAN-mar *", sat), at(2027, 1, 1, 0, 0));
438        // Both restricted: either matches (Vixie cron). The 13th or a Friday.
439        assert_eq!(next("0 0 13 * 5", sat), at(2026, 10, 9, 0, 0));
440        assert_eq!(
441            next("0 0 13 * 5", at(2026, 10, 9, 0, 0)),
442            at(2026, 10, 13, 0, 0)
443        );
444        // A restricted day of week with `*` day of month: weekday only.
445        assert_eq!(
446            next("0 0 * * 5", at(2026, 10, 9, 0, 0)),
447            at(2026, 10, 16, 0, 0)
448        );
449    }
450
451    #[test]
452    fn errors() {
453        for bad in [
454            "",
455            "* * * *",
456            "* * * * * *",
457            "60 * * * *",
458            "* 24 * * *",
459            "* * 0 * *",
460            "* * * 13 *",
461            "* * * * 8",
462            "*/0 * * * *",
463            "5-1 * * * *",
464            "a * * * *",
465            "1,,2 * * * *",
466            "* * * foo *",
467        ] {
468            assert!(Schedule::parse(bad).is_err(), "{bad:?}");
469        }
470        let e = Schedule::parse("61 * * * *").unwrap_err().to_string();
471        assert!(e.contains("minute"), "{e}");
472    }
473
474    #[test]
475    fn offsets() {
476        assert_eq!(parse_offset("UTC").unwrap(), 0);
477        assert_eq!(parse_offset("").unwrap(), 0);
478        assert_eq!(parse_offset("+02:00").unwrap(), 7200);
479        assert_eq!(parse_offset("-0830").unwrap(), -30_600);
480        assert_eq!(parse_offset("UTC+5").unwrap(), 18_000);
481        assert!(parse_offset("Europe/Berlin").is_err());
482        assert!(parse_offset("+25:00").is_err());
483        // 09:00 at +02:00 is 07:00 UTC.
484        let s = Schedule::parse_in("0 9 * * *", 7200).unwrap();
485        assert_eq!(
486            s.next_after(at(2026, 10, 3, 0, 0)).unwrap(),
487            at(2026, 10, 3, 7, 0)
488        );
489        // 01:00 at +02:00 is 23:00 UTC the day before.
490        let s = Schedule::parse_in("0 1 * * *", 7200).unwrap();
491        assert_eq!(
492            s.next_after(at(2026, 10, 3, 0, 0)).unwrap(),
493            at(2026, 10, 3, 23, 0)
494        );
495        // A day-of-week restriction is read in local time too.
496        let s = Schedule::parse_in("30 0 * * 0", -3600).unwrap();
497        assert_eq!(
498            s.next_after(at(2026, 10, 3, 0, 0)).unwrap(),
499            at(2026, 10, 4, 1, 30)
500        );
501    }
502}