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()
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 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 pub fn as_str(&self) -> &str {
63 &self.expr
64 }
65
66 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 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 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}