Skip to main content

mecha_core/
cron.rs

1//! Five-field cron expressions, resolved in a named timezone.
2//!
3//! Hand-rolled rather than pulled in, for two reasons. The available crates
4//! speak Quartz's six-or-seven-field dialect where the *first* field is
5//! seconds, so `0 7 * * *` — what a person types, and what every crontab on
6//! this machine means by "seven in the morning" — parses as something else
7//! entirely rather than failing. A scheduler that silently fires at the wrong
8//! time is the worst shape of bug this project keeps finding. And the whole
9//! engine is two functions over a parsed bitfield, which is less code than the
10//! wrapper that would have made a crate's dialect safe.
11//!
12//! Two things are load-bearing beyond the parsing:
13//!
14//! **[`Schedule::prev_at_or_before`] is the primitive, not `next_after`.**
15//! "Is this due?" is answered by asking for the most recent slot at or before
16//! now and comparing it to the last one that fired — which means a scheduler
17//! that was asleep for a week wakes up owing exactly *one* run, not a week of
18//! them, and a tick that arrives late has lost nothing. Iterating forward from
19//! the last fire would have to enumerate every missed slot to find out how many
20//! it was going to throw away.
21//!
22//! **Wall-clock time is not monotonic, and both discontinuities are handled
23//! deliberately.** In the spring-forward gap a daily 02:30 job has no 02:30 to
24//! run at, so it fires at the first instant that exists after the gap — a job
25//! that silently skips a day twice a year is a job you cannot trust. In the
26//! autumn fall-back the local time happens twice, and the *earlier* instant
27//! wins, so the job runs once rather than twice. This is why the timezone is an
28//! IANA name throughout and never an offset.
29
30use chrono::{DateTime, Datelike, Duration, LocalResult, NaiveDate, TimeZone, Timelike, Utc};
31use chrono_tz::Tz;
32use serde::{Deserialize, Serialize};
33
34/// How far the search will look before giving up. A schedule like
35/// `0 0 30 2 *` — the 30th of February — matches nothing ever, and the search
36/// has to terminate on something other than the heat death of the universe.
37/// Four years covers every leap-year interaction a cron expression can express.
38const HORIZON_DAYS: i64 = 366 * 4;
39
40/// A parsed cron expression: minute, hour, day-of-month, month, day-of-week.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(try_from = "String", into = "String")]
43pub struct Schedule {
44    /// The expression as written, so `mecha trigger list` shows what the user
45    /// typed rather than a normalised rendering of it.
46    source: String,
47    minutes: u64,
48    hours: u64,
49    /// Bit 1..=31.
50    days: u64,
51    /// Bit 1..=12.
52    months: u64,
53    /// Bit 0..=6, Sunday is 0.
54    weekdays: u64,
55    /// Vixie cron's rule: when *both* day-of-month and day-of-week are
56    /// restricted, a day matches if *either* does. Recording which fields were
57    /// literally `*` is the only way to reproduce it, because a restriction
58    /// that happens to name every value is not the same as `*`.
59    dom_restricted: bool,
60    dow_restricted: bool,
61}
62
63impl std::fmt::Display for Schedule {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        f.write_str(&self.source)
66    }
67}
68
69impl From<Schedule> for String {
70    fn from(s: Schedule) -> String {
71        s.source
72    }
73}
74
75impl TryFrom<String> for Schedule {
76    type Error = anyhow::Error;
77    fn try_from(s: String) -> anyhow::Result<Self> {
78        Schedule::parse(&s)
79    }
80}
81
82impl std::str::FromStr for Schedule {
83    type Err = anyhow::Error;
84    fn from_str(s: &str) -> anyhow::Result<Self> {
85        Schedule::parse(s)
86    }
87}
88
89impl Schedule {
90    /// Parse `minute hour day-of-month month day-of-week`, or one of the
91    /// `@daily`-style aliases.
92    ///
93    /// `@reboot` is rejected rather than accepted-and-ignored: it means
94    /// something in a crontab and nothing here, and a schedule that parses but
95    /// never fires is exactly the failure this whole module is shaped to avoid.
96    pub fn parse(expr: &str) -> anyhow::Result<Self> {
97        let expr = expr.trim();
98        let expanded = match expr.to_ascii_lowercase().as_str() {
99            "@yearly" | "@annually" => "0 0 1 1 *",
100            "@monthly" => "0 0 1 * *",
101            "@weekly" => "0 0 * * 0",
102            "@daily" | "@midnight" => "0 0 * * *",
103            "@hourly" => "0 * * * *",
104            "@reboot" => anyhow::bail!(
105                "`@reboot` has no meaning for a mecha trigger — there is no boot to hang \
106                 it on. Use an explicit schedule."
107            ),
108            other if other.starts_with('@') => {
109                anyhow::bail!(
110                    "unknown schedule alias `{expr}` (known: @hourly, @daily, @midnight, \
111                     @weekly, @monthly, @yearly)"
112                )
113            }
114            _ => expr,
115        };
116
117        let fields: Vec<&str> = expanded.split_whitespace().collect();
118        anyhow::ensure!(
119            fields.len() == 5,
120            "a cron schedule has five fields — minute hour day-of-month month day-of-week \
121             — but `{expr}` has {}. (Seconds are not a field here: `0 7 * * *` is 7am.)",
122            fields.len()
123        );
124
125        let minutes =
126            parse_field(fields[0], 0, 59, &[]).map_err(|e| ctx("minute", fields[0], e))?;
127        let hours = parse_field(fields[1], 0, 23, &[]).map_err(|e| ctx("hour", fields[1], e))?;
128        let days =
129            parse_field(fields[2], 1, 31, &[]).map_err(|e| ctx("day-of-month", fields[2], e))?;
130        let months =
131            parse_field(fields[3], 1, 12, MONTHS).map_err(|e| ctx("month", fields[3], e))?;
132        let weekdays =
133            parse_field(fields[4], 0, 7, WEEKDAYS).map_err(|e| ctx("day-of-week", fields[4], e))?;
134
135        // Cron numbers Sunday as both 0 and 7; fold so matching only checks 0.
136        let weekdays = if weekdays & (1 << 7) != 0 {
137            (weekdays | 1) & !(1 << 7)
138        } else {
139            weekdays
140        };
141
142        Ok(Schedule {
143            source: expr.to_string(),
144            minutes,
145            hours,
146            days,
147            months,
148            weekdays,
149            dom_restricted: fields[2] != "*",
150            dow_restricted: fields[4] != "*",
151        })
152    }
153
154    pub fn source(&self) -> &str {
155        &self.source
156    }
157
158    /// Does this local wall-clock date match the day fields?
159    fn matches_day(&self, date: NaiveDate) -> bool {
160        if self.months & (1 << date.month()) == 0 {
161            return false;
162        }
163        let dom = self.days & (1 << date.day()) != 0;
164        let dow = self.weekdays & (1 << date.weekday().num_days_from_sunday()) != 0;
165        match (self.dom_restricted, self.dow_restricted) {
166            // Vixie's rule: two restrictions are a union, not an intersection.
167            // `0 0 13 * 5` is "the 13th, and every Friday", not "Friday the 13th".
168            (true, true) => dom || dow,
169            (true, false) => dom,
170            (false, true) => dow,
171            (false, false) => true,
172        }
173    }
174
175    /// The first instant strictly after `after` at which this schedule fires.
176    ///
177    /// `None` only when the expression matches no date within four years —
178    /// February 30th and friends.
179    pub fn next_after(&self, after: DateTime<Utc>, tz: Tz) -> Option<DateTime<Utc>> {
180        // Start from the minute after `after`, in local time: the search space
181        // is wall-clock, which is the whole reason this is not arithmetic.
182        let local = after.with_timezone(&tz);
183        let mut date = local.date_naive();
184        let mut from_minute = local.hour() * 60 + local.minute() + 1;
185
186        for _ in 0..HORIZON_DAYS {
187            if self.matches_day(date) {
188                for minute in from_minute..24 * 60 {
189                    if !self.matches_minute(minute) {
190                        continue;
191                    }
192                    if let Some(utc) = self.resolve(date, minute, tz) {
193                        // A fall-back hour repeats local times, so a resolved
194                        // instant can land at or before where we started even
195                        // though the local clock moved forward.
196                        if utc > after {
197                            return Some(utc);
198                        }
199                    }
200                }
201            }
202            date = date.succ_opt()?;
203            from_minute = 0;
204        }
205        None
206    }
207
208    /// The most recent instant at or before `at` at which this schedule fired.
209    ///
210    /// This is what answers "is it due?": compare it against the last slot that
211    /// actually ran. A scheduler that missed forty slots owes one run, and this
212    /// is the function that makes that true without enumerating the forty.
213    pub fn prev_at_or_before(&self, at: DateTime<Utc>, tz: Tz) -> Option<DateTime<Utc>> {
214        let local = at.with_timezone(&tz);
215        let mut date = local.date_naive();
216        let mut to_minute = local.hour() * 60 + local.minute();
217
218        for _ in 0..HORIZON_DAYS {
219            if self.matches_day(date) {
220                for minute in (0..=to_minute).rev() {
221                    if !self.matches_minute(minute) {
222                        continue;
223                    }
224                    if let Some(utc) = self.resolve(date, minute, tz) {
225                        if utc <= at {
226                            return Some(utc);
227                        }
228                    }
229                }
230            }
231            date = date.pred_opt()?;
232            to_minute = 24 * 60 - 1;
233        }
234        None
235    }
236
237    fn matches_minute(&self, minute_of_day: u32) -> bool {
238        self.hours & (1 << (minute_of_day / 60)) != 0
239            && self.minutes & (1 << (minute_of_day % 60)) != 0
240    }
241
242    /// Turn a local wall-clock slot into a real instant.
243    ///
244    /// The two DST cases, each decided rather than defaulted:
245    ///
246    /// * **Ambiguous** (the hour ran twice): take the earlier. The job runs
247    ///   once, on the first pass, and `next_after`'s "must be strictly later"
248    ///   check keeps the second pass from firing it again.
249    /// * **Gap** (the hour never happened): walk forward to the first minute
250    ///   that exists. A 02:30 daily job fires at 03:00 on the spring-forward
251    ///   day rather than silently skipping it — a scheduled run that vanishes
252    ///   twice a year is worse than one that is half an hour late once.
253    fn resolve(&self, date: NaiveDate, minute_of_day: u32, tz: Tz) -> Option<DateTime<Utc>> {
254        let naive = date.and_hms_opt(minute_of_day / 60, minute_of_day % 60, 0)?;
255        match tz.from_local_datetime(&naive) {
256            LocalResult::Single(dt) => Some(dt.with_timezone(&Utc)),
257            LocalResult::Ambiguous(earlier, _) => Some(earlier.with_timezone(&Utc)),
258            LocalResult::None => {
259                // Gaps are an hour at most in every zone anyone has shipped;
260                // search a little past that and give up rather than loop.
261                let mut probe = naive;
262                for _ in 0..180 {
263                    probe += Duration::minutes(1);
264                    match tz.from_local_datetime(&probe) {
265                        LocalResult::Single(dt) => return Some(dt.with_timezone(&Utc)),
266                        LocalResult::Ambiguous(earlier, _) => {
267                            return Some(earlier.with_timezone(&Utc))
268                        }
269                        LocalResult::None => continue,
270                    }
271                }
272                None
273            }
274        }
275    }
276}
277
278const MONTHS: &[(&str, u32)] = &[
279    ("jan", 1),
280    ("feb", 2),
281    ("mar", 3),
282    ("apr", 4),
283    ("may", 5),
284    ("jun", 6),
285    ("jul", 7),
286    ("aug", 8),
287    ("sep", 9),
288    ("oct", 10),
289    ("nov", 11),
290    ("dec", 12),
291];
292
293const WEEKDAYS: &[(&str, u32)] = &[
294    ("sun", 0),
295    ("mon", 1),
296    ("tue", 2),
297    ("wed", 3),
298    ("thu", 4),
299    ("fri", 5),
300    ("sat", 6),
301];
302
303fn ctx(field: &str, text: &str, e: anyhow::Error) -> anyhow::Error {
304    anyhow::anyhow!("{field} field `{text}`: {e}")
305}
306
307/// One field into a bitmask: `*`, `a`, `a-b`, `*/n`, `a-b/n`, and comma lists
308/// of any of those. Names are accepted where cron accepts them.
309fn parse_field(text: &str, min: u32, max: u32, names: &[(&str, u32)]) -> anyhow::Result<u64> {
310    anyhow::ensure!(!text.is_empty(), "is empty");
311    let mut mask = 0u64;
312
313    for part in text.split(',') {
314        let part = part.trim();
315        anyhow::ensure!(!part.is_empty(), "has an empty item (a stray comma?)");
316
317        let (range, step) = match part.split_once('/') {
318            Some((r, s)) => {
319                let step: u32 = s
320                    .parse()
321                    .map_err(|_| anyhow::anyhow!("step `{s}` is not a number"))?;
322                anyhow::ensure!(step > 0, "a step of 0 matches nothing");
323                (r, step)
324            }
325            None => (part, 1),
326        };
327
328        let (lo, hi) = if range == "*" {
329            (min, max)
330        } else if let Some((a, b)) = range.split_once('-') {
331            (value(a, min, max, names)?, value(b, min, max, names)?)
332        } else {
333            let v = value(range, min, max, names)?;
334            // `5/15` means "from 5, stepping" — same as `5-max/15`, as cron has
335            // it. A bare `5` is just 5.
336            if step > 1 {
337                (v, max)
338            } else {
339                (v, v)
340            }
341        };
342        anyhow::ensure!(lo <= hi, "range {lo}-{hi} runs backwards");
343
344        let mut v = lo;
345        while v <= hi {
346            mask |= 1 << v;
347            v += step;
348        }
349    }
350    Ok(mask)
351}
352
353fn value(text: &str, min: u32, max: u32, names: &[(&str, u32)]) -> anyhow::Result<u32> {
354    let text = text.trim();
355    let n = match text.parse::<u32>() {
356        Ok(n) => n,
357        Err(_) => {
358            let lower = text.to_ascii_lowercase();
359            *names
360                .iter()
361                .find(|(name, _)| lower.starts_with(name))
362                .map(|(_, v)| v)
363                .ok_or_else(|| anyhow::anyhow!("`{text}` is not a number or a known name"))?
364        }
365    };
366    anyhow::ensure!(n >= min && n <= max, "{n} is outside {min}-{max}");
367    Ok(n)
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    fn utc(s: &str) -> DateTime<Utc> {
375        DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
376    }
377
378    fn ny() -> Tz {
379        chrono_tz::America::New_York
380    }
381
382    #[test]
383    fn five_fields_are_five_fields() {
384        // The whole reason this module is hand-rolled: the crates' first field
385        // is seconds, so this expression means 07:00 there only by accident.
386        let s = Schedule::parse("0 7 * * *").unwrap();
387        let next = s.next_after(utc("2026-08-05T00:00:00Z"), ny()).unwrap();
388        assert_eq!(
389            next.with_timezone(&ny()).to_string(),
390            "2026-08-05 07:00:00 EDT"
391        );
392
393        // A six-field expression is an error, not a reinterpretation.
394        let err = Schedule::parse("0 0 7 * * *").unwrap_err().to_string();
395        assert!(err.contains("five fields"), "{err}");
396        assert!(
397            err.contains("7am"),
398            "the message has to say what the user meant: {err}"
399        );
400    }
401
402    #[test]
403    fn steps_ranges_lists_and_names_all_parse() {
404        let s = Schedule::parse("*/15 9-17 * * mon-fri").unwrap();
405        let start = utc("2026-08-05T12:07:00Z"); // Wednesday, 08:07 EDT
406        let next = s.next_after(start, ny()).unwrap();
407        assert_eq!(
408            next.with_timezone(&ny()).to_string(),
409            "2026-08-05 09:00:00 EDT"
410        );
411
412        let s = Schedule::parse("30 3 1,15 jan,jul *").unwrap();
413        let next = s.next_after(utc("2026-08-05T00:00:00Z"), ny()).unwrap();
414        assert_eq!(
415            next.with_timezone(&ny()).to_string(),
416            "2027-01-01 03:30:00 EST"
417        );
418
419        // Weekend-only, by name, crossing a week boundary.
420        let s = Schedule::parse("0 10 * * sat,sun").unwrap();
421        let next = s.next_after(utc("2026-08-05T00:00:00Z"), ny()).unwrap();
422        assert_eq!(next.with_timezone(&ny()).weekday(), chrono::Weekday::Sat);
423    }
424
425    #[test]
426    fn aliases_expand_and_reboot_is_refused() {
427        assert_eq!(Schedule::parse("@daily").unwrap().minutes, 1);
428        assert_eq!(Schedule::parse("@hourly").unwrap().hours, u64::MAX >> 40);
429        let err = Schedule::parse("@reboot").unwrap_err().to_string();
430        assert!(err.contains("no meaning"), "{err}");
431        assert!(Schedule::parse("@yesterday").is_err());
432    }
433
434    #[test]
435    fn a_bad_field_says_which_field_and_what_was_wrong() {
436        let err = Schedule::parse("0 25 * * *").unwrap_err().to_string();
437        assert!(err.contains("hour"), "{err}");
438        assert!(err.contains("outside 0-23"), "{err}");
439
440        let err = Schedule::parse("0 7 * * funday").unwrap_err().to_string();
441        assert!(err.contains("day-of-week"), "{err}");
442
443        let err = Schedule::parse("*/0 * * * *").unwrap_err().to_string();
444        assert!(err.contains("step of 0"), "{err}");
445    }
446
447    /// Vixie's rule, and the reason `dom_restricted`/`dow_restricted` exist:
448    /// two day restrictions are a union, not an intersection.
449    #[test]
450    fn day_of_month_and_day_of_week_are_a_union_when_both_are_set() {
451        // "the 13th, or any Friday" — not "Friday the 13th".
452        let s = Schedule::parse("0 0 13 * fri").unwrap();
453        let after = utc("2026-08-05T00:00:00Z"); // Wednesday
454        let first = s.next_after(after, ny()).unwrap();
455        assert_eq!(
456            first.with_timezone(&ny()).day(),
457            7,
458            "Friday the 7th comes first"
459        );
460        let second = s.next_after(first, ny()).unwrap();
461        assert_eq!(
462            second.with_timezone(&ny()).day(),
463            13,
464            "then the 13th, itself a Thursday"
465        );
466
467        // With only one of them restricted, it is just that one.
468        let s = Schedule::parse("0 0 13 * *").unwrap();
469        let only = s.next_after(after, ny()).unwrap();
470        assert_eq!(only.with_timezone(&ny()).day(), 13);
471    }
472
473    #[test]
474    fn an_impossible_date_terminates_instead_of_searching_forever() {
475        let s = Schedule::parse("0 0 30 2 *").unwrap();
476        assert_eq!(s.next_after(utc("2026-08-05T00:00:00Z"), ny()), None);
477        assert_eq!(s.prev_at_or_before(utc("2026-08-05T00:00:00Z"), ny()), None);
478    }
479
480    /// The spring-forward gap. A daily 02:30 job has no 02:30 to run at on the
481    /// day the clocks jump; it must still run.
482    #[test]
483    fn a_job_inside_the_spring_forward_gap_still_fires() {
484        // 2027-03-14: America/New_York jumps 02:00 EST → 03:00 EDT.
485        let s = Schedule::parse("30 2 * * *").unwrap();
486        let next = s.next_after(utc("2027-03-13T12:00:00Z"), ny()).unwrap();
487        let local = next.with_timezone(&ny());
488        assert_eq!(local.date_naive().to_string(), "2027-03-14");
489        assert_eq!(
490            local.to_string(),
491            "2027-03-14 03:00:00 EDT",
492            "the run is late, not lost — a schedule that silently skips a day twice a \
493             year is a schedule you cannot build on"
494        );
495    }
496
497    /// The fall-back hour happens twice. The job must not.
498    #[test]
499    fn a_job_inside_the_repeated_hour_fires_once() {
500        // 2026-11-01: 02:00 EDT → 01:00 EST, so 01:30 happens twice.
501        let s = Schedule::parse("30 1 * * *").unwrap();
502        let first = s.next_after(utc("2026-10-31T12:00:00Z"), ny()).unwrap();
503        assert_eq!(
504            first.to_rfc3339(),
505            "2026-11-01T05:30:00+00:00",
506            "the earlier 01:30, EDT"
507        );
508
509        let second = s.next_after(first, ny()).unwrap();
510        assert_eq!(
511            second.with_timezone(&ny()).date_naive().to_string(),
512            "2026-11-02",
513            "the next fire is the following day, not the repeated 01:30 in EST"
514        );
515
516        // And the due check agrees: asked at the *second* 01:30, the most
517        // recent slot is still the first one, which already fired.
518        let during = utc("2026-11-01T06:30:00Z");
519        assert_eq!(s.prev_at_or_before(during, ny()).unwrap(), first);
520    }
521
522    /// The property the whole scheduler rests on: a missed week owes one run.
523    #[test]
524    fn the_most_recent_slot_is_one_slot_however_long_the_gap() {
525        let s = Schedule::parse("0 7 * * *").unwrap();
526        let now = utc("2026-08-05T12:30:00Z"); // 08:30 EDT
527        let prev = s.prev_at_or_before(now, ny()).unwrap();
528        assert_eq!(
529            prev.with_timezone(&ny()).to_string(),
530            "2026-08-05 07:00:00 EDT"
531        );
532
533        // A month asleep does not change the answer, and costs no more work.
534        let long_ago = utc("2026-07-01T00:00:00Z");
535        assert!(prev > long_ago, "one slot owed, not thirty-five");
536        assert_eq!(s.prev_at_or_before(now, ny()).unwrap(), prev);
537    }
538
539    #[test]
540    fn prev_and_next_agree_on_a_slot_boundary() {
541        let s = Schedule::parse("*/10 * * * *").unwrap();
542        let exactly = utc("2026-08-05T12:30:00Z");
543        // At the instant of a slot, that slot is the most recent one...
544        assert_eq!(s.prev_at_or_before(exactly, ny()).unwrap(), exactly);
545        // ...and the next is strictly later, so nothing fires twice.
546        assert_eq!(
547            s.next_after(exactly, ny()).unwrap(),
548            utc("2026-08-05T12:40:00Z")
549        );
550    }
551
552    #[test]
553    fn the_timezone_is_the_users_not_the_machines() {
554        let s = Schedule::parse("0 7 * * *").unwrap();
555        let at = utc("2026-08-05T00:00:00Z");
556        let in_ny = s.next_after(at, ny()).unwrap();
557        let in_utc = s.next_after(at, chrono_tz::UTC).unwrap();
558        assert_ne!(in_ny, in_utc, "07:00 is a wall-clock claim, not an instant");
559        assert_eq!(in_utc.to_rfc3339(), "2026-08-05T07:00:00+00:00");
560        assert_eq!(in_ny.to_rfc3339(), "2026-08-05T11:00:00+00:00");
561    }
562
563    #[test]
564    fn a_schedule_round_trips_through_serde_as_what_the_user_typed() {
565        let s = Schedule::parse("*/15 9-17 * * mon-fri").unwrap();
566        let toml = toml::to_string(&serde_json::json!({"schedule": s.clone()})).unwrap();
567        assert!(
568            toml.contains(r#"schedule = "*/15 9-17 * * mon-fri""#),
569            "{toml}"
570        );
571        let back: Schedule = serde_json::from_str(r#""*/15 9-17 * * mon-fri""#).unwrap();
572        assert_eq!(back, s);
573        assert!(serde_json::from_str::<Schedule>(r#""nonsense""#).is_err());
574    }
575}