Skip to main content

agent_code_lib/schedule/
cron.rs

1//! Minimal cron expression parser.
2//!
3//! Supports standard 5-field cron: `minute hour day-of-month month day-of-week`.
4//! Special values: `*` (any), `*/N` (step), `N-M` (range), `N,M` (list).
5
6use chrono::{Datelike, NaiveDateTime, Timelike};
7
8/// A parsed cron expression.
9#[derive(Debug, Clone)]
10pub struct CronExpr {
11    minutes: FieldSet,
12    hours: FieldSet,
13    days_of_month: FieldSet,
14    months: FieldSet,
15    days_of_week: FieldSet,
16    raw: String,
17}
18
19/// Set of allowed values for one cron field.
20#[derive(Debug, Clone)]
21struct FieldSet {
22    values: Vec<u32>,
23}
24
25impl FieldSet {
26    fn contains(&self, v: u32) -> bool {
27        self.values.contains(&v)
28    }
29
30    fn parse(field: &str, min: u32, max: u32) -> Result<Self, String> {
31        let mut values = Vec::new();
32        for part in field.split(',') {
33            let part = part.trim();
34            if part == "*" {
35                return Ok(Self {
36                    values: (min..=max).collect(),
37                });
38            } else if let Some(step) = part.strip_prefix("*/") {
39                let step: u32 = step.parse().map_err(|_| format!("invalid step: {part}"))?;
40                if step == 0 {
41                    return Err("step cannot be 0".into());
42                }
43                let mut v = min;
44                while v <= max {
45                    values.push(v);
46                    v += step;
47                }
48            } else if part.contains('-') {
49                let parts: Vec<&str> = part.splitn(2, '-').collect();
50                let lo: u32 = parts[0]
51                    .parse()
52                    .map_err(|_| format!("invalid range start: {}", parts[0]))?;
53                let hi: u32 = parts[1]
54                    .parse()
55                    .map_err(|_| format!("invalid range end: {}", parts[1]))?;
56                if lo > hi || lo < min || hi > max {
57                    return Err(format!("range {lo}-{hi} out of bounds ({min}-{max})"));
58                }
59                values.extend(lo..=hi);
60            } else {
61                let v: u32 = part.parse().map_err(|_| format!("invalid value: {part}"))?;
62                if v < min || v > max {
63                    return Err(format!("value {v} out of bounds ({min}-{max})"));
64                }
65                values.push(v);
66            }
67        }
68        values.sort_unstable();
69        values.dedup();
70        Ok(Self { values })
71    }
72}
73
74impl CronExpr {
75    /// Parse a 5-field cron expression.
76    pub fn parse(expr: &str) -> Result<Self, String> {
77        let fields: Vec<&str> = expr.split_whitespace().collect();
78        if fields.len() != 5 {
79            return Err(format!(
80                "expected 5 fields (minute hour dom month dow), got {}",
81                fields.len()
82            ));
83        }
84
85        Ok(Self {
86            minutes: FieldSet::parse(fields[0], 0, 59)?,
87            hours: FieldSet::parse(fields[1], 0, 23)?,
88            days_of_month: FieldSet::parse(fields[2], 1, 31)?,
89            months: FieldSet::parse(fields[3], 1, 12)?,
90            days_of_week: FieldSet::parse(fields[4], 0, 6)?,
91            raw: expr.to_string(),
92        })
93    }
94
95    /// Check whether a given datetime matches this cron expression.
96    pub fn matches(&self, dt: &NaiveDateTime) -> bool {
97        self.minutes.contains(dt.minute())
98            && self.hours.contains(dt.hour())
99            && self.days_of_month.contains(dt.day())
100            && self.months.contains(dt.month())
101            && self
102                .days_of_week
103                .contains(dt.weekday().num_days_from_sunday())
104    }
105
106    /// Return the raw cron string.
107    pub fn as_str(&self) -> &str {
108        &self.raw
109    }
110
111    /// Find the next matching datetime strictly after `after`.
112    ///
113    /// Scans minute-by-minute up to 366 days out. Returns `None` if
114    /// no match is found (e.g. impossible expression like `30 2 30 2 *`).
115    pub fn next_after(&self, after: &NaiveDateTime) -> Option<NaiveDateTime> {
116        // Start from the next whole minute.
117        let mut candidate = *after + chrono::Duration::minutes(1);
118        candidate = candidate.with_second(0)?.with_nanosecond(0)?;
119
120        let limit = *after + chrono::Duration::days(366);
121        while candidate < limit {
122            if self.matches(&candidate) {
123                return Some(candidate);
124            }
125            candidate += chrono::Duration::minutes(1);
126        }
127        None
128    }
129}
130
131impl std::fmt::Display for CronExpr {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        f.write_str(&self.raw)
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use chrono::NaiveDate;
141
142    fn dt(y: i32, m: u32, d: u32, h: u32, min: u32) -> NaiveDateTime {
143        NaiveDate::from_ymd_opt(y, m, d)
144            .unwrap()
145            .and_hms_opt(h, min, 0)
146            .unwrap()
147    }
148
149    #[test]
150    fn test_parse_every_minute() {
151        let expr = CronExpr::parse("* * * * *").unwrap();
152        assert!(expr.matches(&dt(2026, 4, 5, 12, 30)));
153    }
154
155    #[test]
156    fn test_parse_specific_time() {
157        let expr = CronExpr::parse("30 9 * * *").unwrap();
158        assert!(expr.matches(&dt(2026, 4, 5, 9, 30)));
159        assert!(!expr.matches(&dt(2026, 4, 5, 9, 31)));
160        assert!(!expr.matches(&dt(2026, 4, 5, 10, 30)));
161    }
162
163    #[test]
164    fn test_parse_step() {
165        let expr = CronExpr::parse("*/15 * * * *").unwrap();
166        assert!(expr.matches(&dt(2026, 1, 1, 0, 0)));
167        assert!(expr.matches(&dt(2026, 1, 1, 0, 15)));
168        assert!(expr.matches(&dt(2026, 1, 1, 0, 30)));
169        assert!(expr.matches(&dt(2026, 1, 1, 0, 45)));
170        assert!(!expr.matches(&dt(2026, 1, 1, 0, 10)));
171    }
172
173    #[test]
174    fn test_parse_range() {
175        let expr = CronExpr::parse("0 9-17 * * *").unwrap();
176        assert!(expr.matches(&dt(2026, 1, 1, 9, 0)));
177        assert!(expr.matches(&dt(2026, 1, 1, 17, 0)));
178        assert!(!expr.matches(&dt(2026, 1, 1, 8, 0)));
179        assert!(!expr.matches(&dt(2026, 1, 1, 18, 0)));
180    }
181
182    #[test]
183    fn test_parse_list() {
184        let expr = CronExpr::parse("0 9,12,18 * * *").unwrap();
185        assert!(expr.matches(&dt(2026, 1, 1, 9, 0)));
186        assert!(expr.matches(&dt(2026, 1, 1, 12, 0)));
187        assert!(expr.matches(&dt(2026, 1, 1, 18, 0)));
188        assert!(!expr.matches(&dt(2026, 1, 1, 10, 0)));
189    }
190
191    #[test]
192    fn test_day_of_week() {
193        // 2026-04-06 is a Monday (dow=1)
194        let expr = CronExpr::parse("0 9 * * 1").unwrap();
195        assert!(expr.matches(&dt(2026, 4, 6, 9, 0)));
196        assert!(!expr.matches(&dt(2026, 4, 5, 9, 0))); // Sunday
197    }
198
199    #[test]
200    fn test_next_after() {
201        let expr = CronExpr::parse("30 9 * * *").unwrap();
202        let now = dt(2026, 4, 5, 8, 0);
203        let next = expr.next_after(&now).unwrap();
204        assert_eq!(next, dt(2026, 4, 5, 9, 30));
205    }
206
207    #[test]
208    fn test_next_after_wraps_day() {
209        let expr = CronExpr::parse("0 6 * * *").unwrap();
210        let now = dt(2026, 4, 5, 23, 0);
211        let next = expr.next_after(&now).unwrap();
212        assert_eq!(next, dt(2026, 4, 6, 6, 0));
213    }
214
215    #[test]
216    fn test_invalid_field_count() {
217        assert!(CronExpr::parse("* * *").is_err());
218    }
219
220    #[test]
221    fn test_invalid_value() {
222        assert!(CronExpr::parse("60 * * * *").is_err());
223    }
224
225    #[test]
226    fn test_invalid_step_zero() {
227        assert!(CronExpr::parse("*/0 * * * *").is_err());
228    }
229}