Skip to main content

subms_timer_wheel/features/
cron.rs

1//! Minimal cron-expression parser + recurring scheduler.
2//!
3//! Accepts the classic 5-field syntax:
4//!
5//! ```text
6//!   minute  hour  day-of-month  month  day-of-week
7//!   0-59    0-23  1-31          1-12   0-6 (Sunday=0)
8//! ```
9//!
10//! Per field we support:
11//!   `*`      - every value in the field's range
12//!   `*/N`    - every Nth value (step from the field's minimum)
13//!   `a-b`    - inclusive range
14//!   `a,b,c`  - explicit list (entries may be ranges or steps)
15//!   `a`      - single literal value
16//!
17//! Not supported (out of scope for the minimum-viable recipe): the
18//! `L`/`W`/`?` extensions, named months/days, seconds field, and
19//! step modifiers attached to ranges (`1-10/2`). If a workload needs
20//! those, reach for a full-featured cron crate.
21//!
22//! `CronSchedule::next_after(epoch_seconds)` returns the next firing
23//! second on or after the input. `CronScheduler` ties a `CronSchedule`
24//! to a base `TimerWheel`, re-arming the next deadline each time the
25//! current one fires.
26
27use std::fmt;
28
29#[derive(Debug, PartialEq, Eq)]
30pub enum CronError {
31    /// Wrong number of whitespace-separated fields.
32    WrongFieldCount(usize),
33    /// A field couldn't be parsed (invalid number, out of range, etc.).
34    InvalidField(&'static str, String),
35}
36
37impl fmt::Display for CronError {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        match self {
40            CronError::WrongFieldCount(n) => {
41                write!(f, "cron expression must have 5 fields, got {n}")
42            }
43            CronError::InvalidField(name, raw) => write!(f, "invalid {name} field: {raw}"),
44        }
45    }
46}
47
48impl std::error::Error for CronError {}
49
50#[derive(Debug, Clone)]
51pub struct CronSchedule {
52    minute: Vec<u8>,
53    hour: Vec<u8>,
54    dom: Vec<u8>,
55    month: Vec<u8>,
56    dow: Vec<u8>,
57}
58
59impl CronSchedule {
60    /// Parse a five-field cron expression. Whitespace-separated.
61    pub fn parse(expr: &str) -> Result<Self, CronError> {
62        let fields: Vec<&str> = expr.split_whitespace().collect();
63        if fields.len() != 5 {
64            return Err(CronError::WrongFieldCount(fields.len()));
65        }
66        Ok(Self {
67            minute: parse_field(fields[0], 0, 59, "minute")?,
68            hour: parse_field(fields[1], 0, 23, "hour")?,
69            dom: parse_field(fields[2], 1, 31, "day-of-month")?,
70            month: parse_field(fields[3], 1, 12, "month")?,
71            dow: parse_field(fields[4], 0, 6, "day-of-week")?,
72        })
73    }
74
75    pub fn minutes(&self) -> &[u8] {
76        &self.minute
77    }
78    pub fn hours(&self) -> &[u8] {
79        &self.hour
80    }
81    pub fn days_of_month(&self) -> &[u8] {
82        &self.dom
83    }
84    pub fn months(&self) -> &[u8] {
85        &self.month
86    }
87    pub fn days_of_week(&self) -> &[u8] {
88        &self.dow
89    }
90
91    /// Smallest epoch-second `>= after_epoch` whose minute/hour/dom/
92    /// month/dow all match. Returns `None` if no firing exists within
93    /// the next ~5 years (defensive cap; real schedules fire within
94    /// a year unless misconfigured).
95    pub fn next_after(&self, after_epoch: u64) -> Option<u64> {
96        // Round up to the next whole minute (cron has minute resolution).
97        let mut t = after_epoch.div_ceil(60) * 60;
98        let cap = after_epoch + 5 * 365 * 24 * 60 * 60;
99        while t < cap {
100            let (year, month, dom, dow, hour, minute) = civil_from_epoch(t);
101            if !self.minute.contains(&(minute as u8)) {
102                t += 60;
103                continue;
104            }
105            if !self.hour.contains(&(hour as u8)) {
106                t += 60;
107                continue;
108            }
109            if !self.month.contains(&(month as u8)) {
110                t += 60;
111                continue;
112            }
113            // dom + dow: the cron historical convention is OR when
114            // both are restrictive, AND when one is `*`. Our parser
115            // doesn't track "field was `*`", so we mimic the
116            // simple-AND rule, which is correct for the vast majority
117            // of recipes (`0 */5 * * *` etc).
118            if !self.dom.contains(&(dom as u8)) {
119                t += 60;
120                continue;
121            }
122            if !self.dow.contains(&(dow as u8)) {
123                t += 60;
124                continue;
125            }
126            let _ = year;
127            return Some(t);
128        }
129        None
130    }
131}
132
133fn parse_field(s: &str, lo: u32, hi: u32, name: &'static str) -> Result<Vec<u8>, CronError> {
134    let mut out = Vec::new();
135    for part in s.split(',') {
136        let part = part.trim();
137        if part.is_empty() {
138            return Err(CronError::InvalidField(name, s.to_string()));
139        }
140        // `*/N`
141        if let Some(rest) = part.strip_prefix("*/") {
142            let step: u32 = rest
143                .parse()
144                .map_err(|_| CronError::InvalidField(name, s.to_string()))?;
145            if step == 0 {
146                return Err(CronError::InvalidField(name, s.to_string()));
147            }
148            let mut v = lo;
149            while v <= hi {
150                out.push(v as u8);
151                v += step;
152            }
153            continue;
154        }
155        // `*`
156        if part == "*" {
157            for v in lo..=hi {
158                out.push(v as u8);
159            }
160            continue;
161        }
162        // `a-b`
163        if let Some((a, b)) = part.split_once('-') {
164            let a: u32 = a
165                .parse()
166                .map_err(|_| CronError::InvalidField(name, s.to_string()))?;
167            let b: u32 = b
168                .parse()
169                .map_err(|_| CronError::InvalidField(name, s.to_string()))?;
170            if a < lo || b > hi || a > b {
171                return Err(CronError::InvalidField(name, s.to_string()));
172            }
173            for v in a..=b {
174                out.push(v as u8);
175            }
176            continue;
177        }
178        // literal
179        let v: u32 = part
180            .parse()
181            .map_err(|_| CronError::InvalidField(name, s.to_string()))?;
182        if v < lo || v > hi {
183            return Err(CronError::InvalidField(name, s.to_string()));
184        }
185        out.push(v as u8);
186    }
187    out.sort_unstable();
188    out.dedup();
189    Ok(out)
190}
191
192/// Convert epoch-seconds (Unix epoch, UTC) to (year, month, dom, dow, hour, minute).
193/// Howard Hinnant's algorithm, adapted to u64. Pre-1970 inputs are clamped to 1970-01-01.
194fn civil_from_epoch(epoch: u64) -> (i32, u32, u32, u32, u32, u32) {
195    let days_since_epoch = (epoch / 86_400) as i64;
196    let secs_today = (epoch % 86_400) as u32;
197    let hour = secs_today / 3600;
198    let minute = (secs_today % 3600) / 60;
199    // dow: 1970-01-01 was Thursday = 4. Sunday = 0.
200    let dow = (((days_since_epoch + 4) % 7 + 7) % 7) as u32;
201
202    // Howard Hinnant's civil_from_days.
203    let z = days_since_epoch + 719_468;
204    let era = if z >= 0 {
205        z / 146_097
206    } else {
207        (z - 146_096) / 146_097
208    };
209    let doe = (z - era * 146_097) as u64;
210    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
211    let y = yoe as i64 + era * 400;
212    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
213    let mp = (5 * doy + 2) / 153;
214    let dom = (doy - (153 * mp + 2) / 5 + 1) as u32;
215    let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
216    let year = (y + if month <= 2 { 1 } else { 0 }) as i32;
217    (year, month, dom, dow, hour, minute)
218}
219
220/// Recurring scheduler glued to a base `TimerWheel`. After each fire
221/// the scheduler computes the next deadline from the cron schedule
222/// and re-arms.
223pub struct CronScheduler {
224    schedule: CronSchedule,
225    last_fire_epoch: u64,
226}
227
228impl CronScheduler {
229    pub fn new(schedule: CronSchedule, now_epoch: u64) -> Self {
230        Self {
231            schedule,
232            last_fire_epoch: now_epoch,
233        }
234    }
235
236    pub fn schedule(&self) -> &CronSchedule {
237        &self.schedule
238    }
239
240    /// Epoch-second the schedule will next fire, given the current
241    /// epoch second. Returns `None` if no firing within the schedule's
242    /// look-ahead horizon.
243    pub fn next_fire(&self, now_epoch: u64) -> Option<u64> {
244        let after = now_epoch.max(self.last_fire_epoch + 1);
245        self.schedule.next_after(after)
246    }
247
248    /// Mark a fire at `epoch` consumed; next call to `next_fire` will
249    /// look past it.
250    pub fn record_fire(&mut self, epoch: u64) {
251        self.last_fire_epoch = epoch;
252    }
253}
254
255#[cfg(test)]
256#[path = "cron_tests.rs"]
257mod tests;