pr_01m47d15m3e54sn21z27rpy5n9/crates/actions/src/cron.rs
| 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 | 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 | |
| 105 | /// The date of a day counted from 1970-01-01 (Howard Hinnant's algorithm). |
| 106 | fn civil_from_days(days: i64) -> (i64, u32, u32) { |
| 107 | let z = days + 719_468; |
| 108 | let era = z.div_euclid(146_097); |
| 109 | let doe = z.rem_euclid(146_097); |
| 110 | let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; |
| 111 | let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); |
| 112 | let mp = (5 * doy + 2) / 153; |
| 113 | let day = (doy - (153 * mp + 2) / 5 + 1) as u32; |
| 114 | let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; |
| 115 | (yoe + era * 400 + i64::from(month <= 2), month, day) |
| 116 | } |
| 117 | |
| 118 | #[cfg(test)] |
| 119 | mod tests { |
| 120 | use super::*; |
| 121 | |
| 122 | /// Milliseconds at a UTC date and time. |
| 123 | fn at(days_since_epoch: u64, hour: u64, minute: u64) -> u64 { |
| 124 | ((days_since_epoch * 24 + hour) * 60 + minute) * 60_000 |
| 125 | } |
| 126 | |
| 127 | // 2026-10-05 is a Monday: 20_731 days after 1970-01-01. |
| 128 | const MONDAY: u64 = 20_731; |
| 129 | |
| 130 | #[test] |
| 131 | fn dates_are_worked_out() { |
| 132 | assert_eq!(civil_from_days(0), (1970, 1, 1)); |
| 133 | assert_eq!(civil_from_days(MONDAY as i64), (2026, 10, 5)); |
| 134 | } |
| 135 | |
| 136 | #[test] |
| 137 | fn mondays_at_nine() { |
| 138 | let schedule = Schedule::parse("0 9 * * mon").unwrap(); |
| 139 | assert!(schedule.fires_at(at(MONDAY, 9, 0))); |
| 140 | assert!(!schedule.fires_at(at(MONDAY, 9, 1))); |
| 141 | assert!(!schedule.fires_at(at(MONDAY + 1, 9, 0))); |
| 142 | assert!(Schedule::parse("0 9 * * 1").unwrap().fires_at(at(MONDAY, 9, 0))); |
| 143 | } |
| 144 | |
| 145 | #[test] |
| 146 | fn steps_ranges_and_lists() { |
| 147 | let every_quarter = Schedule::parse("*/15 * * * *").unwrap(); |
| 148 | assert!(every_quarter.fires_at(at(MONDAY, 3, 45))); |
| 149 | assert!(!every_quarter.fires_at(at(MONDAY, 3, 44))); |
| 150 | let weekdays = Schedule::parse("30 8-17/3 * * mon-fri").unwrap(); |
| 151 | assert!(weekdays.fires_at(at(MONDAY, 14, 30))); |
| 152 | assert!(!weekdays.fires_at(at(MONDAY, 15, 30))); |
| 153 | assert!(!weekdays.fires_at(at(MONDAY + 5, 14, 30))); |
| 154 | let sunday = Schedule::parse("0 0 * * 7").unwrap(); |
| 155 | assert!(sunday.fires_at(at(MONDAY + 6, 0, 0))); |
| 156 | // MONDAY is in October. |
| 157 | assert!(Schedule::parse("0 9 * oct mon").unwrap().fires_at(at(MONDAY, 9, 0))); |
| 158 | assert!(!Schedule::parse("0 9 * jan-sep *").unwrap().fires_at(at(MONDAY, 9, 0))); |
| 159 | assert!(Schedule::parse("0 9 * * *").unwrap().fires_at(at(MONDAY, 9, 0))); |
| 160 | } |
| 161 | |
| 162 | #[test] |
| 163 | fn day_of_month_or_week_when_both_are_given() { |
| 164 | // The 1st, or any Monday. |
| 165 | let schedule = Schedule::parse("0 0 1 * mon").unwrap(); |
| 166 | assert!(schedule.fires_at(at(MONDAY, 0, 0))); |
| 167 | assert!(!schedule.fires_at(at(MONDAY + 1, 0, 0))); |
| 168 | } |
| 169 | |
| 170 | #[test] |
| 171 | fn nonsense_is_refused() { |
| 172 | for text in ["", "* * * *", "61 * * * *", "* * * * funday", "*/0 * * * *", "5-1 * * * *"] { |
| 173 | assert!(Schedule::parse(text).is_err(), "{text}"); |
| 174 | } |
| 175 | } |
| 176 | } |