Skip to content
228 linesCodeBlameRaw
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`, and months `jan` to `dec`. For workflows' `on.schedule`.
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/// Months by name, from 1: the empty first entry stands for 0.
21const MONTHS: [&str; 13] = ["", "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
22
23fn field(text: &str, low: u32, high: u32, names: &[&str]) -> Result<(Vec<bool>, bool), String> {
24 let mut set = vec![false; (high + 1) as usize];
25 let value = |part: &str| -> Result<u32, String> {
26 if let Some(at) = names.iter().position(|name| !name.is_empty() && part.eq_ignore_ascii_case(name)) {
27 return Ok(at as u32);
28 }
29 part.parse::<u32>().map_err(|_| format!("`{part}` is not a number"))
30 };
31 for item in text.split(',') {
32 let (range, step) = match item.split_once('/') {
33 Some((range, step)) => (range, step.parse::<u32>().map_err(|_| format!("`{step}` is not a step"))?),
34 None => (item, 1),
35 };
36 if step == 0 {
37 return Err("a step cannot be 0".to_owned());
38 }
39 let (from, to) = if range == "*" {
40 (low, high)
41 } else if let Some((a, b)) = range.split_once('-') {
42 (value(a)?, value(b)?)
43 } else {
44 let at = value(range)?;
45 (at, if item.contains('/') { high } else { at })
46 };
47 // Sunday may be written 7.
48 let (from, to) = if names.len() == 7 && to == 7 { (from.min(6), 6) } else { (from, to) };
49 if from < low || to > high || from > to {
50 return Err(format!("`{item}` is outside {low}-{high}"));
51 }
52 let mut at = from;
53 while at <= to {
54 set[at as usize] = true;
55 at += step;
56 }
57 if names.len() == 7 && text.split(',').any(|part| part == "7") {
58 set[0] = true;
59 }
60 }
61 Ok((set, text != "*"))
62}
63
64impl Schedule {
65 pub fn parse(text: &str) -> Result<Schedule, String> {
66 let parts: Vec<&str> = text.split_whitespace().collect();
67 let [minute, hour, day, month, weekday] = parts[..] else {
68 return Err("a schedule has five fields: minute hour day month weekday, such as `0 9 * * mon`".to_owned());
69 };
70 let (minutes, _) = field(minute, 0, 59, &[])?;
71 let (hours, _) = field(hour, 0, 23, &[])?;
72 let (days, days_restricted) = field(day, 1, 31, &[])?;
73 let (months, _) = field(month, 1, 12, &MONTHS)?;
74 let (weekdays, weekdays_restricted) = field(weekday, 0, 6, &WEEKDAYS)?;
75 Ok(Schedule {
76 minutes,
77 hours,
78 days,
79 months,
80 weekdays,
81 days_restricted,
82 weekdays_restricted,
83 })
84 }
85
86 /// Whether it fires in the minute starting at `ms` since the epoch, UTC.
87 pub fn fires_at(&self, ms: u64) -> bool {
88 let minutes_total = ms / 60_000;
89 let minute = (minutes_total % 60) as usize;
90 let hour = (minutes_total / 60 % 24) as usize;
91 let days_since_epoch = (minutes_total / 60 / 24) as i64;
92 // 1970-01-01 was a Thursday.
93 let weekday = ((days_since_epoch + 4) % 7) as usize;
94 let (_, month, day) = civil_from_days(days_since_epoch);
95 let day_ok = self.days[day as usize];
96 let weekday_ok = self.weekdays[weekday];
97 let date_ok = match (self.days_restricted, self.weekdays_restricted) {
98 (true, true) => day_ok || weekday_ok,
99 _ => day_ok && weekday_ok,
100 };
101 self.minutes[minute] && self.hours[hour] && self.months[month as usize] && date_ok
102 }
103
104 /// Whether its minutes come closer together than
105 /// [`MIN_INTERVAL_MINUTES`], counting round the hour.
106 pub fn too_frequent(&self) -> bool {
107 let set: Vec<usize> = (0..60).filter(|&m| self.minutes[m]).collect();
108 let Some(&first) = set.first() else { return false };
109 let wrap = first + 60 - set[set.len() - 1];
110 set.windows(2).map(|pair| pair[1] - pair[0]).chain([wrap]).any(|gap| gap < MIN_INTERVAL_MINUTES as usize)
111 }
112
113 /// Whether a workflow on this schedule runs in the minute starting at
114 /// `ms`. A schedule no more frequent than every
115 /// [`MIN_INTERVAL_MINUTES`] runs when it fires. A more frequent one
116 /// runs on the five-minute marks, at each one it fired at or since the
117 /// last: at most every five minutes.
118 pub fn runs_at(&self, ms: u64) -> bool {
119 if !self.too_frequent() {
120 return self.fires_at(ms);
121 }
122 let minute = ms / 60_000;
123 minute % MIN_INTERVAL_MINUTES == 0
124 && (0..MIN_INTERVAL_MINUTES).any(|back| minute >= back && self.fires_at((minute - back) * 60_000))
125 }
126}
127
128/// The shortest interval a workflow's schedule runs at, in minutes.
129pub const MIN_INTERVAL_MINUTES: u64 = 5;
130
131/// The date of a day counted from 1970-01-01 (Howard Hinnant's algorithm).
132fn civil_from_days(days: i64) -> (i64, u32, u32) {
133 let z = days + 719_468;
134 let era = z.div_euclid(146_097);
135 let doe = z.rem_euclid(146_097);
136 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
137 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
138 let mp = (5 * doy + 2) / 153;
139 let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
140 let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
141 (yoe + era * 400 + i64::from(month <= 2), month, day)
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 /// Milliseconds at a UTC date and time.
149 fn at(days_since_epoch: u64, hour: u64, minute: u64) -> u64 {
150 ((days_since_epoch * 24 + hour) * 60 + minute) * 60_000
151 }
152
153 // 2026-10-05 is a Monday: 20_731 days after 1970-01-01.
154 const MONDAY: u64 = 20_731;
155
156 #[test]
157 fn dates_are_worked_out() {
158 assert_eq!(civil_from_days(0), (1970, 1, 1));
159 assert_eq!(civil_from_days(MONDAY as i64), (2026, 10, 5));
160 }
161
162 #[test]
163 fn mondays_at_nine() {
164 let schedule = Schedule::parse("0 9 * * mon").unwrap();
165 assert!(schedule.fires_at(at(MONDAY, 9, 0)));
166 assert!(!schedule.fires_at(at(MONDAY, 9, 1)));
167 assert!(!schedule.fires_at(at(MONDAY + 1, 9, 0)));
168 assert!(Schedule::parse("0 9 * * 1").unwrap().fires_at(at(MONDAY, 9, 0)));
169 }
170
171 #[test]
172 fn steps_ranges_and_lists() {
173 let every_quarter = Schedule::parse("*/15 * * * *").unwrap();
174 assert!(every_quarter.fires_at(at(MONDAY, 3, 45)));
175 assert!(!every_quarter.fires_at(at(MONDAY, 3, 44)));
176 let weekdays = Schedule::parse("30 8-17/3 * * mon-fri").unwrap();
177 assert!(weekdays.fires_at(at(MONDAY, 14, 30)));
178 assert!(!weekdays.fires_at(at(MONDAY, 15, 30)));
179 assert!(!weekdays.fires_at(at(MONDAY + 5, 14, 30)));
180 let sunday = Schedule::parse("0 0 * * 7").unwrap();
181 assert!(sunday.fires_at(at(MONDAY + 6, 0, 0)));
182 // MONDAY is in October.
183 assert!(Schedule::parse("0 9 * oct mon").unwrap().fires_at(at(MONDAY, 9, 0)));
184 assert!(!Schedule::parse("0 9 * jan-sep *").unwrap().fires_at(at(MONDAY, 9, 0)));
185 assert!(Schedule::parse("0 9 * * *").unwrap().fires_at(at(MONDAY, 9, 0)));
186 }
187
188 #[test]
189 fn day_of_month_or_week_when_both_are_given() {
190 // The 1st, or any Monday.
191 let schedule = Schedule::parse("0 0 1 * mon").unwrap();
192 assert!(schedule.fires_at(at(MONDAY, 0, 0)));
193 assert!(!schedule.fires_at(at(MONDAY + 1, 0, 0)));
194 }
195
196 #[test]
197 fn schedules_run_at_most_every_five_minutes() {
198 let runs = |text: &str| -> Vec<u64> {
199 let schedule = Schedule::parse(text).unwrap();
200 (0..30).filter(|&m| schedule.runs_at(at(MONDAY, 3, m))).collect()
201 };
202 assert_eq!(runs("* * * * *"), [0, 5, 10, 15, 20, 25]);
203 assert_eq!(runs("*/2 * * * *"), [0, 5, 10, 15, 20, 25]);
204 // Every five minutes or less often: exactly when it fires.
205 assert_eq!(runs("*/5 * * * *"), [0, 5, 10, 15, 20, 25]);
206 assert_eq!(runs("7,17 * * * *"), [7, 17]);
207 assert_eq!(runs("*/10 * * * *"), [0, 10, 20]);
208 // Two minutes close together: the later one waits for the mark.
209 assert_eq!(runs("0,3 * * * *"), [0, 5]);
210 // Close across the hour counts too.
211 assert!(Schedule::parse("2,58 * * * *").unwrap().too_frequent());
212 assert!(!Schedule::parse("0 9 * * mon").unwrap().too_frequent());
213 // Every minute of one hour: the marks of that hour, and the one
214 // that closes it.
215 let nine = Schedule::parse("* 9 * * *").unwrap();
216 assert!(nine.runs_at(at(MONDAY, 9, 0)));
217 assert!(!nine.runs_at(at(MONDAY, 9, 1)));
218 assert!(nine.runs_at(at(MONDAY, 10, 0)));
219 assert!(!nine.runs_at(at(MONDAY, 10, 5)));
220 }
221
222 #[test]
223 fn nonsense_is_refused() {
224 for text in ["", "* * * *", "61 * * * *", "* * * * funday", "*/0 * * * *", "5-1 * * * *"] {
225 assert!(Schedule::parse(text).is_err(), "{text}");
226 }
227 }
228}