qrush_engine/cron/
cron_parser.rs

1// /src/cron/cron_parser.rs
2use anyhow::{anyhow, Context, Result};
3use chrono::{DateTime, Datelike, TimeZone, Timelike, Utc};
4use chrono_tz::Tz;
5use std::collections::HashSet;
6use std::str::FromStr;
7
8/// Full-featured cron parser (no external cron crate).
9/// Supports:
10/// - 6-field:  `sec  min  hour  dom  mon  dow`
11/// - 5-field:  `min  hour  dom  mon  dow`   (auto-seconds = 0)
12///
13/// Tokens per field:
14/// ```text
15/// *         -> any
16/// a         -> exact
17/// a,b,c     -> list
18/// a-b       -> range inclusive
19/// */n       -> step over full range
20/// a-b/n     -> stepped range
21/// Names:
22///   Months:  JAN..DEC
23///   Weekdays: SUN,MON,TUE,WED,THU,FRI,SAT  (0/7 = SUN)
24/// ```
25pub struct CronParser;
26
27#[derive(Debug, Clone)]
28struct CronSpec {
29    sec:  Field,
30    min:  Field,
31    hour: Field,
32    dom:  FieldDomDow, // day-of-month (1..31) with "any" info
33    mon:  Field,
34    dow:  FieldDomDow, // day-of-week (0..6, 0/7 = Sun) with "any" info
35}
36
37#[derive(Debug, Clone)]
38struct Field {
39    allowed: HashSet<u32>, // empty => Any
40    min: u32,
41    max: u32,
42}
43
44#[derive(Debug, Clone)]
45#[allow(dead_code)]
46struct FieldDomDow {
47    allowed: HashSet<u32>, // empty => Any
48    min: u32,
49    max: u32,
50    any: bool,
51}
52
53impl CronParser {
54    /// `cron_expr`: 5 or 6 fields (see header).
55    /// `from_utc` : baseline (exclusive) instant in UTC; next > from returned.
56    /// `tz_str`   : IANA timezone string, e.g. "UTC", "Asia/Kolkata".
57    pub fn next_execution(cron_expr: &str, from_utc: DateTime<Utc>, tz_str: &str) -> Result<DateTime<Utc>> {
58        let tz: Tz = tz_str.parse().unwrap_or(chrono_tz::UTC);
59
60        // Normalize to 6 fields if 5 are provided (insert seconds=0 at start)
61        let expr = normalize_to_six(cron_expr);
62        let spec = CronSpec::parse(&expr)
63            .with_context(|| format!("Invalid cron expression: {}", cron_expr))?;
64
65        // Work in local tz for correct wall-clock semantics
66        // Start strictly AFTER 'from'
67        let mut dt = from_utc.with_timezone(&tz) + chrono::Duration::seconds(1);
68
69        // Hard upper bound to avoid infinite loops: advance up to 5 years
70        let end_limit = dt + chrono::Duration::days(366 * 5);
71
72        // Outer loop: year-month-day alignment
73        loop {
74            if dt > end_limit {
75                return Err(anyhow!("Could not find next occurrence within 5 years"));
76            }
77
78            // 1) MONTH
79            if !spec.mon.matches(dt.month()) {
80                if let Some(next_m) = spec.mon.next_ge(dt.month()) {
81                    // same year
82                    if next_m != dt.month() {
83                        // bump month, reset lower units
84                        dt = set_ymd_hms(&tz, dt.year(), next_m, 1, 0, 0, 0)?;
85                    }
86                } else {
87                    // move to earliest allowed month next year
88                    let first_m = spec.mon.first().unwrap_or(1);
89                    dt = set_ymd_hms(&tz, dt.year() + 1, first_m, 1, 0, 0, 0)?;
90                }
91            }
92
93            // 2) DAY (DOM/DOW with OR)
94            if !spec.matches_day(&dt) {
95                // advance day by 1 until matching day (or month/year rolls)
96                dt = dt + chrono::Duration::days(1);
97                dt = set_hms(&tz, dt, 0, 0, 0)?;
98                continue; // re-check month/day constraints on new date
99            }
100
101            // 3) HOUR
102            if !spec.hour.matches(dt.hour()) {
103                if let Some(next_h) = spec.hour.next_ge(dt.hour()) {
104                    dt = set_hms(&tz, dt, next_h, 0, 0)?;
105                } else {
106                    // Next allowed hour is in next day
107                    dt = dt + chrono::Duration::days(1);
108                    dt = set_hms(&tz, dt, spec.hour.first().unwrap_or(0), 0, 0)?;
109                    continue; // day may change -> re-run month/day checks
110                }
111            }
112
113            // 4) MINUTE
114            if !spec.min.matches(dt.minute()) {
115                if let Some(next_min) = spec.min.next_ge(dt.minute()) {
116                    dt = set_hms(&tz, dt, dt.hour(), next_min, 0)?;
117                } else {
118                    // bump hour
119                    if let Some(next_h) = spec.hour.next_gt(dt.hour()) {
120                        dt = set_hms(&tz, dt, next_h, spec.min.first().unwrap_or(0), 0)?;
121                    } else {
122                        // next day at first hour/min
123                        dt = dt + chrono::Duration::days(1);
124                        dt = set_hms(
125                            &tz,
126                            dt,
127                            spec.hour.first().unwrap_or(0),
128                            spec.min.first().unwrap_or(0),
129                            0,
130                        )?;
131                    }
132                    continue; // hour/day may change -> re-check
133                }
134            }
135
136            // 5) SECOND
137            if !spec.sec.matches(dt.second()) {
138                if let Some(next_s) = spec.sec.next_ge(dt.second()) {
139                    dt = set_hms(&tz, dt, dt.hour(), dt.minute(), next_s)?;
140                } else {
141                    // bump minute
142                    if let Some(next_min) = spec.min.next_gt(dt.minute()) {
143                        dt = set_hms(&tz, dt, dt.hour(), next_min, spec.sec.first().unwrap_or(0))?;
144                    } else if let Some(next_h) = spec.hour.next_gt(dt.hour()) {
145                        dt = set_hms(&tz, dt, next_h, spec.min.first().unwrap_or(0), spec.sec.first().unwrap_or(0))?;
146                    } else {
147                        // next day at first hour/min/sec
148                        dt = dt + chrono::Duration::days(1);
149                        dt = set_hms(
150                            &tz,
151                            dt,
152                            spec.hour.first().unwrap_or(0),
153                            spec.min.first().unwrap_or(0),
154                            spec.sec.first().unwrap_or(0),
155                        )?;
156                    }
157                    continue; // minute/hour/day may change -> re-check
158                }
159            }
160
161            // All constraints satisfied
162            return Ok(dt.with_timezone(&Utc));
163        }
164    }
165}
166
167// --------- CronSpec parsing & matching ----------
168
169impl CronSpec {
170    fn parse(expr6: &str) -> Result<Self> {
171        let parts: Vec<&str> = expr6.split_whitespace().collect();
172        if parts.len() != 6 {
173            return Err(anyhow!("Expected 6 fields: sec min hour dom mon dow"));
174        }
175
176        let sec  = Field::parse(parts[0], 0, 59, None, false)?;
177        let min  = Field::parse(parts[1], 0, 59, None, false)?;
178        let hour = Field::parse(parts[2], 0, 23, None, false)?;
179        let dom  = FieldDomDow::parse_dom(parts[3])?;
180        let mon  = Field::parse(parts[4], 1, 12, Some(&month_name_map()), false)?;
181        let dow  = FieldDomDow::parse_dow(parts[5])?;
182
183        Ok(Self { sec, min, hour, dom, mon, dow })
184    }
185
186    /// DOM/DOW OR logic:
187    /// - If both are Any => accept any day
188    /// - Else day is valid if (DOM matches) OR (DOW matches)
189    fn matches_day(&self, dt: &DateTime<Tz>) -> bool {
190        let dom_any = self.dom.any;
191        let dow_any = self.dow.any;
192
193        let dom_match = self.dom.matches_dom(dt.day());
194        let dow_match = self.dow.matches_dow(dt.weekday().num_days_from_sunday()); // 0=Sun..6=Sat
195
196        match (dom_any, dow_any) {
197            (true,  true)  => true,
198            (false, true)  => dom_match,
199            (true,  false) => dow_match,
200            (false, false) => dom_match || dow_match,
201        }
202    }
203}
204
205impl Field {
206    fn parse(token: &str, min: u32, max: u32, names: Option<&std::collections::HashMap<&'static str, u32>>, is_dow: bool) -> Result<Self> {
207        let mut allowed = HashSet::new();
208
209        // Empty or "*" => Any
210        if token.trim() == "*" {
211            return Ok(Self { allowed, min, max });
212        }
213
214        for part in token.split(',') {
215            let part = part.trim();
216            if part.is_empty() { continue; }
217
218            // Handle names
219            let mut part = if let Some(map) = names {
220                // replace names with numbers (case-insensitive)
221                let upper = part.to_ascii_uppercase();
222                if let Some(&num) = map.get(upper.as_str()) {
223                    num.to_string()
224                } else {
225                    part.to_string()
226                }
227            } else {
228                part.to_string()
229            };
230
231            // For DOW: allow 7 => 0 (Sunday)
232            if is_dow && part == "7" {
233                part = "0".to_string();
234            }
235
236            // Step forms: "*/n" or "a-b/n"
237            if let Some((lhs, step_s)) = part.split_once('/') {
238                let step = parse_u(lhs, step_s, min, max)?; // parse step, lhs can be "*" or "a-b"
239                if lhs == "*" {
240                    for v in (min..=max).step_by(step as usize) {
241                        allowed.insert(v);
242                    }
243                } else if let Some((a_s, b_s)) = lhs.split_once('-') {
244                    let a = parse_num(a_s, min, max, names, is_dow)?;
245                    let b = parse_num(b_s, min, max, names, is_dow)?;
246                    let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
247                    for v in (lo..=hi).step_by(step as usize) {
248                        allowed.insert(v);
249                    }
250                } else {
251                    return Err(anyhow!("Invalid stepped token '{}'", part));
252                }
253                continue;
254            }
255
256            // "*/n"
257            if let Some(step_s) = part.strip_prefix("*/") {
258                let step: u32 = step_s.parse().context("Invalid step")?;
259                for v in (min..=max).step_by(step as usize) {
260                    allowed.insert(v);
261                }
262                continue;
263            }
264
265            // "a-b"
266            if let Some((a_s, b_s)) = part.split_once('-') {
267                let a = parse_num(a_s, min, max, names, is_dow)?;
268                let b = parse_num(b_s, min, max, names, is_dow)?;
269                let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
270                for v in lo..=hi {
271                    allowed.insert(v);
272                }
273                continue;
274            }
275
276            // single number
277            let n = parse_num(&part, min, max, names, is_dow)?;
278            allowed.insert(n);
279        }
280
281        Ok(Self { allowed, min, max })
282    }
283
284    #[inline]
285    fn matches(&self, v: u32) -> bool {
286        if self.allowed.is_empty() { return true; }
287        self.allowed.contains(&v)
288    }
289
290    #[inline]
291    fn first(&self) -> Option<u32> {
292        if self.allowed.is_empty() { return Some(self.min); }
293        self.allowed.iter().cloned().min()
294    }
295
296    #[inline]
297    fn next_ge(&self, v: u32) -> Option<u32> {
298        if self.allowed.is_empty() {
299            if v < self.min { return Some(self.min); }
300            if v > self.max { return None; }
301            return Some(v);
302        }
303        let mut cand: Option<u32> = None;
304        for &x in &self.allowed {
305            if x >= v {
306                cand = Some(match cand {
307                    Some(c) => c.min(x),
308                    None => x,
309                });
310            }
311        }
312        if cand.is_none() {
313            // wrap not allowed here; caller handles carry
314        }
315        cand
316    }
317
318    #[inline]
319    fn next_gt(&self, v: u32) -> Option<u32> {
320        if self.allowed.is_empty() {
321            if v < self.max { return Some(v + 1); }
322            return None;
323        }
324        let mut cand: Option<u32> = None;
325        for &x in &self.allowed {
326            if x > v {
327                cand = Some(match cand {
328                    Some(c) => c.min(x),
329                    None => x,
330                });
331            }
332        }
333        cand
334    }
335}
336
337impl FieldDomDow {
338    fn parse_dom(token: &str) -> Result<Self> {
339        let base = Field::parse(token, 1, 31, None, false)?;
340        Ok(Self { any: base.allowed.is_empty(), allowed: base.allowed, min: 1, max: 31 })
341    }
342    fn parse_dow(token: &str) -> Result<Self> {
343        let base = Field::parse(token, 0, 6, Some(&weekday_name_map()), true)?;
344        Ok(Self { any: base.allowed.is_empty(), allowed: base.allowed, min: 0, max: 6 })
345    }
346    #[inline]
347    fn matches_dom(&self, day: u32) -> bool {
348        if self.any { return true; }
349        self.allowed.contains(&day)
350    }
351    #[inline]
352    fn matches_dow(&self, dow0sun: u32) -> bool {
353        if self.any { return true; }
354        self.allowed.contains(&dow0sun)
355    }
356}
357
358// --------- helpers ----------
359
360fn normalize_to_six(expr: &str) -> String {
361    let parts: Vec<&str> = expr.split_whitespace().collect();
362    match parts.len() {
363        5 => format!("0 {}", expr.trim()),
364        _ => expr.trim().to_string(),
365    }
366}
367
368fn set_ymd_hms(tz: &Tz, y: i32, m: u32, d: u32, h: u32, min: u32, s: u32) -> Result<DateTime<Tz>> {
369    tz.with_ymd_and_hms(y, m, d, h, min, s)
370        .single()
371        .ok_or_else(|| anyhow!("Invalid local time (DST gap/overlap): {y}-{m}-{d} {h}:{min}:{s}"))
372}
373
374fn set_hms(tz: &Tz, dt: DateTime<Tz>, h: u32, m: u32, s: u32) -> Result<DateTime<Tz>> {
375    set_ymd_hms(tz, dt.year(), dt.month(), dt.day(), h, m, s)
376}
377
378fn parse_u(lhs: &str, step: &str, _min: u32, _max: u32) -> Result<usize> {
379    if !lhs.is_empty() && lhs != "*" && !lhs.contains('-') {
380        return Err(anyhow!("Invalid stepped lhs '{}'", lhs));
381    }
382    let st: u32 = step.parse().context("Invalid step value")?;
383    if st == 0 { return Err(anyhow!("Step must be > 0")); }
384    Ok(st as usize)
385}
386
387fn parse_num(token: &str, min: u32, max: u32, names: Option<&std::collections::HashMap<&'static str, u32>>, is_dow: bool) -> Result<u32> {
388    let t = token.trim();
389    // names (already handled in Field::parse for ranges/steps/lists), but single could still be a name
390    if let Some(map) = names {
391        let up = t.to_ascii_uppercase();
392        if let Some(&n) = map.get(up.as_str()) {
393            return Ok(n);
394        }
395    }
396    let mut n: u32 = u32::from_str(t).context(format!("Invalid number '{}'", t))?;
397    if is_dow && n == 7 { n = 0; } // 7 => 0 (Sunday)
398    if n < min || n > max {
399        return Err(anyhow!("Value {} out of range {}..{}", n, min, max));
400    }
401    Ok(n)
402}
403
404fn month_name_map() -> std::collections::HashMap<&'static str, u32> {
405    use std::iter::FromIterator;
406    std::collections::HashMap::from_iter([
407        ("JAN", 1), ("FEB", 2), ("MAR", 3), ("APR", 4), ("MAY", 5), ("JUN", 6),
408        ("JUL", 7), ("AUG", 8), ("SEP", 9), ("OCT", 10), ("NOV", 11), ("DEC", 12),
409    ])
410}
411
412fn weekday_name_map() -> std::collections::HashMap<&'static str, u32> {
413    use std::iter::FromIterator;
414    // 0=SUN .. 6=SAT
415    std::collections::HashMap::from_iter([
416        ("SUN", 0), ("MON", 1), ("TUE", 2), ("WED", 3),
417        ("THU", 4), ("FRI", 5), ("SAT", 6),
418    ])
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use chrono::TimeZone;
425
426    #[test]
427    fn test_minutely_5field_defaults_seconds0() {
428        let from = Utc.with_ymd_and_hms(2025, 9, 26, 10, 0, 10).unwrap();
429        let tz = "UTC";
430        let next = CronParser::next_execution("*/1 * * * *", from, tz).unwrap();
431        assert_eq!(next, Utc.with_ymd_and_hms(2025, 9, 26, 10, 1, 0).unwrap());
432    }
433
434    #[test]
435    fn test_every_5_minutes_6field() {
436        let tz = "Asia/Kolkata";
437        let from = Utc.with_ymd_and_hms(2025, 9, 26, 10, 2, 30).unwrap();
438        let next = CronParser::next_execution("0 */5 * * * *", from, tz).unwrap();
439        assert!(next > from);
440    }
441
442    #[test]
443    fn test_named_month_and_weekday() {
444        let tz = "UTC";
445        let from = Utc.with_ymd_and_hms(2025, 1, 30, 23, 59, 59).unwrap();
446        // First MON in FEB at 09:00:00
447        let next = CronParser::next_execution("0 0 9 1-7 FEB MON", from, tz).unwrap();
448        assert!(next > from);
449    }
450
451    #[test]
452    fn test_dow_sunday_0_or_7() {
453        let tz = "UTC";
454        let from = Utc.with_ymd_and_hms(2025, 9, 26, 10, 0, 0).unwrap(); // Friday
455        let n0 = CronParser::next_execution("0 0 * * * 0", from, tz).unwrap();
456        let n7 = CronParser::next_execution("0 0 * * * 7", from, tz).unwrap();
457        assert_eq!(n0, n7);
458    }
459}