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, 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    pub fn next_after(&mut self, after: DateTime<Utc>) -> Option<DateTime<Utc>> {
68        self.ensure_parsed();
69        self.parsed
70            .as_ref()
71            .and_then(|s| s.after(&after).next())
72    }
73
74    /// Approximate interval between consecutive fires, used for jitter sizing.
75    pub fn approx_interval_seconds(&mut self) -> i64 {
76        let now = Utc::now();
77        self.ensure_parsed();
78        let Some(sched) = self.parsed.as_ref() else {
79            return 3600;
80        };
81        let mut iter = sched.after(&now);
82        let Some(a) = iter.next() else { return 3600 };
83        let Some(b) = iter.next() else { return 3600 };
84        (b - a).num_seconds().max(60)
85    }
86
87    fn ensure_parsed(&mut self) {
88        if self.parsed.is_none() {
89            let six = format!("0 {}", self.expr);
90            self.parsed = cron::Schedule::from_str(&six).ok();
91        }
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn parses_every_5_min() {
101        let e = CronExpr::parse("*/5 * * * *").unwrap();
102        assert_eq!(e.as_str(), "*/5 * * * *");
103    }
104
105    #[test]
106    fn parses_weekday_morning() {
107        CronExpr::parse("0 9 * * 1-5").unwrap();
108    }
109
110    #[test]
111    fn rejects_wrong_field_count() {
112        let err = CronExpr::parse("0 9 * *").unwrap_err();
113        assert!(err.contains("5 fields"), "got: {err}");
114    }
115
116    #[test]
117    fn rejects_l_syntax() {
118        let err = CronExpr::parse("0 9 L * *").unwrap_err();
119        assert!(err.contains("unsupported character"), "got: {err}");
120    }
121
122    #[test]
123    fn rejects_name_alias() {
124        let err = CronExpr::parse("0 9 * * MON").unwrap_err();
125        assert!(err.contains("unsupported character"), "got: {err}");
126    }
127
128    #[test]
129    fn next_after_advances() {
130        let mut e = CronExpr::parse("*/5 * * * *").unwrap();
131        let now = Utc::now();
132        let nxt = e.next_after(now).unwrap();
133        assert!(nxt > now);
134        assert!((nxt - now).num_minutes() <= 5);
135    }
136}