| 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)] |
| 7 | pub 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 | |
| 19 | const WEEKDAYS: [&str; 7] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; |
| 20 | /// Months by name, from 1: the empty first entry stands for 0. |
| 21 | const MONTHS: [&str; 13] = ["", "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]; |
| 22 | |
| 23 | fn 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 | |
| 64 | impl 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 | self.minutes[(ms / 60_000 % 60) as usize] && self.hour_and_date_at(ms) |
| 89 | } |
| 90 | |
| 91 | /// Whether the minute starting at `ms` is in its hours, days and |
| 92 | /// months, whatever its minute field says. |
| 93 | fn hour_and_date_at(&self, ms: u64) -> bool { |
| 94 | let minutes_total = ms / 60_000; |
| 95 | let hour = (minutes_total / 60 % 24) as usize; |
| 96 | let days_since_epoch = (minutes_total / 60 / 24) as i64; |
| 97 | // 1970-01-01 was a Thursday. |
| 98 | let weekday = ((days_since_epoch + 4) % 7) as usize; |
| 99 | let (_, month, day) = civil_from_days(days_since_epoch); |
| 100 | let day_ok = self.days[day as usize]; |
| 101 | let weekday_ok = self.weekdays[weekday]; |
| 102 | let date_ok = match (self.days_restricted, self.weekdays_restricted) { |
| 103 | (true, true) => day_ok || weekday_ok, |
| 104 | _ => day_ok && weekday_ok, |
| 105 | }; |
| 106 | self.hours[hour] && self.months[month as usize] && date_ok |
| 107 | } |
| 108 | |
| 109 | /// Whether its minutes come closer together than |
| 110 | /// [`MIN_INTERVAL_MINUTES`], counting round the hour. |
| 111 | pub fn too_frequent(&self) -> bool { |
| 112 | let set: Vec<usize> = (0..60).filter(|&m| self.minutes[m]).collect(); |
| 113 | let Some(&first) = set.first() else { return false }; |
| 114 | let wrap = first + 60 - set[set.len() - 1]; |
| 115 | set.windows(2).map(|pair| pair[1] - pair[0]).chain([wrap]).any(|gap| gap < MIN_INTERVAL_MINUTES as usize) |
| 116 | } |
| 117 | |
| 118 | /// Whether a workflow on this schedule runs in the minute starting at |
| 119 | /// `ms`. A schedule no more frequent than every |
| 120 | /// [`MIN_INTERVAL_MINUTES`] runs when it fires. A more frequent one |
| 121 | /// runs on the five-minute marks: at a mark that is itself in its |
| 122 | /// hours, days and months, when it fired in the five minutes the mark |
| 123 | /// ends. At most every five minutes, and never outside its own hours. |
| 124 | pub fn runs_at(&self, ms: u64) -> bool { |
| 125 | if !self.too_frequent() { |
| 126 | return self.fires_at(ms); |
| 127 | } |
| 128 | let minute = ms / 60_000; |
| 129 | minute % MIN_INTERVAL_MINUTES == 0 |
| 130 | && self.hour_and_date_at(ms) |
| 131 | && (0..MIN_INTERVAL_MINUTES).any(|back| minute >= back && self.fires_at((minute - back) * 60_000)) |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | /// The shortest interval a workflow's schedule runs at, in minutes. |
| 136 | pub const MIN_INTERVAL_MINUTES: u64 = 5; |
| 137 | |
| 138 | /// The date of a day counted from 1970-01-01 (Howard Hinnant's algorithm). |
| 139 | fn civil_from_days(days: i64) -> (i64, u32, u32) { |
| 140 | let z = days + 719_468; |
| 141 | let era = z.div_euclid(146_097); |
| 142 | let doe = z.rem_euclid(146_097); |
| 143 | let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; |
| 144 | let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); |
| 145 | let mp = (5 * doy + 2) / 153; |
| 146 | let day = (doy - (153 * mp + 2) / 5 + 1) as u32; |
| 147 | let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; |
| 148 | (yoe + era * 400 + i64::from(month <= 2), month, day) |
| 149 | } |
| 150 | |
| 151 | #[cfg(test)] |
| 152 | mod tests { |
| 153 | use super::*; |
| 154 | |
| 155 | /// Milliseconds at a UTC date and time. |
| 156 | fn at(days_since_epoch: u64, hour: u64, minute: u64) -> u64 { |
| 157 | ((days_since_epoch * 24 + hour) * 60 + minute) * 60_000 |
| 158 | } |
| 159 | |
| 160 | // 2026-10-05 is a Monday: 20_731 days after 1970-01-01. |
| 161 | const MONDAY: u64 = 20_731; |
| 162 | |
| 163 | #[test] |
| 164 | fn dates_are_worked_out() { |
| 165 | assert_eq!(civil_from_days(0), (1970, 1, 1)); |
| 166 | assert_eq!(civil_from_days(MONDAY as i64), (2026, 10, 5)); |
| 167 | } |
| 168 | |
| 169 | #[test] |
| 170 | fn mondays_at_nine() { |
| 171 | let schedule = Schedule::parse("0 9 * * mon").unwrap(); |
| 172 | assert!(schedule.fires_at(at(MONDAY, 9, 0))); |
| 173 | assert!(!schedule.fires_at(at(MONDAY, 9, 1))); |
| 174 | assert!(!schedule.fires_at(at(MONDAY + 1, 9, 0))); |
| 175 | assert!(Schedule::parse("0 9 * * 1").unwrap().fires_at(at(MONDAY, 9, 0))); |
| 176 | } |
| 177 | |
| 178 | #[test] |
| 179 | fn steps_ranges_and_lists() { |
| 180 | let every_quarter = Schedule::parse("*/15 * * * *").unwrap(); |
| 181 | assert!(every_quarter.fires_at(at(MONDAY, 3, 45))); |
| 182 | assert!(!every_quarter.fires_at(at(MONDAY, 3, 44))); |
| 183 | let weekdays = Schedule::parse("30 8-17/3 * * mon-fri").unwrap(); |
| 184 | assert!(weekdays.fires_at(at(MONDAY, 14, 30))); |
| 185 | assert!(!weekdays.fires_at(at(MONDAY, 15, 30))); |
| 186 | assert!(!weekdays.fires_at(at(MONDAY + 5, 14, 30))); |
| 187 | let sunday = Schedule::parse("0 0 * * 7").unwrap(); |
| 188 | assert!(sunday.fires_at(at(MONDAY + 6, 0, 0))); |
| 189 | // MONDAY is in October. |
| 190 | assert!(Schedule::parse("0 9 * oct mon").unwrap().fires_at(at(MONDAY, 9, 0))); |
| 191 | assert!(!Schedule::parse("0 9 * jan-sep *").unwrap().fires_at(at(MONDAY, 9, 0))); |
| 192 | assert!(Schedule::parse("0 9 * * *").unwrap().fires_at(at(MONDAY, 9, 0))); |
| 193 | } |
| 194 | |
| 195 | #[test] |
| 196 | fn day_of_month_or_week_when_both_are_given() { |
| 197 | // The 1st, or any Monday. |
| 198 | let schedule = Schedule::parse("0 0 1 * mon").unwrap(); |
| 199 | assert!(schedule.fires_at(at(MONDAY, 0, 0))); |
| 200 | assert!(!schedule.fires_at(at(MONDAY + 1, 0, 0))); |
| 201 | } |
| 202 | |
| 203 | #[test] |
| 204 | fn schedules_run_at_most_every_five_minutes() { |
| 205 | let runs = |text: &str| -> Vec<u64> { |
| 206 | let schedule = Schedule::parse(text).unwrap(); |
| 207 | (0..30).filter(|&m| schedule.runs_at(at(MONDAY, 3, m))).collect() |
| 208 | }; |
| 209 | assert_eq!(runs("* * * * *"), [0, 5, 10, 15, 20, 25]); |
| 210 | assert_eq!(runs("*/2 * * * *"), [0, 5, 10, 15, 20, 25]); |
| 211 | // Every five minutes or less often: exactly when it fires. |
| 212 | assert_eq!(runs("*/5 * * * *"), [0, 5, 10, 15, 20, 25]); |
| 213 | assert_eq!(runs("7,17 * * * *"), [7, 17]); |
| 214 | assert_eq!(runs("*/10 * * * *"), [0, 10, 20]); |
| 215 | // Two minutes close together: the later one waits for the mark. |
| 216 | assert_eq!(runs("0,3 * * * *"), [0, 5]); |
| 217 | // Close across the hour counts too. |
| 218 | assert!(Schedule::parse("2,58 * * * *").unwrap().too_frequent()); |
| 219 | assert!(!Schedule::parse("0 9 * * mon").unwrap().too_frequent()); |
| 220 | // Every minute of one hour: its own marks, 09:00 to 09:55, and |
| 221 | // nothing after. |
| 222 | let nine = Schedule::parse("* 9 * * *").unwrap(); |
| 223 | let day: Vec<(u64, u64)> = |
| 224 | (0..24 * 60).filter(|&m| nine.runs_at(at(MONDAY, m / 60, m % 60))).map(|m| (m / 60, m % 60)).collect(); |
| 225 | assert_eq!(day, (0..12).map(|i| (9, i * 5)).collect::<Vec<_>>()); |
| 226 | assert!(!nine.runs_at(at(MONDAY, 10, 0))); |
| 227 | // Every minute of Mondays: the last run is 23:55, none on Tuesday. |
| 228 | let mondays = Schedule::parse("*/1 * * * 1").unwrap(); |
| 229 | assert!(mondays.runs_at(at(MONDAY, 0, 0))); |
| 230 | assert!(mondays.runs_at(at(MONDAY, 23, 55))); |
| 231 | assert!(!mondays.runs_at(at(MONDAY + 1, 0, 0))); |
| 232 | assert!(!(0..24 * 60).any(|m| mondays.runs_at(at(MONDAY + 1, m / 60, m % 60)))); |
| 233 | // Close minutes: one run for each window they fall in, at its mark. |
| 234 | let close = Schedule::parse("0,3 * * * *").unwrap(); |
| 235 | let hour: Vec<u64> = (0..60).filter(|&m| close.runs_at(at(MONDAY, 3, m))).collect(); |
| 236 | assert_eq!(hour, [0, 5]); |
| 237 | assert!(close.runs_at(at(MONDAY, 4, 0))); |
| 238 | } |
| 239 | |
| 240 | #[test] |
| 241 | fn nonsense_is_refused() { |
| 242 | for text in ["", "* * * *", "61 * * * *", "* * * * funday", "*/0 * * * *", "5-1 * * * *"] { |
| 243 | assert!(Schedule::parse(text).is_err(), "{text}"); |
| 244 | } |
| 245 | } |
| 246 | } |