deepseek/agent/scheduler/
cron.rs1use std::str::FromStr;
9
10use chrono::{DateTime, 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>> {
68 self.ensure_parsed();
69 self.parsed
70 .as_ref()
71 .and_then(|s| s.after(&after).next())
72 }
73
74 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}