cron_parser/
lib.rs

1//! Library for parsing cron expressions with timezone support.
2//!
3//! Example:
4//! ```
5//! use chrono::{TimeZone, Utc};
6//! use chrono_tz::Europe::Lisbon;
7//! use cron_parser::parse;
8//!
9//! if let Ok(next) = parse("*/5 * * * *", &Utc::now()) {
10//!      println!("when: {}", next);
11//! }
12//!
13//! // every 6 hours starting at 1:00
14//! if let Ok(next) = parse("0 1/6 * * *", &Utc::now()) {
15//!      println!("when: {}", next);
16//! }
17//!
18//! // passing a custom timestamp
19//! if let Ok(next) = parse("0 0 29 2 *", &Utc.timestamp_opt(1893456000, 0).unwrap()) {
20//!      println!("next leap year: {}", next);
21//!      assert_eq!(next.timestamp(), 1961625600);
22//! }
23//!
24//! assert!(parse("2-3,9,*/15,1-8,11,9,4,5 * * * *", &Utc::now()).is_ok());
25//! assert!(parse("* * * * */Fri", &Utc::now()).is_err());
26//!
27//! // use custom timezone
28//! assert!(parse("*/5 * * * *", &Utc::now().with_timezone(&Lisbon)).is_ok());
29//! ```
30use chrono::{DateTime, Datelike, Duration, TimeZone, Timelike, Utc};
31use std::{collections::BTreeSet, error::Error, fmt, num, str::FromStr};
32
33#[derive(Debug)]
34pub enum ParseError {
35    InvalidCron,
36    InvalidRange,
37    InvalidValue,
38    ParseIntError(num::ParseIntError),
39    TryFromIntError(num::TryFromIntError),
40    InvalidTimezone,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44enum Dow {
45    Sun = 0,
46    Mon = 1,
47    Tue = 2,
48    Wed = 3,
49    Thu = 4,
50    Fri = 5,
51    Sat = 6,
52}
53
54impl FromStr for Dow {
55    type Err = ();
56
57    fn from_str(s: &str) -> Result<Self, Self::Err> {
58        match &*s.to_uppercase() {
59            "SUN" => Ok(Self::Sun),
60            "MON" => Ok(Self::Mon),
61            "TUE" => Ok(Self::Tue),
62            "WED" => Ok(Self::Wed),
63            "THU" => Ok(Self::Thu),
64            "FRI" => Ok(Self::Fri),
65            "SAT" => Ok(Self::Sat),
66            _ => Err(()),
67        }
68    }
69}
70
71impl fmt::Display for ParseError {
72    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
73        match *self {
74            Self::InvalidCron => write!(f, "invalid cron"),
75            Self::InvalidRange => write!(f, "invalid input"),
76            Self::InvalidValue => write!(f, "invalid value"),
77            Self::ParseIntError(ref err) => err.fmt(f),
78            Self::TryFromIntError(ref err) => err.fmt(f),
79            Self::InvalidTimezone => write!(f, "invalid timezone"),
80        }
81    }
82}
83
84impl Error for ParseError {}
85
86impl From<num::ParseIntError> for ParseError {
87    fn from(err: num::ParseIntError) -> Self {
88        Self::ParseIntError(err)
89    }
90}
91
92impl From<num::TryFromIntError> for ParseError {
93    fn from(err: num::TryFromIntError) -> Self {
94        Self::TryFromIntError(err)
95    }
96}
97
98/// Parse cron expression
99/// ```text
100///
101/// ┌─────────────────────  minute (0 - 59)
102/// │ ┌───────────────────  hour   (0 - 23)
103/// │ │ ┌─────────────────  dom    (1 - 31) day of month
104/// │ │ │ ┌───────────────  month  (1 - 12)
105/// │ │ │ │ ┌─────────────  dow    (0 - 6 or Sun - Sat) day of week (Sunday to Saturday)
106/// │ │ │ │ │
107/// │ │ │ │ │
108/// │ │ │ │ │
109/// * * * * * <command to execute>
110/// ```
111///
112/// Example
113/// ```
114/// use cron_parser::parse;
115/// use chrono::Utc;
116///
117/// assert!(parse("*/5 * * * *", &Utc::now()).is_ok());
118///
119/// // use custom timezone
120/// use chrono_tz::US::Pacific;
121/// assert!(parse("*/5 * * * *", &Utc::now().with_timezone(&Pacific)).is_ok());
122/// ```
123/// # Errors
124/// [`ParseError`](enum.ParseError.html)
125pub fn parse<TZ: TimeZone>(cron: &str, dt: &DateTime<TZ>) -> Result<DateTime<TZ>, ParseError> {
126    let tz = dt.timezone();
127
128    let fields: Vec<&str> = cron.split_whitespace().collect();
129
130    if fields.len() != 5 {
131        return Err(ParseError::InvalidCron);
132    }
133
134    let mut next = match Utc.from_local_datetime(&dt.naive_local()) {
135        chrono::LocalResult::Single(datetime) => datetime + Duration::minutes(1),
136        chrono::LocalResult::Ambiguous(earlier, _later) => earlier + Duration::minutes(1),
137        chrono::LocalResult::None => return Err(ParseError::InvalidTimezone),
138    };
139
140    next = make_utc_datetime(
141        next.year(),
142        next.month(),
143        next.day(),
144        next.hour(),
145        next.minute(),
146        0,
147    )?;
148
149    let result = loop {
150        // only try until next leap year
151        if next.year() - dt.year() > 4 {
152            return Err(ParseError::InvalidCron);
153        }
154
155        // * * * <month> *
156        let month = parse_field(fields[3], 1, 12)?;
157        if !month.contains(&next.month()) {
158            next = make_utc_datetime(
159                if next.month() == 12 {
160                    next.year() + 1
161                } else {
162                    next.year()
163                },
164                if next.month() == 12 {
165                    1
166                } else {
167                    next.month() + 1
168                },
169                1,
170                0,
171                0,
172                0,
173            )?;
174            continue;
175        }
176
177        // * * <dom> * *
178        let do_m = parse_field(fields[2], 1, 31)?;
179        if !do_m.contains(&next.day()) {
180            next += Duration::days(1);
181            next = make_utc_datetime(next.year(), next.month(), next.day(), 0, 0, 0)?;
182            continue;
183        }
184
185        // * <hour> * * *
186        let hour = parse_field(fields[1], 0, 23)?;
187        if !hour.contains(&next.hour()) {
188            next += Duration::hours(1);
189            next = make_utc_datetime(next.year(), next.month(), next.day(), next.hour(), 0, 0)?;
190            continue;
191        }
192
193        // <minute> * * * *
194        let minute = parse_field(fields[0], 0, 59)?;
195        if !minute.contains(&next.minute()) {
196            next += Duration::minutes(1);
197            continue;
198        }
199
200        // * * * * <dow>
201        let do_w = parse_field(fields[4], 0, 6)?;
202        if !do_w.contains(&next.weekday().num_days_from_sunday()) {
203            next += Duration::days(1);
204            continue;
205        }
206
207        // Valid datetime for the timezone
208        match tz.from_local_datetime(&next.naive_local()) {
209            chrono::LocalResult::Single(dt) => break dt,
210            chrono::LocalResult::Ambiguous(earlier, _later) => break earlier,
211            chrono::LocalResult::None => {
212                next += Duration::minutes(1);
213                continue;
214            }
215        }
216    };
217
218    Ok(result)
219}
220
221/// `parse_field`
222/// Allowed special characters:
223/// * `*` any value
224/// * `,` value list separator
225/// * `-` range of values
226/// * `/` step values
227///
228/// ```text
229/// minutes min: 0, max: 59
230/// hours   min: 0, max: 23
231/// days    min: 1, max: 31
232/// month   min: 1, max: 12
233/// dow     min: 0, max: 6 or min: Sun, max Sat
234///
235/// Day of week (dow):
236///    Sun = 0
237///    Mon = 1
238///    Tue = 2
239///    Wed = 3
240///    Thu = 4
241///    Fri = 5
242///    Sat = 6
243/// ```
244///
245/// The field column can have a `*` or a list of elements separated by commas.
246/// An element is either a number in the ranges or two numbers in the range
247/// separated by a hyphen, slashes can be combined with ranges to specify
248/// step values
249///
250/// Example
251/// ```
252/// use cron_parser::parse_field;
253/// use std::collections::BTreeSet;
254///
255///  // every 3 months
256///  assert_eq!(parse_field("*/3", 1, 12).unwrap(),
257///  BTreeSet::<u32>::from([1,4,7,10].iter().cloned().collect::<BTreeSet<u32>>()));
258///
259///  // day 31
260///  assert_eq!(parse_field("31", 1, 31).unwrap(),
261///  BTreeSet::<u32>::from([31].iter().cloned().collect::<BTreeSet<u32>>()));
262///
263///  // every minute from 40 through 50
264///  assert_eq!(parse_field("40-50", 0, 59).unwrap(),
265///  BTreeSet::<u32>::from([40,41,42,43,44,45,46,47,48,49,50].iter().cloned().collect::<BTreeSet<u32>>()));
266///
267///  // at hour 3,15,23
268///  assert_eq!(parse_field("15,3,23", 0, 23).unwrap(),
269///  BTreeSet::<u32>::from([3,15,23].iter().cloned().collect::<BTreeSet<u32>>()));
270/// ```
271///
272/// Parses a cron field, supporting formats like:
273/// `*/N`, `<start>/<step>`, ranges (`min-max`), and lists (`1,2,3`).
274///
275/// # Errors
276/// [`ParseError`](enum.ParseError.html)
277pub fn parse_field(field: &str, min: u32, max: u32) -> Result<BTreeSet<u32>, ParseError> {
278    let mut values = BTreeSet::<u32>::new();
279
280    // split fields by ','
281    let fields: Vec<&str> = field.split(',').filter(|s| !s.is_empty()).collect();
282
283    // iterate over the fields and match against allowed characters
284    for field in fields {
285        match field {
286            // any
287            "*" => {
288                for i in min..=max {
289                    values.insert(i);
290                }
291            }
292
293            // step values
294            f if f.starts_with("*/") => {
295                let step: u32 = f.trim_start_matches("*/").parse()?;
296
297                if step == 0 || step > max {
298                    return Err(ParseError::InvalidValue);
299                }
300
301                for i in (min..=max).step_by(step as usize) {
302                    values.insert(i);
303                }
304            }
305
306            // step with range, eg: 12-18/2
307            f if f.contains('/') => {
308                let tmp_fields: Vec<&str> = f.split('/').collect();
309
310                if tmp_fields.len() != 2 {
311                    return Err(ParseError::InvalidRange);
312                }
313
314                // get the step, eg: 2 from 12-18/2
315                let step: u32 = tmp_fields[1].parse()?;
316
317                if step == 0 || step > max {
318                    return Err(ParseError::InvalidValue);
319                }
320
321                // check for range, eg: 12-18
322                if tmp_fields[0].contains('-') {
323                    let tmp_range: Vec<&str> = tmp_fields[0].split('-').collect();
324
325                    if tmp_range.len() != 2 {
326                        return Err(ParseError::InvalidRange);
327                    }
328
329                    let start = parse_cron_value(tmp_range[0], min, max)?;
330
331                    let end = parse_cron_value(tmp_range[1], min, max)?;
332
333                    if start > end {
334                        return Err(ParseError::InvalidRange);
335                    }
336
337                    for i in (start..=end).step_by(step as usize) {
338                        values.insert(i);
339                    }
340                } else {
341                    let start = parse_cron_value(tmp_fields[0], min, max)?;
342
343                    for i in (start..=max).step_by(step as usize) {
344                        values.insert(i);
345                    }
346                }
347            }
348
349            // range of values, it can have days of week like Wed-Fri
350            f if f.contains('-') => {
351                let tmp_fields: Vec<&str> = f.split('-').collect();
352
353                if tmp_fields.len() != 2 {
354                    return Err(ParseError::InvalidRange);
355                }
356
357                let start = parse_cron_value(tmp_fields[0], min, max)?;
358
359                let end = parse_cron_value(tmp_fields[1], min, max)?;
360
361                if start > end {
362                    return Err(ParseError::InvalidRange);
363                }
364                for i in start..=end {
365                    values.insert(i);
366                }
367            }
368
369            // integers or days of week any other will return an error
370            _ => {
371                let value = parse_cron_value(field, min, max)?;
372                values.insert(value);
373            }
374        }
375    }
376
377    Ok(values)
378}
379
380// helper function to parse cron values
381fn parse_cron_value(value: &str, min: u32, max: u32) -> Result<u32, ParseError> {
382    if let Ok(dow) = Dow::from_str(value) {
383        Ok(dow as u32)
384    } else {
385        let v: u32 = value.parse()?;
386        if v < min || v > max {
387            return Err(ParseError::InvalidValue);
388        }
389        Ok(v)
390    }
391}
392
393// helper function to create UTC datetime, preferring earlier time in ambiguous cases
394fn make_utc_datetime(
395    year: i32,
396    month: u32,
397    day: u32,
398    hour: u32,
399    minute: u32,
400    second: u32,
401) -> Result<DateTime<Utc>, ParseError> {
402    match Utc.with_ymd_and_hms(year, month, day, hour, minute, second) {
403        chrono::LocalResult::Single(datetime) => Ok(datetime),
404        chrono::LocalResult::Ambiguous(earlier, _later) => Ok(earlier),
405        chrono::LocalResult::None => Err(ParseError::InvalidTimezone),
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    #[test]
414    fn test_make_utc_datetime_valid() {
415        // Valid datetime
416        let result = make_utc_datetime(2024, 1, 15, 10, 30, 45);
417        assert!(result.is_ok());
418        let dt = result.unwrap();
419        assert_eq!(dt.year(), 2024);
420        assert_eq!(dt.month(), 1);
421        assert_eq!(dt.day(), 15);
422        assert_eq!(dt.hour(), 10);
423        assert_eq!(dt.minute(), 30);
424        assert_eq!(dt.second(), 45);
425    }
426
427    #[test]
428    fn test_make_utc_datetime_leap_year() {
429        // Feb 29 in leap year should be valid
430        assert!(make_utc_datetime(2024, 2, 29, 12, 0, 0).is_ok());
431    }
432
433    #[test]
434    fn test_make_utc_datetime_invalid_date() {
435        // Feb 30 doesn't exist
436        assert!(make_utc_datetime(2024, 2, 30, 12, 0, 0).is_err());
437
438        // Feb 29 in non-leap year
439        assert!(make_utc_datetime(2023, 2, 29, 12, 0, 0).is_err());
440
441        // April 31 doesn't exist
442        assert!(make_utc_datetime(2024, 4, 31, 12, 0, 0).is_err());
443    }
444
445    #[test]
446    fn test_make_utc_datetime_invalid_time() {
447        // Invalid hour
448        assert!(make_utc_datetime(2024, 1, 15, 24, 0, 0).is_err());
449
450        // Invalid minute
451        assert!(make_utc_datetime(2024, 1, 15, 12, 60, 0).is_err());
452
453        // Invalid second
454        assert!(make_utc_datetime(2024, 1, 15, 12, 0, 60).is_err());
455    }
456
457    #[test]
458    fn test_make_utc_datetime_invalid_month() {
459        // Invalid month
460        assert!(make_utc_datetime(2024, 0, 15, 12, 0, 0).is_err());
461        assert!(make_utc_datetime(2024, 13, 15, 12, 0, 0).is_err());
462    }
463
464    #[test]
465    fn test_make_utc_datetime_boundary_values() {
466        // Minimum valid values
467        assert!(make_utc_datetime(2024, 1, 1, 0, 0, 0).is_ok());
468
469        // Maximum valid time in a day
470        assert!(make_utc_datetime(2024, 1, 1, 23, 59, 59).is_ok());
471
472        // December 31
473        assert!(make_utc_datetime(2024, 12, 31, 23, 59, 59).is_ok());
474    }
475
476    #[test]
477    fn test_parse_error_display() {
478        // Test InvalidCron
479        let err = ParseError::InvalidCron;
480        assert_eq!(format!("{}", err), "invalid cron");
481
482        // Test InvalidRange
483        let err = ParseError::InvalidRange;
484        assert_eq!(format!("{}", err), "invalid input");
485
486        // Test InvalidValue
487        let err = ParseError::InvalidValue;
488        assert_eq!(format!("{}", err), "invalid value");
489
490        // Test ParseIntError
491        let parse_int_err = "abc".parse::<u32>().unwrap_err();
492        let err = ParseError::ParseIntError(parse_int_err);
493        assert!(format!("{}", err).contains("invalid digit"));
494
495        // Test TryFromIntError
496        let try_from_err = u8::try_from(256u32).unwrap_err();
497        let err = ParseError::TryFromIntError(try_from_err);
498        assert!(format!("{}", err).contains("out of range"));
499
500        // Test InvalidTimezone
501        let err = ParseError::InvalidTimezone;
502        assert_eq!(format!("{}", err), "invalid timezone");
503    }
504
505    #[test]
506    fn test_parse_error_from_try_from_int_error() {
507        // Test From<TryFromIntError> conversion
508        let try_from_err = u8::try_from(256u32).unwrap_err();
509        let parse_err: ParseError = try_from_err.into();
510        assert!(matches!(parse_err, ParseError::TryFromIntError(_)));
511    }
512
513    #[test]
514    fn test_parse_error_implements_error_trait() {
515        // Test that ParseError implements Error trait
516        let err: Box<dyn Error> = Box::new(ParseError::InvalidCron);
517        assert_eq!(err.to_string(), "invalid cron");
518    }
519}