Skip to main content

deepseek/agent/scheduler/
cron.rs

1//! 5-field cron grammar wrapper.
2//!
3//! Wraps the [`cron`] crate with stricter parsing: only standard 5-field
4//! `minute hour day-of-month month day-of-week` expressions are accepted.
5//! Extended syntax (`L`, `W`, `?`, name aliases like `MON`/`JAN`) is rejected
6//! to match the Claude Code spec.
7
8use std::str::FromStr;
9
10use chrono::{DateTime, Local, Utc};
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct CronExpr {
15    /// The original 5-field expression, exactly as the user provided it.
16    expr: String,
17    /// 6-field form (`0 <expr>`) cached so we don't re-parse on every fire.
18    #[serde(skip)]
19    parsed: Option<cron::Schedule>,
20}
21
22impl CronExpr {
23    /// Parse a 5-field cron expression. Rejects extended syntax.
24    pub fn parse(input: &str) -> Result<Self, String> {
25        let trimmed = input.trim();
26        let fields: Vec<&str> = trimmed.split_whitespace().collect();
27        if fields.len() != 5 {
28            return Err(format!(
29                "cron: expected 5 fields (min hour dom month dow), got {}",
30                fields.len()
31            ));
32        }
33
34        // Reject extended syntax. The `cron` crate accepts `L`/`W`/`?` and
35        // name aliases (`MON`, `JAN`); the Claude Code spec doesn't, and it's
36        // simpler to reject than to whitelist.
37        for f in &fields {
38            for ch in f.chars() {
39                let ok = ch.is_ascii_digit() || matches!(ch, '*' | '/' | ',' | '-');
40                if !ok {
41                    return Err(format!(
42                        "cron: unsupported character '{ch}' in field '{f}' \
43                         (extended syntax like L/W/?/MON/JAN is not supported)"
44                    ));
45                }
46            }
47        }
48
49        // Prepend `0 ` for seconds — the `cron` crate uses 6-field internally.
50        let six = format!("0 {trimmed}");
51        let parsed =
52            cron::Schedule::from_str(&six).map_err(|e| format!("cron: parse error: {e}"))?;
53
54        Ok(Self {
55            expr: trimmed.to_string(),
56            parsed: Some(parsed),
57        })
58    }
59
60    /// The original expression, e.g. `"*/5 * * * *"`.
61    pub fn as_str(&self) -> &str {
62        &self.expr
63    }
64
65    /// Compute the next fire time strictly after `after`.
66    ///
67    /// Cron expressions are interpreted in the **host's local timezone**
68    /// (matching the Claude Code `/loop` spec). The returned fire time is
69    /// stored as `DateTime<Utc>` so persisted task records stay timezone-
70    /// independent.
71    pub fn next_after(&mut self, after: DateTime<Utc>) -> Option<DateTime<Utc>> {
72        self.ensure_parsed();
73        let local_after: DateTime<Local> = after.with_timezone(&Local);
74        self.parsed
75            .as_ref()
76            .and_then(|s| s.after(&local_after).next())
77            .map(|t| t.with_timezone(&Utc))
78    }
79
80    /// Approximate interval between consecutive fires, used for jitter sizing.
81    pub fn approx_interval_seconds(&mut self) -> i64 {
82        let now: DateTime<Local> = Local::now();
83        self.ensure_parsed();
84        let Some(sched) = self.parsed.as_ref() else {
85            return 3600;
86        };
87        let mut iter = sched.after(&now);
88        let Some(a) = iter.next() else { return 3600 };
89        let Some(b) = iter.next() else { return 3600 };
90        (b - a).num_seconds().max(60)
91    }
92
93    fn ensure_parsed(&mut self) {
94        if self.parsed.is_none() {
95            let six = format!("0 {}", self.expr);
96            self.parsed = cron::Schedule::from_str(&six).ok();
97        }
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn parses_every_5_min() {
107        let e = CronExpr::parse("*/5 * * * *").unwrap();
108        assert_eq!(e.as_str(), "*/5 * * * *");
109    }
110
111    #[test]
112    fn parses_weekday_morning() {
113        CronExpr::parse("0 9 * * 1-5").unwrap();
114    }
115
116    #[test]
117    fn rejects_wrong_field_count() {
118        let err = CronExpr::parse("0 9 * *").unwrap_err();
119        assert!(err.contains("5 fields"), "got: {err}");
120    }
121
122    #[test]
123    fn rejects_l_syntax() {
124        let err = CronExpr::parse("0 9 L * *").unwrap_err();
125        assert!(err.contains("unsupported character"), "got: {err}");
126    }
127
128    #[test]
129    fn rejects_name_alias() {
130        let err = CronExpr::parse("0 9 * * MON").unwrap_err();
131        assert!(err.contains("unsupported character"), "got: {err}");
132    }
133
134    #[test]
135    fn next_after_advances() {
136        let mut e = CronExpr::parse("*/5 * * * *").unwrap();
137        let now = Utc::now();
138        let nxt = e.next_after(now).unwrap();
139        assert!(nxt > now);
140        assert!((nxt - now).num_minutes() <= 5);
141    }
142
143    #[test]
144    fn next_after_uses_local_timezone() {
145        // `0 9 * * *` should mean 9 AM **local**, not 9 AM UTC. We verify by
146        // converting the returned UTC fire time to local time and confirming
147        // the local hour is 9.
148        use chrono::{Local, Timelike};
149        let mut e = CronExpr::parse("0 9 * * *").unwrap();
150        let now = Utc::now();
151        let nxt_utc = e.next_after(now).expect("should have a next fire");
152        let nxt_local: DateTime<Local> = nxt_utc.with_timezone(&Local);
153        assert_eq!(
154            nxt_local.hour(),
155            9,
156            "next fire should be at 9 AM local; got {nxt_local} (UTC {nxt_utc})"
157        );
158        assert_eq!(nxt_local.minute(), 0);
159    }
160
161    #[test]
162    fn parses_step_with_range() {
163        // `*/15` is the most common step shape; verify it parses end-to-end.
164        let e = CronExpr::parse("*/15 * * * *").unwrap();
165        assert_eq!(e.as_str(), "*/15 * * * *");
166    }
167
168    #[test]
169    fn parses_explicit_list() {
170        let e = CronExpr::parse("0,15,30,45 * * * *").unwrap();
171        assert_eq!(e.as_str(), "0,15,30,45 * * * *");
172    }
173
174    #[test]
175    fn parses_range_in_dow() {
176        // weekday range — already covered for parse, here we also fire it.
177        let mut e = CronExpr::parse("0 9 * * 1-5").unwrap();
178        let now = Utc::now();
179        let nxt = e.next_after(now).expect("should have a next fire");
180        assert!(nxt > now);
181    }
182
183    #[test]
184    fn parses_with_surrounding_whitespace() {
185        // Trim should make leading/trailing whitespace harmless.
186        let e = CronExpr::parse("   */5 * * * *   ").unwrap();
187        assert_eq!(e.as_str(), "*/5 * * * *");
188    }
189
190    #[test]
191    fn rejects_empty_string() {
192        let err = CronExpr::parse("").unwrap_err();
193        assert!(err.contains("5 fields"), "got: {err}");
194    }
195
196    #[test]
197    fn rejects_too_many_fields() {
198        let err = CronExpr::parse("0 0 9 * * *").unwrap_err();
199        assert!(err.contains("5 fields"), "got: {err}");
200    }
201
202    #[test]
203    fn rejects_w_syntax() {
204        let err = CronExpr::parse("0 9 1W * *").unwrap_err();
205        assert!(err.contains("unsupported character"), "got: {err}");
206    }
207
208    #[test]
209    fn rejects_question_mark() {
210        let err = CronExpr::parse("0 9 ? * *").unwrap_err();
211        assert!(err.contains("unsupported character"), "got: {err}");
212    }
213
214    #[test]
215    fn rejects_month_alias() {
216        let err = CronExpr::parse("0 9 1 JAN *").unwrap_err();
217        assert!(err.contains("unsupported character"), "got: {err}");
218    }
219
220    #[test]
221    fn rejects_garbage_numbers() {
222        // 5-field shape passes the field-count check but fails the underlying
223        // cron parse. The character whitelist would actually allow this through
224        // (digits + `-`), so it must surface a "parse error".
225        let err = CronExpr::parse("99 99 99 99 99").unwrap_err();
226        assert!(err.contains("parse error"), "got: {err}");
227    }
228
229    #[test]
230    fn next_after_is_strictly_monotonic() {
231        // Three consecutive next_after calls must return strictly increasing
232        // times. This guards against an off-by-one where `after` is treated
233        // inclusively.
234        let mut e = CronExpr::parse("*/5 * * * *").unwrap();
235        let t0 = Utc::now();
236        let t1 = e.next_after(t0).unwrap();
237        let t2 = e.next_after(t1).unwrap();
238        let t3 = e.next_after(t2).unwrap();
239        assert!(t1 > t0);
240        assert!(t2 > t1);
241        assert!(t3 > t2);
242        // And the gap between consecutive fires for `*/5` is exactly 5 min.
243        assert_eq!((t2 - t1).num_minutes(), 5);
244        assert_eq!((t3 - t2).num_minutes(), 5);
245    }
246
247    #[test]
248    fn approx_interval_for_every_5_min() {
249        let mut e = CronExpr::parse("*/5 * * * *").unwrap();
250        assert_eq!(e.approx_interval_seconds(), 300);
251    }
252
253    #[test]
254    fn approx_interval_for_hourly() {
255        let mut e = CronExpr::parse("0 * * * *").unwrap();
256        assert_eq!(e.approx_interval_seconds(), 3600);
257    }
258
259    #[test]
260    fn approx_interval_for_daily() {
261        let mut e = CronExpr::parse("0 0 * * *").unwrap();
262        assert_eq!(e.approx_interval_seconds(), 86_400);
263    }
264
265    #[test]
266    fn approx_interval_floored_at_60s() {
267        // `*/1 * * * *` — every minute → 60s. The `.max(60)` floor keeps
268        // sub-minute schedulers (which we don't actually parse) safe.
269        let mut e = CronExpr::parse("*/1 * * * *").unwrap();
270        assert_eq!(e.approx_interval_seconds(), 60);
271    }
272
273    #[test]
274    fn serde_round_trip_preserves_expr() {
275        // CronExpr derives Serialize/Deserialize and skips `parsed`. A round
276        // trip through JSON must preserve the original expression and keep
277        // `next_after` working after deserialization.
278        let original = CronExpr::parse("*/15 * * * *").unwrap();
279        let json = serde_json::to_string(&original).unwrap();
280        let mut decoded: CronExpr = serde_json::from_str(&json).unwrap();
281        assert_eq!(decoded.as_str(), "*/15 * * * *");
282        // ensure_parsed should re-hydrate the schedule lazily.
283        let now = Utc::now();
284        let nxt = decoded
285            .next_after(now)
286            .expect("should fire after rehydrate");
287        assert!(nxt > now);
288    }
289
290    #[test]
291    fn clone_does_not_share_parsed_cache() {
292        // CronExpr is Clone; the clone should still work even though `parsed`
293        // is `#[serde(skip)]` and might be `None` after a round-trip.
294        let original = CronExpr::parse("*/5 * * * *").unwrap();
295        let mut cloned = original.clone();
296        let now = Utc::now();
297        assert!(cloned.next_after(now).is_some());
298    }
299}