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
43enum Dow {
44    Sun = 0,
45    Mon = 1,
46    Tue = 2,
47    Wed = 3,
48    Thu = 4,
49    Fri = 5,
50    Sat = 6,
51}
52
53impl FromStr for Dow {
54    type Err = ();
55
56    fn from_str(s: &str) -> Result<Self, Self::Err> {
57        match &*s.to_uppercase() {
58            "SUN" => Ok(Self::Sun),
59            "MON" => Ok(Self::Mon),
60            "TUE" => Ok(Self::Tue),
61            "WED" => Ok(Self::Wed),
62            "THU" => Ok(Self::Thu),
63            "FRI" => Ok(Self::Fri),
64            "SAT" => Ok(Self::Sat),
65            _ => Err(()),
66        }
67    }
68}
69
70impl fmt::Display for ParseError {
71    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
72        match *self {
73            Self::InvalidCron => write!(f, "invalid cron"),
74            Self::InvalidRange => write!(f, "invalid input"),
75            Self::InvalidValue => write!(f, "invalid value"),
76            Self::ParseIntError(ref err) => err.fmt(f),
77            Self::TryFromIntError(ref err) => err.fmt(f),
78            Self::InvalidTimezone => write!(f, "invalid timezone"),
79        }
80    }
81}
82
83impl Error for ParseError {}
84
85impl From<num::ParseIntError> for ParseError {
86    #[must_use]
87    fn from(err: num::ParseIntError) -> Self {
88        Self::ParseIntError(err)
89    }
90}
91
92impl From<num::TryFromIntError> for ParseError {
93    #[must_use]
94    fn from(err: num::TryFromIntError) -> Self {
95        Self::TryFromIntError(err)
96    }
97}
98
99/// Parse cron expression
100/// ```text
101///
102/// ┌─────────────────────  minute (0 - 59)
103/// │ ┌───────────────────  hour   (0 - 23)
104/// │ │ ┌─────────────────  dom    (1 - 31) day of month
105/// │ │ │ ┌───────────────  month  (1 - 12)
106/// │ │ │ │ ┌─────────────  dow    (0 - 6 or Sun - Sat) day of week (Sunday to Saturday)
107/// │ │ │ │ │
108/// │ │ │ │ │
109/// │ │ │ │ │
110/// * * * * * <command to execute>
111/// ```
112///
113/// Example
114/// ```
115/// use cron_parser::parse;
116/// use chrono::Utc;
117///
118/// assert!(parse("*/5 * * * *", &Utc::now()).is_ok());
119///
120/// // use custom timezone
121/// use chrono_tz::US::Pacific;
122/// assert!(parse("*/5 * * * *", &Utc::now().with_timezone(&Pacific)).is_ok());
123/// ```
124/// # Errors
125/// [`ParseError`](enum.ParseError.html)
126pub fn parse<TZ: TimeZone>(cron: &str, dt: &DateTime<TZ>) -> Result<DateTime<TZ>, ParseError> {
127    let tz = dt.timezone();
128
129    let fields: Vec<&str> = cron.split_whitespace().collect();
130
131    if fields.len() != 5 {
132        return Err(ParseError::InvalidCron);
133    }
134
135    let mut next = match Utc.from_local_datetime(&dt.naive_local()) {
136        chrono::LocalResult::Single(datetime) => datetime + Duration::minutes(1),
137        chrono::LocalResult::Ambiguous(earlier, _later) => earlier + Duration::minutes(1),
138        chrono::LocalResult::None => return Err(ParseError::InvalidTimezone),
139    };
140
141    next = match Utc.with_ymd_and_hms(
142        next.year(),
143        next.month(),
144        next.day(),
145        next.hour(),
146        next.minute(),
147        0,
148    ) {
149        chrono::LocalResult::Single(datetime) => datetime,
150        chrono::LocalResult::Ambiguous(earlier, _later) => earlier,
151        chrono::LocalResult::None => return Err(ParseError::InvalidTimezone),
152    };
153
154    let result = loop {
155        // only try until next leap year
156        if next.year() - dt.year() > 4 {
157            return Err(ParseError::InvalidCron);
158        }
159
160        // * * * <month> *
161        let month = parse_field(fields[3], 1, 12)?;
162        if !month.contains(&next.month()) {
163            next = match Utc.with_ymd_and_hms(
164                if next.month() == 12 {
165                    next.year() + 1
166                } else {
167                    next.year()
168                },
169                if next.month() == 12 {
170                    1
171                } else {
172                    next.month() + 1
173                },
174                1,
175                0,
176                0,
177                0,
178            ) {
179                chrono::LocalResult::Single(datetime) => datetime,
180                chrono::LocalResult::Ambiguous(earlier, _later) => earlier,
181                chrono::LocalResult::None => return Err(ParseError::InvalidTimezone),
182            };
183            continue;
184        }
185
186        // * * <dom> * *
187        let do_m = parse_field(fields[2], 1, 31)?;
188        if !do_m.contains(&next.day()) {
189            next += Duration::days(1);
190            next = match Utc.with_ymd_and_hms(next.year(), next.month(), next.day(), 0, 0, 0) {
191                chrono::LocalResult::Single(datetime) => datetime,
192                chrono::LocalResult::Ambiguous(earlier, _later) => earlier,
193                chrono::LocalResult::None => return Err(ParseError::InvalidTimezone),
194            };
195            continue;
196        }
197
198        // * <hour> * * *
199        let hour = parse_field(fields[1], 0, 23)?;
200        if !hour.contains(&next.hour()) {
201            next += Duration::hours(1);
202            next = match Utc.with_ymd_and_hms(
203                next.year(),
204                next.month(),
205                next.day(),
206                next.hour(),
207                0,
208                0,
209            ) {
210                chrono::LocalResult::Single(datetime) => datetime,
211                chrono::LocalResult::Ambiguous(earlier, _later) => earlier,
212                chrono::LocalResult::None => return Err(ParseError::InvalidTimezone),
213            };
214            continue;
215        }
216
217        // <minute> * * * *
218        let minute = parse_field(fields[0], 0, 59)?;
219        if !minute.contains(&next.minute()) {
220            next += Duration::minutes(1);
221            continue;
222        }
223
224        // * * * * <dow>
225        let do_w = parse_field(fields[4], 0, 6)?;
226        if !do_w.contains(&next.weekday().num_days_from_sunday()) {
227            next += Duration::days(1);
228            continue;
229        }
230
231        // Valid datetime for the timezone
232        match tz.from_local_datetime(&next.naive_local()) {
233            chrono::LocalResult::Single(dt) => break dt,
234            chrono::LocalResult::Ambiguous(earlier, _later) => break earlier,
235            chrono::LocalResult::None => {
236                next += Duration::minutes(1);
237                continue;
238            }
239        }
240    };
241
242    Ok(result)
243}
244
245/// `parse_field`
246/// Allowed special characters:
247/// * `*` any value
248/// * `,` value list separator
249/// * `-` range of values
250/// * `/` step values
251///
252/// ```text
253/// minutes min: 0, max: 59
254/// hours   min: 0, max: 23
255/// days    min: 1, max: 31
256/// month   min: 1, max: 12
257/// dow     min: 0, max: 6 or min: Sun, max Sat
258///
259/// Day of week (dow):
260///    Sun = 0
261///    Mon = 1
262///    Tue = 2
263///    Wed = 3
264///    Thu = 4
265///    Fri = 5
266///    Sat = 6
267/// ```
268///
269/// The field column can have a `*` or a list of elements separated by commas.
270/// An element is either a number in the ranges or two numbers in the range
271/// separated by a hyphen, slashes can be combined with ranges to specify
272/// step values
273///
274/// Example
275/// ```
276/// use cron_parser::parse_field;
277/// use std::collections::BTreeSet;
278///
279///  // every 3 months
280///  assert_eq!(parse_field("*/3", 1, 12).unwrap(),
281///  BTreeSet::<u32>::from([1,4,7,10].iter().cloned().collect::<BTreeSet<u32>>()));
282///
283///  // day 31
284///  assert_eq!(parse_field("31", 1, 31).unwrap(),
285///  BTreeSet::<u32>::from([31].iter().cloned().collect::<BTreeSet<u32>>()));
286///
287///  // every minute from 40 through 50
288///  assert_eq!(parse_field("40-50", 0, 59).unwrap(),
289///  BTreeSet::<u32>::from([40,41,42,43,44,45,46,47,48,49,50].iter().cloned().collect::<BTreeSet<u32>>()));
290///
291///  // at hour 3,15,23
292///  assert_eq!(parse_field("15,3,23", 0, 23).unwrap(),
293///  BTreeSet::<u32>::from([3,15,23].iter().cloned().collect::<BTreeSet<u32>>()));
294/// ```
295///
296/// Parses a cron field, supporting formats like:
297/// `*/N`, `<start>/<step>`, ranges (`min-max`), and lists (`1,2,3`).
298///
299/// # Errors
300/// [`ParseError`](enum.ParseError.html)
301pub fn parse_field(field: &str, min: u32, max: u32) -> Result<BTreeSet<u32>, ParseError> {
302    let mut values = BTreeSet::<u32>::new();
303
304    // split fields by ','
305    let fields: Vec<&str> = field.split(',').filter(|s| !s.is_empty()).collect();
306
307    // iterate over the fields and match against allowed characters
308    for field in fields {
309        match field {
310            // any
311            "*" => {
312                for i in min..=max {
313                    values.insert(i);
314                }
315            }
316
317            // step values
318            f if f.starts_with("*/") => {
319                let step: u32 = f.trim_start_matches("*/").parse()?;
320
321                if step == 0 || step > max {
322                    return Err(ParseError::InvalidValue);
323                }
324
325                for i in (min..=max).step_by(step as usize) {
326                    values.insert(i);
327                }
328            }
329
330            // step with range, eg: 12-18/2
331            f if f.contains('/') => {
332                let tmp_fields: Vec<&str> = f.split('/').collect();
333
334                if tmp_fields.len() != 2 {
335                    return Err(ParseError::InvalidRange);
336                }
337
338                // get the step, eg: 2 from 12-18/2
339                let step: u32 = tmp_fields[1].parse()?;
340
341                if step == 0 || step > max {
342                    return Err(ParseError::InvalidValue);
343                }
344
345                // check for range, eg: 12-18
346                if tmp_fields[0].contains('-') {
347                    let tmp_range: Vec<&str> = tmp_fields[0].split('-').collect();
348
349                    if tmp_range.len() != 2 {
350                        return Err(ParseError::InvalidRange);
351                    }
352
353                    let start = parse_cron_value(tmp_range[0], min, max)?;
354
355                    let end = parse_cron_value(tmp_range[1], min, max)?;
356
357                    if start > end {
358                        return Err(ParseError::InvalidRange);
359                    }
360
361                    for i in (start..=end).step_by(step as usize) {
362                        values.insert(i);
363                    }
364                } else {
365                    let start = parse_cron_value(tmp_fields[0], min, max)?;
366
367                    for i in (start..=max).step_by(step as usize) {
368                        values.insert(i);
369                    }
370                }
371            }
372
373            // range of values, it can have days of week like Wed-Fri
374            f if f.contains('-') => {
375                let tmp_fields: Vec<&str> = f.split('-').collect();
376
377                if tmp_fields.len() != 2 {
378                    return Err(ParseError::InvalidRange);
379                }
380
381                let start = parse_cron_value(tmp_fields[0], min, max)?;
382
383                let end = parse_cron_value(tmp_fields[1], min, max)?;
384
385                if start > end {
386                    return Err(ParseError::InvalidRange);
387                }
388                for i in start..=end {
389                    values.insert(i);
390                }
391            }
392
393            // integers or days of week any other will return an error
394            _ => {
395                let value = parse_cron_value(field, min, max)?;
396                values.insert(value);
397            }
398        }
399    }
400
401    Ok(values)
402}
403
404// helper function to parse cron values
405fn parse_cron_value(value: &str, min: u32, max: u32) -> Result<u32, ParseError> {
406    if let Ok(dow) = Dow::from_str(value) {
407        Ok(dow as u32)
408    } else {
409        let v: u32 = value.parse()?;
410        if v < min || v > max {
411            return Err(ParseError::InvalidValue);
412        }
413        Ok(v)
414    }
415}