flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/crates/actions/src/cron.rs

176 lines7,067 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`. 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
105/// The date of a day counted from 1970-01-01 (Howard Hinnant's algorithm).
106fn 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)]
119mod 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}