Skip to main content

asched_core/routine/
cron.rs

1use super::RoutineError;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub struct LocalTime {
5    pub minute: u8,
6    pub hour: u8,
7    pub day_of_month: u8,
8    pub month: u8,
9    pub day_of_week: u8,
10}
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13struct Field {
14    allowed: Vec<bool>,
15    restricted: bool,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct CronSchedule {
20    minute: Field,
21    hour: Field,
22    day_of_month: Field,
23    month: Field,
24    day_of_week: Field,
25}
26
27impl CronSchedule {
28    pub fn parse(input: &str) -> Result<Self, RoutineError> {
29        let parts: Vec<_> = input.split_whitespace().collect();
30        if parts.len() != 5 {
31            return Err(invalid("cron must contain exactly five fields"));
32        }
33        Ok(Self {
34            minute: parse_field(parts[0], 0, 59, false)?,
35            hour: parse_field(parts[1], 0, 23, false)?,
36            day_of_month: parse_field(parts[2], 1, 31, false)?,
37            month: parse_field(parts[3], 1, 12, false)?,
38            day_of_week: parse_field(parts[4], 0, 7, true)?,
39        })
40    }
41
42    pub fn matches(&self, t: LocalTime) -> bool {
43        let basic = self.minute.allowed[t.minute as usize]
44            && self.hour.allowed[t.hour as usize]
45            && self.month.allowed[t.month as usize];
46        let dom = self.day_of_month.allowed[t.day_of_month as usize];
47        let dow = self.day_of_week.allowed[t.day_of_week as usize];
48        let day = if self.day_of_month.restricted && self.day_of_week.restricted {
49            dom || dow
50        } else {
51            dom && dow
52        };
53        basic && day
54    }
55
56    /// Project the first matching local civil minute strictly after `epoch`.
57    /// This belongs in the refreshed view model, never in a render pass.
58    pub fn next_run_after(&self, epoch: i64) -> Option<i64> {
59        self.next_run_after_counted(epoch).0
60    }
61
62    fn next_run_after_counted(&self, epoch: i64) -> (Option<i64>, usize) {
63        let mut candidate = (epoch / 60 + 1) * 60;
64        // ^ Eight years covers the longest Gregorian gap between realizable
65        // numeric schedules (Feb 29 across a non-leap century).
66        let deadline = candidate + 8 * 366 * 24 * 60 * 60;
67        let mut probes = 0;
68        while candidate < deadline {
69            let local = local_time(candidate);
70            probes += 1;
71            if self.matches(local) {
72                return (Some(candidate), probes);
73            }
74            let date_allowed = self.month.allowed[local.month as usize]
75                && self.day_matches(local.day_of_month, local.day_of_week);
76            let minutes = if !date_allowed || !self.hour.allowed[local.hour as usize] {
77                60 - i64::from(local.minute)
78            } else {
79                self.minute
80                    .allowed
81                    .iter()
82                    .enumerate()
83                    .skip(usize::from(local.minute) + 1)
84                    .find_map(|(minute, allowed)| allowed.then_some(minute as i64))
85                    .map(|minute| minute - i64::from(local.minute))
86                    .unwrap_or_else(|| 60 - i64::from(local.minute))
87            };
88            candidate += minutes.max(1) * 60;
89        }
90        (None, probes)
91    }
92
93    fn day_matches(&self, day_of_month: u8, day_of_week: u8) -> bool {
94        let dom = self.day_of_month.allowed[day_of_month as usize];
95        let dow = self.day_of_week.allowed[day_of_week as usize];
96        if self.day_of_month.restricted && self.day_of_week.restricted {
97            dom || dow
98        } else {
99            dom && dow
100        }
101    }
102}
103
104fn local_time(epoch: i64) -> LocalTime {
105    let timestamp = epoch as libc::time_t;
106    let mut out = std::mem::MaybeUninit::<libc::tm>::uninit();
107    unsafe {
108        libc::localtime_r(&timestamp, out.as_mut_ptr());
109        let out = out.assume_init();
110        LocalTime {
111            minute: out.tm_min as u8,
112            hour: out.tm_hour as u8,
113            day_of_month: out.tm_mday as u8,
114            month: (out.tm_mon + 1) as u8,
115            day_of_week: out.tm_wday as u8,
116        }
117    }
118}
119
120fn invalid(message: impl Into<String>) -> RoutineError {
121    RoutineError::Validation(message.into())
122}
123
124fn parse_field(text: &str, min: u8, max: u8, sunday: bool) -> Result<Field, RoutineError> {
125    if text.is_empty() {
126        return Err(invalid("empty cron field"));
127    }
128    let mut allowed = vec![false; max as usize + 1];
129    let mut wildcard = false;
130    for item in text.split(',') {
131        if item.is_empty() {
132            return Err(invalid(format!("empty cron list item in '{text}'")));
133        }
134        let (base, step, stepped) = match item.split_once('/') {
135            Some((base, step)) if !base.is_empty() && !step.is_empty() && !step.contains('/') => {
136                let step = step
137                    .parse::<u8>()
138                    .map_err(|_| invalid(format!("invalid step in '{item}'")))?;
139                if step == 0 {
140                    return Err(invalid("cron step must be positive"));
141                }
142                (base, step, true)
143            }
144            Some(_) => return Err(invalid(format!("invalid stepped field '{item}'"))),
145            None => (item, 1, false),
146        };
147        let (start, end) = if base == "*" {
148            wildcard = true;
149            (min, max)
150        } else if let Some((a, b)) = base.split_once('-') {
151            if b.contains('-') {
152                return Err(invalid(format!("invalid range '{base}'")));
153            }
154            (number(a, min, max)?, number(b, min, max)?)
155        } else {
156            let value = number(base, min, max)?;
157            (value, if stepped { max } else { value })
158        };
159        if start > end {
160            return Err(invalid(format!("descending range '{base}'")));
161        }
162        for value in (start..=end).step_by(step as usize) {
163            allowed[value as usize] = true;
164        }
165    }
166    if sunday && allowed[7] {
167        allowed[0] = true;
168        allowed[7] = false;
169    }
170    Ok(Field {
171        allowed,
172        restricted: !wildcard,
173    })
174}
175
176fn number(text: &str, min: u8, max: u8) -> Result<u8, RoutineError> {
177    let value = text
178        .parse::<u8>()
179        .map_err(|_| invalid(format!("invalid cron number '{text}'")))?;
180    if !(min..=max).contains(&value) {
181        return Err(invalid(format!(
182            "cron number {value} outside {min}..={max}"
183        )));
184    }
185    Ok(value)
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    fn time(minute: u8, hour: u8, dom: u8, month: u8, dow: u8) -> LocalTime {
193        LocalTime {
194            minute,
195            hour,
196            day_of_month: dom,
197            month,
198            day_of_week: dow,
199        }
200    }
201
202    #[test]
203    fn supports_wildcard_lists_ranges_steps_and_sunday_seven() {
204        let cron = CronSchedule::parse("*/15 1,3 10-12/2 * 7").unwrap();
205        assert!(cron.matches(time(30, 3, 10, 6, 0)));
206        assert!(!cron.matches(time(31, 3, 10, 6, 0)));
207        let numeric_step = CronSchedule::parse("5/20 * * * *").unwrap();
208        assert!(numeric_step.matches(time(25, 0, 1, 1, 1)));
209    }
210
211    #[test]
212    fn restricted_dom_and_dow_use_vixie_or() {
213        let cron = CronSchedule::parse("0 0 1 * 2").unwrap();
214        assert!(cron.matches(time(0, 0, 9, 1, 2)));
215        assert!(cron.matches(time(0, 0, 1, 1, 4)));
216        assert!(!cron.matches(time(0, 0, 9, 1, 4)));
217    }
218
219    #[test]
220    fn rejects_invalid_boundaries_and_steps() {
221        for value in [
222            "60 * * * *",
223            "* 24 * * *",
224            "* * 0 * *",
225            "* * * 13 *",
226            "* * * * 8",
227            "*/0 * * * *",
228            "1,,2 * * * *",
229            "4-2 * * * *",
230        ] {
231            assert!(CronSchedule::parse(value).is_err(), "{value}");
232        }
233    }
234
235    #[test]
236    fn sparse_annual_schedule_projects_once_into_the_view_model() {
237        let schedule = CronSchedule::parse("0 0 1 1 *").unwrap();
238        let start = 1_767_225_600; // 2026-01-01T00:00:00Z
239        let (next, probes) = schedule.next_run_after_counted(start);
240        let next = next.unwrap();
241        assert!(next > start);
242        assert!(next <= start + 366 * 24 * 60 * 60);
243        assert!(schedule.matches(local_time(next)));
244        assert!(probes < 10_000, "sparse projection used {probes} probes");
245    }
246
247    #[test]
248    fn leap_day_schedule_beyond_one_year_still_projects() {
249        let schedule = CronSchedule::parse("0 0 29 2 *").unwrap();
250        let start = 1_767_225_600; // 2026-01-01T00:00:00Z
251        let next = schedule.next_run_after(start).unwrap();
252        assert!(next > start + 366 * 24 * 60 * 60);
253        assert!(next <= start + 8 * 366 * 24 * 60 * 60);
254        assert!(schedule.matches(local_time(next)));
255    }
256}