pr_01m47d15m3e54sn21z27rpy5n9/crates/actions/src/cron.rs

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