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()
40                    || matches!(ch, '*' | '/' | ',' | '-');
41                if !ok {
42                    return Err(format!(
43                        "cron: unsupported character '{ch}' in field '{f}' \
44                         (extended syntax like L/W/?/MON/JAN is not supported)"
45                    ));
46                }
47            }
48        }
49
50        // Prepend `0 ` for seconds — the `cron` crate uses 6-field internally.
51        let six = format!("0 {trimmed}");
52        let parsed = cron::Schedule::from_str(&six)
53            .map_err(|e| format!("cron: parse error: {e}"))?;
54
55        Ok(Self {
56            expr: trimmed.to_string(),
57            parsed: Some(parsed),
58        })
59    }
60
61    /// The original expression, e.g. `"*/5 * * * *"`.
62    pub fn as_str(&self) -> &str {
63        &self.expr
64    }
65
66    /// Compute the next fire time strictly after `after`.
67    ///
68    /// Cron expressions are interpreted in the **host's local timezone**
69    /// (matching the Claude Code `/loop` spec). The returned fire time is
70    /// stored as `DateTime<Utc>` so persisted task records stay timezone-
71    /// independent.
72    pub fn next_after(&mut self, after: DateTime<Utc>) -> Option<DateTime<Utc>> {
73        self.ensure_parsed();
74        let local_after: DateTime<Local> = after.with_timezone(&Local);
75        self.parsed
76            .as_ref()
77            .and_then(|s| s.after(&local_after).next())
78            .map(|t| t.with_timezone(&Utc))
79    }
80
81    /// Approximate interval between consecutive fires, used for jitter sizing.
82    pub fn approx_interval_seconds(&mut self) -> i64 {
83        let now: DateTime<Local> = Local::now();
84        self.ensure_parsed();
85        let Some(sched) = self.parsed.as_ref() else {
86            return 3600;
87        };
88        let mut iter = sched.after(&now);
89        let Some(a) = iter.next() else { return 3600 };
90        let Some(b) = iter.next() else { return 3600 };
91        (b - a).num_seconds().max(60)
92    }
93
94    fn ensure_parsed(&mut self) {
95        if self.parsed.is_none() {
96            let six = format!("0 {}", self.expr);
97            self.parsed = cron::Schedule::from_str(&six).ok();
98        }
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn parses_every_5_min() {
108        let e = CronExpr::parse("*/5 * * * *").unwrap();
109        assert_eq!(e.as_str(), "*/5 * * * *");
110    }
111
112    #[test]
113    fn parses_weekday_morning() {
114        CronExpr::parse("0 9 * * 1-5").unwrap();
115    }
116
117    #[test]
118    fn rejects_wrong_field_count() {
119        let err = CronExpr::parse("0 9 * *").unwrap_err();
120        assert!(err.contains("5 fields"), "got: {err}");
121    }
122
123    #[test]
124    fn rejects_l_syntax() {
125        let err = CronExpr::parse("0 9 L * *").unwrap_err();
126        assert!(err.contains("unsupported character"), "got: {err}");
127    }
128
129    #[test]
130    fn rejects_name_alias() {
131        let err = CronExpr::parse("0 9 * * MON").unwrap_err();
132        assert!(err.contains("unsupported character"), "got: {err}");
133    }
134
135    #[test]
136    fn next_after_advances() {
137        let mut e = CronExpr::parse("*/5 * * * *").unwrap();
138        let now = Utc::now();
139        let nxt = e.next_after(now).unwrap();
140        assert!(nxt > now);
141        assert!((nxt - now).num_minutes() <= 5);
142    }
143
144    #[test]
145    fn next_after_uses_local_timezone() {
146        // `0 9 * * *` should mean 9 AM **local**, not 9 AM UTC. We verify by
147        // converting the returned UTC fire time to local time and confirming
148        // the local hour is 9.
149        use chrono::{Local, Timelike};
150        let mut e = CronExpr::parse("0 9 * * *").unwrap();
151        let now = Utc::now();
152        let nxt_utc = e.next_after(now).expect("should have a next fire");
153        let nxt_local: DateTime<Local> = nxt_utc.with_timezone(&Local);
154        assert_eq!(
155            nxt_local.hour(),
156            9,
157            "next fire should be at 9 AM local; got {nxt_local} (UTC {nxt_utc})"
158        );
159        assert_eq!(nxt_local.minute(), 0);
160    }
161}