use chrono::{DateTime, Datelike, Duration, TimeZone, Timelike, Utc};
use std::{collections::BTreeSet, error::Error, fmt, num, str::FromStr};
#[derive(Debug)]
pub enum ParseError {
InvalidCron,
InvalidRange,
InvalidValue,
ParseIntError(num::ParseIntError),
TryFromIntError(num::TryFromIntError),
InvalidTimezone,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Dow {
Sun = 0,
Mon = 1,
Tue = 2,
Wed = 3,
Thu = 4,
Fri = 5,
Sat = 6,
}
impl FromStr for Dow {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match &*s.to_uppercase() {
"SUN" => Ok(Self::Sun),
"MON" => Ok(Self::Mon),
"TUE" => Ok(Self::Tue),
"WED" => Ok(Self::Wed),
"THU" => Ok(Self::Thu),
"FRI" => Ok(Self::Fri),
"SAT" => Ok(Self::Sat),
_ => Err(()),
}
}
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
Self::InvalidCron => write!(f, "invalid cron"),
Self::InvalidRange => write!(f, "invalid input"),
Self::InvalidValue => write!(f, "invalid value"),
Self::ParseIntError(ref err) => err.fmt(f),
Self::TryFromIntError(ref err) => err.fmt(f),
Self::InvalidTimezone => write!(f, "invalid timezone"),
}
}
}
impl Error for ParseError {}
impl From<num::ParseIntError> for ParseError {
fn from(err: num::ParseIntError) -> Self {
Self::ParseIntError(err)
}
}
impl From<num::TryFromIntError> for ParseError {
fn from(err: num::TryFromIntError) -> Self {
Self::TryFromIntError(err)
}
}
pub fn parse<TZ: TimeZone>(cron: &str, dt: &DateTime<TZ>) -> Result<DateTime<TZ>, ParseError> {
let tz = dt.timezone();
let fields: Vec<&str> = cron.split_whitespace().collect();
let [
minute_str,
hour_str,
day_of_month_str,
month_str,
day_of_week_str,
] = fields.as_slice()
else {
return Err(ParseError::InvalidCron);
};
let mut next = match Utc.from_local_datetime(&dt.naive_local()) {
chrono::LocalResult::Single(datetime) => datetime + Duration::minutes(1),
chrono::LocalResult::Ambiguous(earlier, _later) => earlier + Duration::minutes(1),
chrono::LocalResult::None => return Err(ParseError::InvalidTimezone),
};
next = make_utc_datetime(
next.year(),
next.month(),
next.day(),
next.hour(),
next.minute(),
0,
)?;
let result = loop {
if next.year() - dt.year() > 4 {
return Err(ParseError::InvalidCron);
}
let month = parse_field(month_str, 1, 12)?;
if !month.contains(&next.month()) {
next = make_utc_datetime(
if next.month() == 12 {
next.year() + 1
} else {
next.year()
},
if next.month() == 12 {
1
} else {
next.month() + 1
},
1,
0,
0,
0,
)?;
continue;
}
let do_m = parse_field(day_of_month_str, 1, 31)?;
if !do_m.contains(&next.day()) {
next += Duration::days(1);
next = make_utc_datetime(next.year(), next.month(), next.day(), 0, 0, 0)?;
continue;
}
let hour = parse_field(hour_str, 0, 23)?;
if !hour.contains(&next.hour()) {
next += Duration::hours(1);
next = make_utc_datetime(next.year(), next.month(), next.day(), next.hour(), 0, 0)?;
continue;
}
let minute = parse_field(minute_str, 0, 59)?;
if !minute.contains(&next.minute()) {
next += Duration::minutes(1);
continue;
}
let do_w = parse_field(day_of_week_str, 0, 6)?;
if !do_w.contains(&next.weekday().num_days_from_sunday()) {
next += Duration::days(1);
continue;
}
match tz.from_local_datetime(&next.naive_local()) {
chrono::LocalResult::Single(dt) => break dt,
chrono::LocalResult::Ambiguous(earlier, _later) => break earlier,
chrono::LocalResult::None => {
next += Duration::minutes(1);
}
}
};
Ok(result)
}
pub fn parse_field(field: &str, min: u32, max: u32) -> Result<BTreeSet<u32>, ParseError> {
let mut values = BTreeSet::<u32>::new();
let fields: Vec<&str> = field.split(',').filter(|s| !s.is_empty()).collect();
for field in fields {
match field {
"*" => {
for i in min..=max {
values.insert(i);
}
}
f if f.starts_with("*/") => {
let step: u32 = f.trim_start_matches("*/").parse()?;
if step == 0 || step > max {
return Err(ParseError::InvalidValue);
}
for i in (min..=max).step_by(step as usize) {
values.insert(i);
}
}
f if f.contains('/') => {
let tmp_fields: Vec<&str> = f.split('/').collect();
let [range_part, step_part] = tmp_fields.as_slice() else {
return Err(ParseError::InvalidRange);
};
let step: u32 = step_part.parse()?;
if step == 0 || step > max {
return Err(ParseError::InvalidValue);
}
if range_part.contains('-') {
let tmp_range: Vec<&str> = range_part.split('-').collect();
let [start_str, end_str] = tmp_range.as_slice() else {
return Err(ParseError::InvalidRange);
};
let start = parse_cron_value(start_str, min, max)?;
let end = parse_cron_value(end_str, min, max)?;
if start > end {
return Err(ParseError::InvalidRange);
}
for i in (start..=end).step_by(step as usize) {
values.insert(i);
}
} else {
let start = parse_cron_value(range_part, min, max)?;
for i in (start..=max).step_by(step as usize) {
values.insert(i);
}
}
}
f if f.contains('-') => {
let tmp_fields: Vec<&str> = f.split('-').collect();
let [start_str, end_str] = tmp_fields.as_slice() else {
return Err(ParseError::InvalidRange);
};
let start = parse_cron_value(start_str, min, max)?;
let end = parse_cron_value(end_str, min, max)?;
if start > end {
return Err(ParseError::InvalidRange);
}
for i in start..=end {
values.insert(i);
}
}
_ => {
let value = parse_cron_value(field, min, max)?;
values.insert(value);
}
}
}
Ok(values)
}
fn parse_cron_value(value: &str, min: u32, max: u32) -> Result<u32, ParseError> {
if let Ok(dow) = Dow::from_str(value) {
Ok(dow as u32)
} else {
let v: u32 = value.parse()?;
if v < min || v > max {
return Err(ParseError::InvalidValue);
}
Ok(v)
}
}
fn make_utc_datetime(
year: i32,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: u32,
) -> Result<DateTime<Utc>, ParseError> {
match Utc.with_ymd_and_hms(year, month, day, hour, minute, second) {
chrono::LocalResult::Single(datetime) => Ok(datetime),
chrono::LocalResult::Ambiguous(earlier, _later) => Ok(earlier),
chrono::LocalResult::None => Err(ParseError::InvalidTimezone),
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn test_make_utc_datetime_valid() {
let result = make_utc_datetime(2024, 1, 15, 10, 30, 45);
assert!(result.is_ok());
let dt = result.expect("Should be valid");
assert_eq!(dt.year(), 2024);
assert_eq!(dt.month(), 1);
assert_eq!(dt.day(), 15);
assert_eq!(dt.hour(), 10);
assert_eq!(dt.minute(), 30);
assert_eq!(dt.second(), 45);
}
#[test]
fn test_make_utc_datetime_leap_year() {
assert!(make_utc_datetime(2024, 2, 29, 12, 0, 0).is_ok());
}
#[test]
fn test_make_utc_datetime_invalid_date() {
assert!(make_utc_datetime(2024, 2, 30, 12, 0, 0).is_err());
assert!(make_utc_datetime(2023, 2, 29, 12, 0, 0).is_err());
assert!(make_utc_datetime(2024, 4, 31, 12, 0, 0).is_err());
}
#[test]
fn test_make_utc_datetime_invalid_time() {
assert!(make_utc_datetime(2024, 1, 15, 24, 0, 0).is_err());
assert!(make_utc_datetime(2024, 1, 15, 12, 60, 0).is_err());
assert!(make_utc_datetime(2024, 1, 15, 12, 0, 60).is_err());
}
#[test]
fn test_make_utc_datetime_invalid_month() {
assert!(make_utc_datetime(2024, 0, 15, 12, 0, 0).is_err());
assert!(make_utc_datetime(2024, 13, 15, 12, 0, 0).is_err());
}
#[test]
fn test_make_utc_datetime_boundary_values() {
assert!(make_utc_datetime(2024, 1, 1, 0, 0, 0).is_ok());
assert!(make_utc_datetime(2024, 1, 1, 23, 59, 59).is_ok());
assert!(make_utc_datetime(2024, 12, 31, 23, 59, 59).is_ok());
}
#[test]
fn test_parse_error_display() {
let err = ParseError::InvalidCron;
assert_eq!(format!("{err}"), "invalid cron");
let err = ParseError::InvalidRange;
assert_eq!(format!("{err}"), "invalid input");
let err = ParseError::InvalidValue;
assert_eq!(format!("{err}"), "invalid value");
let parse_int_err = "abc".parse::<u32>().expect_err("Should fail");
let err = ParseError::ParseIntError(parse_int_err);
assert!(format!("{err}").contains("invalid digit"));
let try_from_err = u8::try_from(256u32).expect_err("Should fail");
let err = ParseError::TryFromIntError(try_from_err);
assert!(format!("{err}").contains("out of range"));
let err = ParseError::InvalidTimezone;
assert_eq!(format!("{err}"), "invalid timezone");
}
#[test]
fn test_parse_error_from_try_from_int_error() {
let try_from_err = u8::try_from(256u32).expect_err("Should fail");
let parse_err: ParseError = try_from_err.into();
assert!(matches!(parse_err, ParseError::TryFromIntError(_)));
}
#[test]
fn test_parse_error_implements_error_trait() {
let err: Box<dyn Error> = Box::new(ParseError::InvalidCron);
assert_eq!(err.to_string(), "invalid cron");
}
}