1use anyhow::{anyhow, Context, Result};
3use chrono::{DateTime, Datelike, TimeZone, Timelike, Utc};
4use chrono_tz::Tz;
5use std::collections::HashSet;
6use std::str::FromStr;
7
8pub struct CronParser;
26
27#[derive(Debug, Clone)]
28struct CronSpec {
29 sec: Field,
30 min: Field,
31 hour: Field,
32 dom: FieldDomDow, mon: Field,
34 dow: FieldDomDow, }
36
37#[derive(Debug, Clone)]
38struct Field {
39 allowed: HashSet<u32>, min: u32,
41 max: u32,
42}
43
44#[derive(Debug, Clone)]
45#[allow(dead_code)]
46struct FieldDomDow {
47 allowed: HashSet<u32>, min: u32,
49 max: u32,
50 any: bool,
51}
52
53impl CronParser {
54 pub fn next_execution(cron_expr: &str, from_utc: DateTime<Utc>, tz_str: &str) -> Result<DateTime<Utc>> {
58 let tz: Tz = tz_str.parse().unwrap_or(chrono_tz::UTC);
59
60 let expr = normalize_to_six(cron_expr);
62 let spec = CronSpec::parse(&expr)
63 .with_context(|| format!("Invalid cron expression: {}", cron_expr))?;
64
65 let mut dt = from_utc.with_timezone(&tz) + chrono::Duration::seconds(1);
68
69 let end_limit = dt + chrono::Duration::days(366 * 5);
71
72 loop {
74 if dt > end_limit {
75 return Err(anyhow!("Could not find next occurrence within 5 years"));
76 }
77
78 if !spec.mon.matches(dt.month()) {
80 if let Some(next_m) = spec.mon.next_ge(dt.month()) {
81 if next_m != dt.month() {
83 dt = set_ymd_hms(&tz, dt.year(), next_m, 1, 0, 0, 0)?;
85 }
86 } else {
87 let first_m = spec.mon.first().unwrap_or(1);
89 dt = set_ymd_hms(&tz, dt.year() + 1, first_m, 1, 0, 0, 0)?;
90 }
91 }
92
93 if !spec.matches_day(&dt) {
95 dt = dt + chrono::Duration::days(1);
97 dt = set_hms(&tz, dt, 0, 0, 0)?;
98 continue; }
100
101 if !spec.hour.matches(dt.hour()) {
103 if let Some(next_h) = spec.hour.next_ge(dt.hour()) {
104 dt = set_hms(&tz, dt, next_h, 0, 0)?;
105 } else {
106 dt = dt + chrono::Duration::days(1);
108 dt = set_hms(&tz, dt, spec.hour.first().unwrap_or(0), 0, 0)?;
109 continue; }
111 }
112
113 if !spec.min.matches(dt.minute()) {
115 if let Some(next_min) = spec.min.next_ge(dt.minute()) {
116 dt = set_hms(&tz, dt, dt.hour(), next_min, 0)?;
117 } else {
118 if let Some(next_h) = spec.hour.next_gt(dt.hour()) {
120 dt = set_hms(&tz, dt, next_h, spec.min.first().unwrap_or(0), 0)?;
121 } else {
122 dt = dt + chrono::Duration::days(1);
124 dt = set_hms(
125 &tz,
126 dt,
127 spec.hour.first().unwrap_or(0),
128 spec.min.first().unwrap_or(0),
129 0,
130 )?;
131 }
132 continue; }
134 }
135
136 if !spec.sec.matches(dt.second()) {
138 if let Some(next_s) = spec.sec.next_ge(dt.second()) {
139 dt = set_hms(&tz, dt, dt.hour(), dt.minute(), next_s)?;
140 } else {
141 if let Some(next_min) = spec.min.next_gt(dt.minute()) {
143 dt = set_hms(&tz, dt, dt.hour(), next_min, spec.sec.first().unwrap_or(0))?;
144 } else if let Some(next_h) = spec.hour.next_gt(dt.hour()) {
145 dt = set_hms(&tz, dt, next_h, spec.min.first().unwrap_or(0), spec.sec.first().unwrap_or(0))?;
146 } else {
147 dt = dt + chrono::Duration::days(1);
149 dt = set_hms(
150 &tz,
151 dt,
152 spec.hour.first().unwrap_or(0),
153 spec.min.first().unwrap_or(0),
154 spec.sec.first().unwrap_or(0),
155 )?;
156 }
157 continue; }
159 }
160
161 return Ok(dt.with_timezone(&Utc));
163 }
164 }
165}
166
167impl CronSpec {
170 fn parse(expr6: &str) -> Result<Self> {
171 let parts: Vec<&str> = expr6.split_whitespace().collect();
172 if parts.len() != 6 {
173 return Err(anyhow!("Expected 6 fields: sec min hour dom mon dow"));
174 }
175
176 let sec = Field::parse(parts[0], 0, 59, None, false)?;
177 let min = Field::parse(parts[1], 0, 59, None, false)?;
178 let hour = Field::parse(parts[2], 0, 23, None, false)?;
179 let dom = FieldDomDow::parse_dom(parts[3])?;
180 let mon = Field::parse(parts[4], 1, 12, Some(&month_name_map()), false)?;
181 let dow = FieldDomDow::parse_dow(parts[5])?;
182
183 Ok(Self { sec, min, hour, dom, mon, dow })
184 }
185
186 fn matches_day(&self, dt: &DateTime<Tz>) -> bool {
190 let dom_any = self.dom.any;
191 let dow_any = self.dow.any;
192
193 let dom_match = self.dom.matches_dom(dt.day());
194 let dow_match = self.dow.matches_dow(dt.weekday().num_days_from_sunday()); match (dom_any, dow_any) {
197 (true, true) => true,
198 (false, true) => dom_match,
199 (true, false) => dow_match,
200 (false, false) => dom_match || dow_match,
201 }
202 }
203}
204
205impl Field {
206 fn parse(token: &str, min: u32, max: u32, names: Option<&std::collections::HashMap<&'static str, u32>>, is_dow: bool) -> Result<Self> {
207 let mut allowed = HashSet::new();
208
209 if token.trim() == "*" {
211 return Ok(Self { allowed, min, max });
212 }
213
214 for part in token.split(',') {
215 let part = part.trim();
216 if part.is_empty() { continue; }
217
218 let mut part = if let Some(map) = names {
220 let upper = part.to_ascii_uppercase();
222 if let Some(&num) = map.get(upper.as_str()) {
223 num.to_string()
224 } else {
225 part.to_string()
226 }
227 } else {
228 part.to_string()
229 };
230
231 if is_dow && part == "7" {
233 part = "0".to_string();
234 }
235
236 if let Some((lhs, step_s)) = part.split_once('/') {
238 let step = parse_u(lhs, step_s, min, max)?; if lhs == "*" {
240 for v in (min..=max).step_by(step as usize) {
241 allowed.insert(v);
242 }
243 } else if let Some((a_s, b_s)) = lhs.split_once('-') {
244 let a = parse_num(a_s, min, max, names, is_dow)?;
245 let b = parse_num(b_s, min, max, names, is_dow)?;
246 let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
247 for v in (lo..=hi).step_by(step as usize) {
248 allowed.insert(v);
249 }
250 } else {
251 return Err(anyhow!("Invalid stepped token '{}'", part));
252 }
253 continue;
254 }
255
256 if let Some(step_s) = part.strip_prefix("*/") {
258 let step: u32 = step_s.parse().context("Invalid step")?;
259 for v in (min..=max).step_by(step as usize) {
260 allowed.insert(v);
261 }
262 continue;
263 }
264
265 if let Some((a_s, b_s)) = part.split_once('-') {
267 let a = parse_num(a_s, min, max, names, is_dow)?;
268 let b = parse_num(b_s, min, max, names, is_dow)?;
269 let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
270 for v in lo..=hi {
271 allowed.insert(v);
272 }
273 continue;
274 }
275
276 let n = parse_num(&part, min, max, names, is_dow)?;
278 allowed.insert(n);
279 }
280
281 Ok(Self { allowed, min, max })
282 }
283
284 #[inline]
285 fn matches(&self, v: u32) -> bool {
286 if self.allowed.is_empty() { return true; }
287 self.allowed.contains(&v)
288 }
289
290 #[inline]
291 fn first(&self) -> Option<u32> {
292 if self.allowed.is_empty() { return Some(self.min); }
293 self.allowed.iter().cloned().min()
294 }
295
296 #[inline]
297 fn next_ge(&self, v: u32) -> Option<u32> {
298 if self.allowed.is_empty() {
299 if v < self.min { return Some(self.min); }
300 if v > self.max { return None; }
301 return Some(v);
302 }
303 let mut cand: Option<u32> = None;
304 for &x in &self.allowed {
305 if x >= v {
306 cand = Some(match cand {
307 Some(c) => c.min(x),
308 None => x,
309 });
310 }
311 }
312 if cand.is_none() {
313 }
315 cand
316 }
317
318 #[inline]
319 fn next_gt(&self, v: u32) -> Option<u32> {
320 if self.allowed.is_empty() {
321 if v < self.max { return Some(v + 1); }
322 return None;
323 }
324 let mut cand: Option<u32> = None;
325 for &x in &self.allowed {
326 if x > v {
327 cand = Some(match cand {
328 Some(c) => c.min(x),
329 None => x,
330 });
331 }
332 }
333 cand
334 }
335}
336
337impl FieldDomDow {
338 fn parse_dom(token: &str) -> Result<Self> {
339 let base = Field::parse(token, 1, 31, None, false)?;
340 Ok(Self { any: base.allowed.is_empty(), allowed: base.allowed, min: 1, max: 31 })
341 }
342 fn parse_dow(token: &str) -> Result<Self> {
343 let base = Field::parse(token, 0, 6, Some(&weekday_name_map()), true)?;
344 Ok(Self { any: base.allowed.is_empty(), allowed: base.allowed, min: 0, max: 6 })
345 }
346 #[inline]
347 fn matches_dom(&self, day: u32) -> bool {
348 if self.any { return true; }
349 self.allowed.contains(&day)
350 }
351 #[inline]
352 fn matches_dow(&self, dow0sun: u32) -> bool {
353 if self.any { return true; }
354 self.allowed.contains(&dow0sun)
355 }
356}
357
358fn normalize_to_six(expr: &str) -> String {
361 let parts: Vec<&str> = expr.split_whitespace().collect();
362 match parts.len() {
363 5 => format!("0 {}", expr.trim()),
364 _ => expr.trim().to_string(),
365 }
366}
367
368fn set_ymd_hms(tz: &Tz, y: i32, m: u32, d: u32, h: u32, min: u32, s: u32) -> Result<DateTime<Tz>> {
369 tz.with_ymd_and_hms(y, m, d, h, min, s)
370 .single()
371 .ok_or_else(|| anyhow!("Invalid local time (DST gap/overlap): {y}-{m}-{d} {h}:{min}:{s}"))
372}
373
374fn set_hms(tz: &Tz, dt: DateTime<Tz>, h: u32, m: u32, s: u32) -> Result<DateTime<Tz>> {
375 set_ymd_hms(tz, dt.year(), dt.month(), dt.day(), h, m, s)
376}
377
378fn parse_u(lhs: &str, step: &str, _min: u32, _max: u32) -> Result<usize> {
379 if !lhs.is_empty() && lhs != "*" && !lhs.contains('-') {
380 return Err(anyhow!("Invalid stepped lhs '{}'", lhs));
381 }
382 let st: u32 = step.parse().context("Invalid step value")?;
383 if st == 0 { return Err(anyhow!("Step must be > 0")); }
384 Ok(st as usize)
385}
386
387fn parse_num(token: &str, min: u32, max: u32, names: Option<&std::collections::HashMap<&'static str, u32>>, is_dow: bool) -> Result<u32> {
388 let t = token.trim();
389 if let Some(map) = names {
391 let up = t.to_ascii_uppercase();
392 if let Some(&n) = map.get(up.as_str()) {
393 return Ok(n);
394 }
395 }
396 let mut n: u32 = u32::from_str(t).context(format!("Invalid number '{}'", t))?;
397 if is_dow && n == 7 { n = 0; } if n < min || n > max {
399 return Err(anyhow!("Value {} out of range {}..{}", n, min, max));
400 }
401 Ok(n)
402}
403
404fn month_name_map() -> std::collections::HashMap<&'static str, u32> {
405 use std::iter::FromIterator;
406 std::collections::HashMap::from_iter([
407 ("JAN", 1), ("FEB", 2), ("MAR", 3), ("APR", 4), ("MAY", 5), ("JUN", 6),
408 ("JUL", 7), ("AUG", 8), ("SEP", 9), ("OCT", 10), ("NOV", 11), ("DEC", 12),
409 ])
410}
411
412fn weekday_name_map() -> std::collections::HashMap<&'static str, u32> {
413 use std::iter::FromIterator;
414 std::collections::HashMap::from_iter([
416 ("SUN", 0), ("MON", 1), ("TUE", 2), ("WED", 3),
417 ("THU", 4), ("FRI", 5), ("SAT", 6),
418 ])
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424 use chrono::TimeZone;
425
426 #[test]
427 fn test_minutely_5field_defaults_seconds0() {
428 let from = Utc.with_ymd_and_hms(2025, 9, 26, 10, 0, 10).unwrap();
429 let tz = "UTC";
430 let next = CronParser::next_execution("*/1 * * * *", from, tz).unwrap();
431 assert_eq!(next, Utc.with_ymd_and_hms(2025, 9, 26, 10, 1, 0).unwrap());
432 }
433
434 #[test]
435 fn test_every_5_minutes_6field() {
436 let tz = "Asia/Kolkata";
437 let from = Utc.with_ymd_and_hms(2025, 9, 26, 10, 2, 30).unwrap();
438 let next = CronParser::next_execution("0 */5 * * * *", from, tz).unwrap();
439 assert!(next > from);
440 }
441
442 #[test]
443 fn test_named_month_and_weekday() {
444 let tz = "UTC";
445 let from = Utc.with_ymd_and_hms(2025, 1, 30, 23, 59, 59).unwrap();
446 let next = CronParser::next_execution("0 0 9 1-7 FEB MON", from, tz).unwrap();
448 assert!(next > from);
449 }
450
451 #[test]
452 fn test_dow_sunday_0_or_7() {
453 let tz = "UTC";
454 let from = Utc.with_ymd_and_hms(2025, 9, 26, 10, 0, 0).unwrap(); let n0 = CronParser::next_execution("0 0 * * * 0", from, tz).unwrap();
456 let n7 = CronParser::next_execution("0 0 * * * 7", from, tz).unwrap();
457 assert_eq!(n0, n7);
458 }
459}