g1t/services/automations/src/cron.rs

170 lines6,512 bytesCodeBlame
1//! Five-field cron schedules, in UTC: minute, hour, day of month, month,
2//! day of week. Each field takes `*`, a number, a range `a-b`, a list
3//! `a,b`, and a step `*/n` or `a-b/n`; days of the week also take `mon` to
4//! `sun`.
5
6#[derive(Clone, Debug, PartialEq, Eq)]
7pub struct Schedule {
8 minutes: Vec<bool>,
9 hours: Vec<bool>,
10 days: Vec<bool>,
11 months: Vec<bool>,
12 weekdays: Vec<bool>,
13 /// Whether day of month and day of week were each restricted: when both
14 /// are, either matching is enough, as in every cron.
15 days_restricted: bool,
16 weekdays_restricted: bool,
17}
18
19const WEEKDAYS: [&str; 7] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
20
21fn field(text: &str, low: u32, high: u32, names: &[&str]) -> Result<(Vec<bool>, bool), String> {
22 let mut set = vec![false; (high + 1) as usize];
23 let value = |part: &str| -> Result<u32, String> {
24 if let Some(at) = names.iter().position(|name| part.eq_ignore_ascii_case(name)) {
25 return Ok(at as u32);
26 }
27 part.parse::<u32>().map_err(|_| format!("`{part}` is not a number"))
28 };
29 for item in text.split(',') {
30 let (range, step) = match item.split_once('/') {
31 Some((range, step)) => (range, step.parse::<u32>().map_err(|_| format!("`{step}` is not a step"))?),
32 None => (item, 1),
33 };
34 if step == 0 {
35 return Err("a step cannot be 0".to_owned());
36 }
37 let (from, to) = if range == "*" {
38 (low, high)
39 } else if let Some((a, b)) = range.split_once('-') {
40 (value(a)?, value(b)?)
41 } else {
42 let at = value(range)?;
43 (at, if item.contains('/') { high } else { at })
44 };
45 // Sunday may be written 7.
46 let (from, to) = if names.len() == 7 && to == 7 { (from.min(6), 6) } else { (from, to) };
47 if from < low || to > high || from > to {
48 return Err(format!("`{item}` is outside {low}-{high}"));
49 }
50 let mut at = from;
51 while at <= to {
52 set[at as usize] = true;
53 at += step;
54 }
55 if names.len() == 7 && text.split(',').any(|part| part == "7") {
56 set[0] = true;
57 }
58 }
59 Ok((set, text != "*"))
60}
61
62impl Schedule {
63 pub fn parse(text: &str) -> Result<Schedule, String> {
64 let parts: Vec<&str> = text.split_whitespace().collect();
65 let [minute, hour, day, month, weekday] = parts[..] else {
66 return Err("a schedule has five fields: minute hour day month weekday, such as `0 9 * * mon`".to_owned());
67 };
68 let (minutes, _) = field(minute, 0, 59, &[])?;
69 let (hours, _) = field(hour, 0, 23, &[])?;
70 let (days, days_restricted) = field(day, 1, 31, &[])?;
71 let (months, _) = field(month, 1, 12, &[])?;
72 let (weekdays, weekdays_restricted) = field(weekday, 0, 6, &WEEKDAYS)?;
73 Ok(Schedule {
74 minutes,
75 hours,
76 days,
77 months,
78 weekdays,
79 days_restricted,
80 weekdays_restricted,
81 })
82 }
83
84 /// Whether it fires in the minute starting at `ms` since the epoch, UTC.
85 pub fn fires_at(&self, ms: u64) -> bool {
86 let minutes_total = ms / 60_000;
87 let minute = (minutes_total % 60) as usize;
88 let hour = (minutes_total / 60 % 24) as usize;
89 let days_since_epoch = (minutes_total / 60 / 24) as i64;
90 // 1970-01-01 was a Thursday.
91 let weekday = ((days_since_epoch + 4) % 7) as usize;
92 let (_, month, day) = civil_from_days(days_since_epoch);
93 let day_ok = self.days[day as usize];
94 let weekday_ok = self.weekdays[weekday];
95 let date_ok = match (self.days_restricted, self.weekdays_restricted) {
96 (true, true) => day_ok || weekday_ok,
97 _ => day_ok && weekday_ok,
98 };
99 self.minutes[minute] && self.hours[hour] && self.months[month as usize] && date_ok
100 }
101}
102
103/// The date of a day counted from 1970-01-01 (Howard Hinnant's algorithm).
104fn civil_from_days(days: i64) -> (i64, u32, u32) {
105 let z = days + 719_468;
106 let era = z.div_euclid(146_097);
107 let doe = z.rem_euclid(146_097);
108 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
109 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
110 let mp = (5 * doy + 2) / 153;
111 let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
112 let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
113 (yoe + era * 400 + i64::from(month <= 2), month, day)
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119
120 /// Milliseconds at a UTC date and time.
121 fn at(days_since_epoch: u64, hour: u64, minute: u64) -> u64 {
122 ((days_since_epoch * 24 + hour) * 60 + minute) * 60_000
123 }
124
125 // 2026-10-05 is a Monday: 20_731 days after 1970-01-01.
126 const MONDAY: u64 = 20_731;
127
128 #[test]
129 fn dates_are_worked_out() {
130 assert_eq!(civil_from_days(0), (1970, 1, 1));
131 assert_eq!(civil_from_days(MONDAY as i64), (2026, 10, 5));
132 }
133
134 #[test]
135 fn mondays_at_nine() {
136 let schedule = Schedule::parse("0 9 * * mon").unwrap();
137 assert!(schedule.fires_at(at(MONDAY, 9, 0)));
138 assert!(!schedule.fires_at(at(MONDAY, 9, 1)));
139 assert!(!schedule.fires_at(at(MONDAY + 1, 9, 0)));
140 assert!(Schedule::parse("0 9 * * 1").unwrap().fires_at(at(MONDAY, 9, 0)));
141 }
142
143 #[test]
144 fn steps_ranges_and_lists() {
145 let every_quarter = Schedule::parse("*/15 * * * *").unwrap();
146 assert!(every_quarter.fires_at(at(MONDAY, 3, 45)));
147 assert!(!every_quarter.fires_at(at(MONDAY, 3, 44)));
148 let weekdays = Schedule::parse("30 8-17/3 * * mon-fri").unwrap();
149 assert!(weekdays.fires_at(at(MONDAY, 14, 30)));
150 assert!(!weekdays.fires_at(at(MONDAY, 15, 30)));
151 assert!(!weekdays.fires_at(at(MONDAY + 5, 14, 30)));
152 let sunday = Schedule::parse("0 0 * * 7").unwrap();
153 assert!(sunday.fires_at(at(MONDAY + 6, 0, 0)));
154 }
155
156 #[test]
157 fn day_of_month_or_week_when_both_are_given() {
158 // The 1st, or any Monday.
159 let schedule = Schedule::parse("0 0 1 * mon").unwrap();
160 assert!(schedule.fires_at(at(MONDAY, 0, 0)));
161 assert!(!schedule.fires_at(at(MONDAY + 1, 0, 0)));
162 }
163
164 #[test]
165 fn nonsense_is_refused() {
166 for text in ["", "* * * *", "61 * * * *", "* * * * funday", "*/0 * * * *", "5-1 * * * *"] {
167 assert!(Schedule::parse(text).is_err(), "{text}");
168 }
169 }
170}