Skip to main content

tpt_cron_parse/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3
4use std::fmt;
5
6/// Which field of the cron expression caused a parse error.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum CronFieldName {
9    /// Seconds (6-field cron only).
10    Seconds,
11    /// Minutes field.
12    Minutes,
13    /// Hours field.
14    Hours,
15    /// Day-of-month field.
16    DayOfMonth,
17    /// Month field.
18    Month,
19    /// Day-of-week field.
20    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/// A parse error with exact location information.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct CronError {
40    /// Byte offset in the input where the error occurred.
41    pub position: usize,
42    /// Which cron field was being parsed when the error occurred.
43    pub field: CronFieldName,
44    /// Description of what was expected.
45    pub expected: &'static str,
46    /// The character found, or `None` if the input ended unexpectedly.
47    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/// A single cron field value (wildcard, number, range, step, or list).
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum CronField {
72    /// Wildcard `*` — matches all values.
73    Any,
74    /// A specific numeric value, e.g. `5`.
75    Value(u8),
76    /// An inclusive range, e.g. `1-5`.
77    Range(u8, u8),
78    /// A step expression, e.g. `*/2` or `1-5/2`.
79    Step(Box<CronField>, u8),
80    /// A comma-separated list, e.g. `1,3,5`.
81    List(Vec<CronField>),
82}
83
84/// A parsed cron expression (5-field or 6-field with seconds).
85///
86/// # Example
87///
88/// ```
89/// use tpt_cron_parse::CronExpr;
90///
91/// let expr = CronExpr::parse("0 9 * * 1").unwrap();
92/// assert_eq!(expr.to_human_readable(), "Every Monday at 9:00 AM");
93/// ```
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct CronExpr {
96    /// Seconds field — `Some` for 6-field cron, `None` for 5-field.
97    pub seconds: Option<CronField>,
98    /// Minutes field.
99    pub minutes: CronField,
100    /// Hours field.
101    pub hours: CronField,
102    /// Day-of-month field.
103    pub dom: CronField,
104    /// Month field.
105    pub month: CronField,
106    /// Day-of-week field.
107    pub dow: CronField,
108}
109
110impl CronExpr {
111    /// Parse a cron expression string (5-field or 6-field).
112    ///
113    /// # Example
114    ///
115    /// ```
116    /// use tpt_cron_parse::CronExpr;
117    ///
118    /// let expr = CronExpr::parse("*/5 * * * *").unwrap();
119    /// assert_eq!(expr.to_human_readable(), "Every 5 minutes");
120    /// ```
121    pub fn parse(s: &str) -> Result<CronExpr, CronError> {
122        let parser = CronParser::new(s);
123        parser.parse()
124    }
125
126    /// Returns `true` if this is a 6-field cron expression (with seconds).
127    pub fn is_6_field(&self) -> bool {
128        self.seconds.is_some()
129    }
130
131    /// Convert this cron expression to a human-readable English description.
132    ///
133    /// # Example
134    ///
135    /// ```
136    /// use tpt_cron_parse::CronExpr;
137    ///
138    /// assert_eq!(CronExpr::parse("* * * * *").unwrap().to_human_readable(), "Every minute");
139    /// assert_eq!(CronExpr::parse("0 * * * *").unwrap().to_human_readable(), "Every hour");
140    /// assert_eq!(CronExpr::parse("0 9 * * *").unwrap().to_human_readable(), "Every day at 9:00 AM");
141    /// assert_eq!(CronExpr::parse("0 0 1 1 *").unwrap().to_human_readable(), "At 12:00 AM on January 1st");
142    /// ```
143    pub fn to_human_readable(&self) -> String {
144        human_readable(self)
145    }
146
147    /// Return the first time strictly after `after` that this schedule fires.
148    ///
149    /// Only available with the `chrono` feature (the crate stays dependency-free
150    /// by default). The search is bounded to roughly four years ahead, the
151    /// maximum period of a cron schedule (due to February 29th).
152    ///
153    /// ```rust,ignore
154    /// use tpt_cron_parse::CronExpr;
155    /// use chrono::{TimeZone, Utc};
156    /// let expr = CronExpr::parse("0 9 * * 1-5").unwrap();
157    /// let after = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
158    /// let next = expr.next_after(after).unwrap(); // next weekday at 09:00
159    /// ```
160    #[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/// Expand a [`CronField`] into the sorted, de-duplicated set of values it
228/// permits within the inclusive `[min, max]` range.
229#[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
269// ---- Parser internals ----
270
271struct 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        // Count fields to detect 5 vs 6
378        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
419// ---- Human-readable conversion ----
420
421fn 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    // Every minute
492    if min_any && hr_any && dom_any && mon_any && dow_any {
493        return "Every minute".into();
494    }
495
496    // Every N minutes: */N * * * *
497    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    // Every hour: 0 * * * *
504    if is_zero(&expr.minutes) && hr_any && dom_any && mon_any && dow_any {
505        return "Every hour".into();
506    }
507
508    // Build time part
509    let time_str = format_time(&expr.hours, &expr.minutes);
510
511    // Specific day of week
512    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    // Specific day of month, any month
521    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    // Specific month and day: 0 0 1 1 *
530    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    // Every day at time
539    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    // Fallback: reconstruct the expression
546    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(); // Monday
672        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        // February 30th can never occur, so a schedule pinned to it never fires.
707        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}