g1t/services/automations/src/cron.rs
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Automations: rules in .g1t/automations that act when something happens | 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)] | |
| 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 | ||
| 21 | fn 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 | ||
| 62 | impl 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). | |
| 104 | fn 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)] | |
| 117 | mod 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 | } |