cron-parser 0.3.1

Library for parsing cron syntax
Documentation
//! Library for parsing cron syntax, returning the next available date.
//!
//! Example:
//! ```
//! use chrono::{TimeZone, Utc};
//! use cron_parser::parse;
//!
//! fn main() {
//!    if let Ok(next) = parse("*/5 * * * *", Utc::now()) {
//!         println!("when: {}", next);
//!    }
//!
//!    // passing a custom timestamp
//!    if let Ok(next) = parse("0 0 29 2 *", Utc.timestamp(1893456000, 0)) {
//!         println!("next leap year: {}", next);
//!         assert_eq!(next.timestamp(), 1961625600);
//!    }
//!
//!    assert!(parse("2-3,9,*/15,1-8,11,9,4,5 * * * *", Utc::now()).is_ok());
//!    assert!(parse("* * * * */Fri", Utc::now()).is_err());
//! }
//! ```
use chrono::{DateTime, Datelike, Duration, TimeZone, Timelike, Utc};
use std::{collections::BTreeSet, convert::TryFrom, error::Error, fmt, num};

#[derive(Debug)]
pub enum ParseError {
    InvalidCron,
    InvalidRange,
    InvalidValue,
    ParseIntError(num::ParseIntError),
    TryFromIntError(num::TryFromIntError),
}

enum Dow {
    Sun = 0,
    Mon = 1,
    Tue = 2,
    Wed = 3,
    Thu = 4,
    Fri = 5,
    Sat = 6,
}

impl TryFrom<&str> for Dow {
    type Error = ();

    fn try_from(val: &str) -> Result<Self, Self::Error> {
        match &*val.to_uppercase() {
            "SUN" => Ok(Dow::Sun),
            "MON" => Ok(Dow::Mon),
            "TUE" => Ok(Dow::Tue),
            "WED" => Ok(Dow::Wed),
            "THU" => Ok(Dow::Thu),
            "FRI" => Ok(Dow::Fri),
            "SAT" => Ok(Dow::Sat),
            _ => Err(()),
        }
    }
}

impl fmt::Display for ParseError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match *self {
            ParseError::InvalidCron => write!(f, "invalid cron"),
            ParseError::InvalidRange => write!(f, "invalid input"),
            ParseError::InvalidValue => write!(f, "invalid value"),
            ParseError::ParseIntError(ref err) => err.fmt(f),
            ParseError::TryFromIntError(ref err) => err.fmt(f),
        }
    }
}
impl Error for ParseError {}

impl From<num::ParseIntError> for ParseError {
    fn from(err: num::ParseIntError) -> Self {
        ParseError::ParseIntError(err)
    }
}

impl From<num::TryFromIntError> for ParseError {
    fn from(err: num::TryFromIntError) -> Self {
        ParseError::TryFromIntError(err)
    }
}

/// Parse cron syntax
/// ```text
///
/// ┌─────────────────────  minute (0 - 59)
/// │ ┌───────────────────  hour   (0 - 23)
/// │ │ ┌─────────────────  dom    (1 - 31) day of month
/// │ │ │ ┌───────────────  month  (1 - 12)
/// │ │ │ │ ┌─────────────  dow    (0 - 6 or Sun - Sat) day of week (Sunday to Saturday)
/// │ │ │ │ │
/// │ │ │ │ │
/// │ │ │ │ │
/// * * * * * command to execute
/// ```
///
/// Example
/// ```
/// use cron_parser::parse;
/// use chrono::Utc;
///
/// fn main() {
///     assert!(parse("*/5 * * * *", Utc::now()).is_ok());
/// }
/// ```
pub fn parse(cron: &str, dt: DateTime<Utc>) -> Result<DateTime<Utc>, ParseError> {
    let mut next = dt + Duration::minutes(1);
    let fields: Vec<&str> = cron.split_whitespace().collect();
    if fields.len() > 5 {
        return Err(ParseError::InvalidCron);
    }

    next = Utc
        .ymd(next.year(), next.month(), next.day())
        .and_hms(next.hour(), next.minute(), 0);

    loop {
        // only try until next leap year
        if next.year() - dt.year() > 4 {
            return Err(ParseError::InvalidCron);
        }

        // * * * * <month>
        let month = parse_field(fields[3], 1, 12)?;
        if !month.contains(&next.month()) {
            if next.month() == 12 {
                next = Utc.ymd(next.year() + 1, 1, 1).and_hms(0, 0, 0);
            } else {
                next = Utc.ymd(next.year(), next.month() + 1, 1).and_hms(0, 0, 0);
            }
            continue;
        }

        // * * * <dom> *
        let dom = parse_field(fields[2], 1, 31)?;
        if !dom.contains(&next.day()) {
            next = next + Duration::days(1);
            next = Utc
                .ymd(next.year(), next.month(), next.day())
                .and_hms(0, 0, 0);
            continue;
        }

        // * <hour> * * *
        let hour = parse_field(fields[1], 0, 23)?;
        if !hour.contains(&next.hour()) {
            next = next + Duration::hours(1);
            next = Utc
                .ymd(next.year(), next.month(), next.day())
                .and_hms(next.hour(), 0, 0);
            continue;
        }

        // <minute> * * * *
        let minute = parse_field(fields[0], 0, 59)?;
        if !minute.contains(&next.minute()) {
            next = next + Duration::minutes(1);
            continue;
        }

        // * * * * <dow>
        let dow = parse_field(fields[4], 0, 6)?;
        if !dow.contains(&next.weekday().num_days_from_sunday()) {
            next = next + Duration::days(1);
            continue;
        }

        break;
    }

    Ok(next)
}

