1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3
4use std::fmt;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum CronFieldName {
9 Seconds,
11 Minutes,
13 Hours,
15 DayOfMonth,
17 Month,
19 DayOfWeek,
21}
22
23impl fmt::Display for CronFieldName {
24 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25 let s = match self {
26 Self::Seconds => "seconds",
27 Self::Minutes => "minutes",
28 Self::Hours => "hours",
29 Self::DayOfMonth => "day-of-month",
30 Self::Month => "month",
31 Self::DayOfWeek => "day-of-week",
32 };
33 f.write_str(s)
34 }
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct CronError {
40 pub position: usize,
42 pub field: CronFieldName,
44 pub expected: &'static str,
46 pub found: Option<char>,
48}
49
50impl fmt::Display for CronError {
51 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52 match self.found {
53 Some(c) => write!(
54 f,
55 "cron parse error in {} field at position {}: expected {}, found {:?}",
56 self.field, self.position, self.expected, c
57 ),
58 None => write!(
59 f,
60 "cron parse error in {} field at position {}: expected {}, found end of input",
61 self.field, self.position, self.expected
62 ),
63 }
64 }
65}
66
67impl std::error::Error for CronError {}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum CronField {
72 Any,
74 Value(u8),
76 Range(u8, u8),
78 Step(Box<CronField>, u8),
80 List(Vec<CronField>),
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct CronExpr {
96 pub seconds: Option<CronField>,
98 pub minutes: CronField,
100 pub hours: CronField,
102 pub dom: CronField,
104 pub month: CronField,
106 pub dow: CronField,
108}
109
110impl CronExpr {
111 pub fn parse(s: &str) -> Result<CronExpr, CronError> {
122 let parser = CronParser::new(s);
123 parser.parse()
124 }
125
126 pub fn is_6_field(&self) -> bool {
128 self.seconds.is_some()
129 }
130
131 pub fn to_human_readable(&self) -> String {
144 human_readable(self)
145 }
146
147 #[cfg(feature = "chrono")]
161 pub fn next_after(
162 &self,
163 after: chrono::DateTime<chrono::Utc>,
164 ) -> Option<chrono::DateTime<chrono::Utc>> {
165 use chrono::{Datelike, Timelike};
166 let seconds_set = self.seconds.as_ref().map(|s| expand_field(s, 0, 59));
167 let minutes = expand_field(&self.minutes, 0, 59);
168 let hours = expand_field(&self.hours, 0, 23);
169 let doms = expand_field(&self.dom, 1, 31);
170 let months = expand_field(&self.month, 1, 12);
171 let dows = expand_field(&self.dow, 0, 7)
172 .into_iter()
173 .map(|d| d % 7)
174 .collect::<Vec<_>>();
175 let dom_restricted = !is_any(&self.dom);
176 let dow_restricted = !is_any(&self.dow);
177 let after_minute = after.with_second(0).unwrap().with_nanosecond(0).unwrap();
178 let mut min_start = after_minute + chrono::Duration::minutes(1);
179 let limit = after + chrono::Duration::days(4 * 366 + 1);
180
181 while min_start <= limit {
182 let m = min_start.minute() as u8;
183 let h = min_start.hour() as u8;
184 let dom = min_start.day() as u8;
185 let mon = min_start.month() as u8;
186 let dow = match min_start.weekday() {
187 chrono::Weekday::Sun => 0,
188 chrono::Weekday::Mon => 1,
189 chrono::Weekday::Tue => 2,
190 chrono::Weekday::Wed => 3,
191 chrono::Weekday::Thu => 4,
192 chrono::Weekday::Fri => 5,
193 chrono::Weekday::Sat => 6,
194 };
195 let day_ok = match (dom_restricted, dow_restricted) {
196 (false, false) => true,
197 (true, false) => doms.contains(&dom),
198 (false, true) => dows.contains(&dow),
199 (true, true) => doms.contains(&dom) || dows.contains(&dow),
200 };
201
202 if minutes.contains(&m) && hours.contains(&h) && months.contains(&mon) && day_ok {
203 if let Some(secs) = &seconds_set {
204 let same_minute = min_start == after_minute;
205 let s0 = if same_minute {
206 after.second() as u8 + 1
207 } else {
208 0
209 };
210 if let Some(&s) = secs.iter().find(|&&s| s >= s0) {
211 let t = min_start.with_second(s as u32).unwrap();
212 if t > after {
213 return Some(t);
214 }
215 }
216 } else if min_start > after {
217 return Some(min_start);
218 }
219 }
220
221 min_start += chrono::Duration::minutes(1);
222 }
223 None
224 }
225}
226
227#[cfg(feature = "chrono")]
230fn expand_field(field: &CronField, min: u8, max: u8) -> Vec<u8> {
231 let mut out = match field {
232 CronField::Any => (min..=max).collect(),
233 CronField::Value(n) => vec![*n],
234 CronField::Range(a, b) => (*a..=*b).collect(),
235 CronField::Step(base, step) => {
236 let start = match base.as_ref() {
237 CronField::Any => min,
238 CronField::Value(n) => *n,
239 CronField::Range(a, _) => *a,
240 _ => min,
241 };
242 let mut vals = Vec::new();
243 let mut v = start;
244 while v <= max {
245 vals.push(v);
246 if *step == 0 {
247 break;
248 }
249 match v.checked_add(*step) {
250 Some(n) => v = n,
251 None => break,
252 }
253 }
254 vals
255 }
256 CronField::List(items) => {
257 let mut vals = Vec::new();
258 for it in items {
259 vals.extend(expand_field(it, min, max));
260 }
261 vals
262 }
263 };
264 out.sort_unstable();
265 out.dedup();
266 out
267}
268
269struct CronParser<'a> {
272 input: &'a str,
273 pos: usize,
274}
275
276impl<'a> CronParser<'a> {
277 fn new(input: &'a str) -> Self {
278 Self { input, pos: 0 }
279 }
280
281 fn peek(&self) -> Option<char> {
282 self.input[self.pos..].chars().next()
283 }
284
285 fn skip_whitespace(&mut self) {
286 while self.pos < self.input.len() && self.input.as_bytes()[self.pos] == b' ' {
287 self.pos += 1;
288 }
289 }
290
291 fn parse_u8(&mut self, field: CronFieldName) -> Result<u8, CronError> {
292 let start = self.pos;
293 while self.pos < self.input.len() && self.input.as_bytes()[self.pos].is_ascii_digit() {
294 self.pos += 1;
295 }
296 if self.pos == start {
297 return Err(CronError {
298 position: self.pos,
299 field,
300 expected: "digit",
301 found: self.peek(),
302 });
303 }
304 self.input[start..self.pos]
305 .parse::<u8>()
306 .map_err(|_| CronError {
307 position: start,
308 field,
309 expected: "number 0-255",
310 found: None,
311 })
312 }
313
314 fn parse_field(&mut self, field: CronFieldName) -> Result<CronField, CronError> {
315 let mut items: Vec<CronField> = Vec::new();
316 loop {
317 let item = self.parse_item(field)?;
318 items.push(item);
319 if self.pos < self.input.len() && self.input.as_bytes()[self.pos] == b',' {
320 self.pos += 1;
321 } else {
322 break;
323 }
324 }
325 if items.len() == 1 {
326 Ok(items.remove(0))
327 } else {
328 Ok(CronField::List(items))
329 }
330 }
331
332 fn parse_item(&mut self, field: CronFieldName) -> Result<CronField, CronError> {
333 let base = if self.pos < self.input.len() && self.input.as_bytes()[self.pos] == b'*' {
334 self.pos += 1;
335 CronField::Any
336 } else {
337 let n_start = self.pos;
338 let n = self.parse_u8(field)?;
339 if self.pos < self.input.len() && self.input.as_bytes()[self.pos] == b'-' {
340 self.pos += 1;
341 let end = self.parse_u8(field)?;
342 if n > end {
343 return Err(CronError {
344 position: n_start,
345 field,
346 expected: "ascending range (start <= end)",
347 found: Some('-'),
348 });
349 }
350 CronField::Range(n, end)
351 } else {
352 CronField::Value(n)
353 }
354 };
355
356 if self.pos < self.input.len() && self.input.as_bytes()[self.pos] == b'/' {
357 self.pos += 1;
358 let step_start = self.pos;
359 let step = self.parse_u8(field)?;
360 if step == 0 {
361 return Err(CronError {
362 position: step_start,
363 field,
364 expected: "non-zero step value",
365 found: Some('0'),
366 });
367 }
368 Ok(CronField::Step(Box::new(base), step))
369 } else {
370 Ok(base)
371 }
372 }
373
374 fn parse(mut self) -> Result<CronExpr, CronError> {
375 self.skip_whitespace();
376
377 let fields: Vec<&str> = self.input.split_whitespace().collect();
379 if fields.len() != 5 && fields.len() != 6 {
380 return Err(CronError {
381 position: 0,
382 field: CronFieldName::Minutes,
383 expected: "5 or 6 whitespace-separated fields",
384 found: None,
385 });
386 }
387
388 let is_6 = fields.len() == 6;
389
390 let seconds = if is_6 {
391 let f = self.parse_field(CronFieldName::Seconds)?;
392 self.skip_whitespace();
393 Some(f)
394 } else {
395 None
396 };
397
398 let minutes = self.parse_field(CronFieldName::Minutes)?;
399 self.skip_whitespace();
400 let hours = self.parse_field(CronFieldName::Hours)?;
401 self.skip_whitespace();
402 let dom = self.parse_field(CronFieldName::DayOfMonth)?;
403 self.skip_whitespace();
404 let month = self.parse_field(CronFieldName::Month)?;
405 self.skip_whitespace();
406 let dow = self.parse_field(CronFieldName::DayOfWeek)?;
407
408 Ok(CronExpr {
409 seconds,
410 minutes,
411 hours,
412 dom,
413 month,
414 dow,
415 })
416 }
417}
418
419fn is_any(f: &CronField) -> bool {
422 matches!(f, CronField::Any)
423}
424
425fn is_zero(f: &CronField) -> bool {
426 matches!(f, CronField::Value(0))
427}
428
429fn format_time(hours: &CronField, minutes: &CronField) -> Option<String> {
430 if let (CronField::Value(h), CronField::Value(m)) = (hours, minutes) {
431 let period = if *h < 12 { "AM" } else { "PM" };
432 let h12 = match h {
433 0 => 12,
434 h if *h <= 12 => *h as u32,
435 h => (*h - 12) as u32,
436 };
437 Some(format!("{}:{:02} {}", h12, m, period))
438 } else {
439 None
440 }
441}
442
443fn ordinal(n: u8) -> String {
444 let s = match n % 10 {
445 1 if n % 100 != 11 => "st",
446 2 if n % 100 != 12 => "nd",
447 3 if n % 100 != 13 => "rd",
448 _ => "th",
449 };
450 format!("{}{}", n, s)
451}
452
453fn month_name(m: u8) -> &'static str {
454 match m {
455 1 => "January",
456 2 => "February",
457 3 => "March",
458 4 => "April",
459 5 => "May",
460 6 => "June",
461 7 => "July",
462 8 => "August",
463 9 => "September",
464 10 => "October",
465 11 => "November",
466 12 => "December",
467 _ => "unknown",
468 }
469}
470
471fn dow_name(d: u8) -> &'static str {
472 match d {
473 0 | 7 => "Sunday",
474 1 => "Monday",
475 2 => "Tuesday",
476 3 => "Wednesday",
477 4 => "Thursday",
478 5 => "Friday",
479 6 => "Saturday",
480 _ => "unknown",
481 }
482}
483
484fn human_readable(expr: &CronExpr) -> String {
485 let min_any = is_any(&expr.minutes);
486 let hr_any = is_any(&expr.hours);
487 let dom_any = is_any(&expr.dom);
488 let mon_any = is_any(&expr.month);
489 let dow_any = is_any(&expr.dow);
490
491 if min_any && hr_any && dom_any && mon_any && dow_any {
493 return "Every minute".into();
494 }
495
496 if let CronField::Step(base, n) = &expr.minutes {
498 if matches!(base.as_ref(), CronField::Any) && hr_any && dom_any && mon_any && dow_any {
499 return format!("Every {} minutes", n);
500 }
501 }
502
503 if is_zero(&expr.minutes) && hr_any && dom_any && mon_any && dow_any {
505 return "Every hour".into();
506 }
507
508 let time_str = format_time(&expr.hours, &expr.minutes);
510
511 if dom_any && mon_any {
513 if let CronField::Value(d) = expr.dow {
514 if let Some(t) = &time_str {
515 return format!("Every {} at {}", dow_name(d), t);
516 }
517 }
518 }
519
520 if dow_any && mon_any {
522 if let CronField::Value(d) = expr.dom {
523 if let Some(t) = &time_str {
524 return format!("At {} on the {} of every month", t, ordinal(d));
525 }
526 }
527 }
528
529 if dow_any {
531 if let (CronField::Value(d), CronField::Value(m)) = (&expr.dom, &expr.month) {
532 if let Some(t) = &time_str {
533 return format!("At {} on {} {}", t, month_name(*m), ordinal(*d));
534 }
535 }
536 }
537
538 if dom_any && mon_any && dow_any {
540 if let Some(t) = &time_str {
541 return format!("Every day at {}", t);
542 }
543 }
544
545 format!(
547 "At {} past {} on {} of {} ({})",
548 field_str(&expr.minutes),
549 field_str(&expr.hours),
550 field_str(&expr.dom),
551 field_str(&expr.month),
552 field_str(&expr.dow),
553 )
554}
555
556fn field_str(f: &CronField) -> String {
557 match f {
558 CronField::Any => "*".into(),
559 CronField::Value(n) => n.to_string(),
560 CronField::Range(a, b) => format!("{}-{}", a, b),
561 CronField::Step(base, n) => format!("{}/{}", field_str(base), n),
562 CronField::List(items) => items.iter().map(field_str).collect::<Vec<_>>().join(","),
563 }
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569
570 #[test]
571 fn parse_every_minute() {
572 let e = CronExpr::parse("* * * * *").unwrap();
573 assert_eq!(e.to_human_readable(), "Every minute");
574 }
575
576 #[test]
577 fn parse_every_hour() {
578 let e = CronExpr::parse("0 * * * *").unwrap();
579 assert_eq!(e.to_human_readable(), "Every hour");
580 }
581
582 #[test]
583 fn parse_every_day_9am() {
584 let e = CronExpr::parse("0 9 * * *").unwrap();
585 assert_eq!(e.to_human_readable(), "Every day at 9:00 AM");
586 }
587
588 #[test]
589 fn parse_every_monday() {
590 let e = CronExpr::parse("0 9 * * 1").unwrap();
591 assert_eq!(e.to_human_readable(), "Every Monday at 9:00 AM");
592 }
593
594 #[test]
595 fn parse_1st_of_month() {
596 let e = CronExpr::parse("0 9 1 * *").unwrap();
597 assert_eq!(
598 e.to_human_readable(),
599 "At 9:00 AM on the 1st of every month"
600 );
601 }
602
603 #[test]
604 fn parse_every_5_minutes() {
605 let e = CronExpr::parse("*/5 * * * *").unwrap();
606 assert_eq!(e.to_human_readable(), "Every 5 minutes");
607 }
608
609 #[test]
610 fn parse_jan_1_midnight() {
611 let e = CronExpr::parse("0 0 1 1 *").unwrap();
612 assert_eq!(e.to_human_readable(), "At 12:00 AM on January 1st");
613 }
614
615 #[test]
616 fn parse_6_field() {
617 let e = CronExpr::parse("30 0 9 * * *").unwrap();
618 assert!(e.is_6_field());
619 assert_eq!(e.seconds, Some(CronField::Value(30)));
620 }
621
622 #[test]
623 fn parse_range() {
624 let e = CronExpr::parse("0 9-17 * * *").unwrap();
625 assert_eq!(e.hours, CronField::Range(9, 17));
626 }
627
628 #[test]
629 fn parse_list() {
630 let e = CronExpr::parse("0 9 * * 1,3,5").unwrap();
631 assert_eq!(
632 e.dow,
633 CronField::List(vec![
634 CronField::Value(1),
635 CronField::Value(3),
636 CronField::Value(5)
637 ])
638 );
639 }
640
641 #[test]
642 fn wrong_field_count_error() {
643 let err = CronExpr::parse("* * *").unwrap_err();
644 assert_eq!(err.expected, "5 or 6 whitespace-separated fields");
645 }
646
647 #[test]
648 fn invalid_char_error() {
649 let err = CronExpr::parse("x * * * *").unwrap_err();
650 assert_eq!(err.field, CronFieldName::Minutes);
651 assert_eq!(err.found, Some('x'));
652 }
653
654 #[test]
655 fn pm_time() {
656 let e = CronExpr::parse("0 14 * * *").unwrap();
657 assert_eq!(e.to_human_readable(), "Every day at 2:00 PM");
658 }
659
660 #[test]
661 fn noon() {
662 let e = CronExpr::parse("0 12 * * *").unwrap();
663 assert_eq!(e.to_human_readable(), "Every day at 12:00 PM");
664 }
665
666 #[cfg(feature = "chrono")]
667 #[test]
668 fn next_after_weekday_morning() {
669 use chrono::{Datelike, TimeZone, Timelike, Utc, Weekday};
670 let expr = CronExpr::parse("0 9 * * 1-5").unwrap();
671 let after = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); let next = expr.next_after(after).unwrap();
673 assert!(next > after);
674 assert_eq!(next.hour(), 9);
675 assert_eq!(next.minute(), 0);
676 assert_eq!(next.weekday(), Weekday::Mon);
677 }
678
679 #[cfg(feature = "chrono")]
680 #[test]
681 fn next_after_steps_and_ranges() {
682 use chrono::{TimeZone, Timelike, Utc};
683 let expr = CronExpr::parse("*/15 * * * *").unwrap();
684 let after = Utc.with_ymd_and_hms(2024, 6, 1, 10, 0, 0).unwrap();
685 let next = expr.next_after(after).unwrap();
686 assert_eq!(next.minute() % 15, 0);
687 assert!(next > after);
688 }
689
690 #[cfg(feature = "chrono")]
691 #[test]
692 fn next_after_6field_seconds() {
693 use chrono::{TimeZone, Timelike, Utc};
694 let expr = CronExpr::parse("30 0 9 * * *").unwrap();
695 let after = Utc.with_ymd_and_hms(2024, 6, 1, 9, 0, 0).unwrap();
696 let next = expr.next_after(after).unwrap();
697 assert_eq!(next.hour(), 9);
698 assert_eq!(next.minute(), 0);
699 assert_eq!(next.second(), 30);
700 }
701
702 #[cfg(feature = "chrono")]
703 #[test]
704 fn next_after_none_within_bound() {
705 use chrono::{TimeZone, Utc};
706 let expr = CronExpr::parse("0 0 30 2 *").unwrap();
708 let after = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
709 assert!(expr.next_after(after).is_none());
710 }
711
712 #[test]
713 fn descending_range_rejected() {
714 let err = CronExpr::parse("0 9-5 * * *").unwrap_err();
715 assert_eq!(err.expected, "ascending range (start <= end)");
716 }
717
718 #[test]
719 fn zero_step_rejected() {
720 let err = CronExpr::parse("*/0 * * * *").unwrap_err();
721 assert_eq!(err.expected, "non-zero step value");
722 }
723
724 #[test]
725 fn valid_ascending_range_ok() {
726 let e = CronExpr::parse("0 5-9 * * *").unwrap();
727 assert_eq!(e.hours, CronField::Range(5, 9));
728 }
729}