deepseek/agent/scheduler/
cron.rs1use std::str::FromStr;
9
10use chrono::{DateTime, Local, Utc};
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct CronExpr {
15 expr: String,
17 #[serde(skip)]
19 parsed: Option<cron::Schedule>,
20}
21
22impl CronExpr {
23 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 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 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 pub fn as_str(&self) -> &str {
62 &self.expr
63 }
64
65 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 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 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 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 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 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 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 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 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 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 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 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 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}