/// parse_field
/// Allowed special characters:
/// * `*` any value
/// * `,` value list separator
/// * `-` range of values
/// * `/` step values
///
/// ```text
/// minutes min: 0, max: 59
/// hours   min: 0, max: 23
/// days    min: 1, max: 31
/// month   min: 1, max: 12
/// dweek   min: 0, max: 6 or min: Sun, max Sat
///
/// Day of week
///    Sun = 0
///    Mon = 1
///    Tue = 2
///    Wed = 3
///    Thu = 4
///    Fri = 5
///    Sat = 6
/// ```
///
/// The field column can have a `*` or a list of elements separated by commas.
/// An element is either a number in the ranges or two numbers in the range
/// separated by a hyphen, slashes can be combined with ranges to specify
/// step values
///
/// Example
/// ```
/// use cron_parser::parse_field;
/// use std::collections::BTreeSet;
///
/// fn main() {
///      // every 3 months
///      assert_eq!(parse_field("*/3", 1, 12).unwrap(),
///      BTreeSet::<u32>::from([1,4,7,10].iter().cloned().collect()));
///
///      // day 31
///      assert_eq!(parse_field("31", 1, 31).unwrap(),
///      BTreeSet::<u32>::from([31].iter().cloned().collect()));
///
///      // every minute from 40 through 50
///      assert_eq!(parse_field("40-50", 0, 59).unwrap(),
///      BTreeSet::<u32>::from([40,41,42,43,44,45,46,47,48,49,50].iter().cloned().collect()));
///
///      // at hour 3,15,23
///      assert_eq!(parse_field("15,3,23", 0, 23).unwrap(),
///      BTreeSet::<u32>::from([3,15,23].iter().cloned().collect()));
/// }
/// ```
pub fn parse_field(field: &str, min: u32, max: u32) -> Result<BTreeSet<u32>, ParseError> {
    // set of integers
    let mut values = BTreeSet::<u32>::new();
    let fields: Vec<&str> = field.split(',').filter(|s| !s.is_empty()).collect();
    let dow = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];

    for field in fields.into_iter() {
        match field {
            day if dow.contains(&field.to_uppercase().as_str()) => {
                values.insert(Dow::try_from(day).unwrap() as u32);
            }
            "*" => {
                for i in min..=max {
                    values.insert(i);
                }
            }
            // step values
            f if field.starts_with("*/") => {
                let f: u32 = f.trim_start_matches("*/").parse()?;
                if f > max {
                    return Err(ParseError::InvalidValue);
                }
                let step = usize::try_from(f)?;
                for i in (min..=max).step_by(step).collect::<Vec<u32>>() {
                    values.insert(i);
                }
            }
            // range of values
            f if f.contains('-') => {
                let tmp_fields: Vec<&str> = f.split('-').collect();
                if tmp_fields.len() != 2 {
                    return Err(ParseError::InvalidRange);
                }

                let mut fields: Vec<u32> = Vec::new();

                if dow.contains(&tmp_fields[0].to_uppercase().as_str()) {
                    fields.push(Dow::try_from(tmp_fields[0]).unwrap() as u32);
                } else {
                    fields.push(tmp_fields[0].parse::<u32>()?);
                };

                if dow.contains(&tmp_fields[1].to_uppercase().as_str()) {
                    fields.push(Dow::try_from(tmp_fields[1]).unwrap() as u32);
                } else {
                    fields.push(tmp_fields[1].parse::<u32>()?);
                }

                if fields[0] > fields[1] || fields[1] > max {
                    return Err(ParseError::InvalidRange);
                }
                for i in (fields[0]..=fields[1]).collect::<Vec<u32>>() {
                    values.insert(i);
                }
            }
            _ => {
                let f = field.parse::<u32>()?;
                if f > max {
                    return Err(ParseError::InvalidValue);
                }
                values.insert(f);
            }
        }
    }
    Ok(values)
}