use crate::error::{Error, Result};
use std::collections::HashSet;
use std::fmt;
use std::str::FromStr;
#[cfg(feature = "chrono")]
use chrono::{DateTime, Datelike, TimeZone, Timelike, Utc};
#[derive(Debug, Clone, PartialEq)]
pub struct CronExpression {
pub seconds: Option<CronField>,
pub minutes: CronField,
pub hours: CronField,
pub day_of_month: CronField,
pub month: CronField,
pub day_of_week: CronField,
pub year: Option<CronField>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum CronField {
All,
Value(u32),
List(Vec<u32>),
Range(u32, u32),
Step(Box<CronField>, u32),
Last,
Weekday(u32),
LastWeekday(u32),
NthWeekday(u32, u32),
}
impl CronExpression {
pub fn parse(expression: &str) -> Result<Self> {
let fields: Vec<&str> = expression.trim().split_whitespace().collect();
match fields.len() {
5 => {
Ok(CronExpression {
seconds: None,
minutes: CronField::parse(fields[0], 0, 59)?,
hours: CronField::parse(fields[1], 0, 23)?,
day_of_month: CronField::parse(fields[2], 1, 31)?,
month: CronField::parse(fields[3], 1, 12)?,
day_of_week: CronField::parse(fields[4], 0, 7)?,
year: None,
})
}
6 => {
Ok(CronExpression {
seconds: Some(CronField::parse(fields[0], 0, 59)?),
minutes: CronField::parse(fields[1], 0, 59)?,
hours: CronField::parse(fields[2], 0, 23)?,
day_of_month: CronField::parse(fields[3], 1, 31)?,
month: CronField::parse(fields[4], 1, 12)?,
day_of_week: CronField::parse(fields[5], 0, 7)?,
year: None,
})
}
7 => {
Ok(CronExpression {
seconds: Some(CronField::parse(fields[0], 0, 59)?),
minutes: CronField::parse(fields[1], 0, 59)?,
hours: CronField::parse(fields[2], 0, 23)?,
day_of_month: CronField::parse(fields[3], 1, 31)?,
month: CronField::parse(fields[4], 1, 12)?,
day_of_week: CronField::parse(fields[5], 0, 7)?,
year: Some(CronField::parse(fields[6], 1970, 3000)?),
})
}
_ => Err(Error::validation(format!(
"Invalid cron expression format. Expected 5, 6, or 7 fields, got {}",
fields.len()
))),
}
}
pub fn validate(&self) -> Result<()> {
if let Some(ref seconds) = self.seconds {
seconds.validate(0, 59, "seconds")?;
}
self.minutes.validate(0, 59, "minutes")?;
self.hours.validate(0, 23, "hours")?;
self.day_of_month.validate(1, 31, "day_of_month")?;
self.month.validate(1, 12, "month")?;
self.day_of_week.validate(0, 7, "day_of_week")?;
if let Some(ref year) = self.year {
year.validate(1970, 3000, "year")?;
}
Ok(())
}
#[cfg(feature = "chrono")]
pub fn matches<Tz: TimeZone>(&self, datetime: &DateTime<Tz>) -> bool {
if let Some(ref seconds) = self.seconds {
if !seconds.matches(datetime.second()) {
return false;
}
}
if !self.minutes.matches(datetime.minute()) {
return false;
}
if !self.hours.matches(datetime.hour()) {
return false;
}
if !self.day_of_month.matches(datetime.day()) {
return false;
}
if !self.month.matches(datetime.month()) {
return false;
}
let weekday = datetime.weekday().num_days_from_sunday();
if !self.day_of_week.matches(weekday) {
return false;
}
if let Some(ref year) = self.year {
if !year.matches(datetime.year() as u32) {
return false;
}
}
true
}
#[cfg(feature = "chrono")]
pub fn next_execution(&self, after: &DateTime<Utc>) -> Option<DateTime<Utc>> {
let mut next = *after + chrono::Duration::minutes(1);
if self.seconds.is_none() {
next = next.with_second(0).unwrap().with_nanosecond(0).unwrap();
}
for _ in 0..366 * 24 * 60 { if self.matches(&next) {
return Some(next);
}
next = next + chrono::Duration::minutes(1);
}
None
}
pub fn get_matching_values(&self, field: &CronField, min: u32, max: u32) -> Vec<u32> {
let mut values = Vec::new();
for i in min..=max {
if field.matches(i) {
values.push(i);
}
}
values
}
}
impl CronField {
pub fn parse(field: &str, min: u32, max: u32) -> Result<Self> {
let field = field.trim();
if field == "*" {
return Ok(CronField::All);
}
if field == "L" {
return Ok(CronField::Last);
}
if field.contains('/') {
let parts: Vec<&str> = field.split('/').collect();
if parts.len() != 2 {
return Err(Error::validation(format!("Invalid step format: {}", field)));
}
let step: u32 = parts[1].parse()
.map_err(|_| Error::validation(format!("Invalid step value: {}", parts[1])))?;
if step == 0 {
return Err(Error::validation("Step value cannot be zero".to_string()));
}
let base = if parts[0] == "*" {
Box::new(CronField::All)
} else {
Box::new(CronField::parse(parts[0], min, max)?)
};
return Ok(CronField::Step(base, step));
}
if field.contains('-') {
let parts: Vec<&str> = field.split('-').collect();
if parts.len() != 2 {
return Err(Error::validation(format!("Invalid range format: {}", field)));
}
let start: u32 = parts[0].parse()
.map_err(|_| Error::validation(format!("Invalid range start: {}", parts[0])))?;
let end: u32 = parts[1].parse()
.map_err(|_| Error::validation(format!("Invalid range end: {}", parts[1])))?;
if start > end {
return Err(Error::validation(format!("Range start {} is greater than end {}", start, end)));
}
if start < min || end > max {
return Err(Error::validation(format!(
"Range [{}, {}] is out of bounds [{}, {}]",
start, end, min, max
)));
}
return Ok(CronField::Range(start, end));
}
if field.contains(',') {
let parts: Vec<&str> = field.split(',').collect();
let mut values = Vec::new();
for part in parts {
let value: u32 = part.trim().parse()
.map_err(|_| Error::validation(format!("Invalid list value: {}", part)))?;
if value < min || value > max {
return Err(Error::validation(format!(
"List value {} is out of range [{}, {}]",
value, min, max
)));
}
values.push(value);
}
values.sort();
values.dedup();
return Ok(CronField::List(values));
}
if field.ends_with('W') {
let day_str = &field[..field.len() - 1];
let day: u32 = day_str.parse()
.map_err(|_| Error::validation(format!("Invalid weekday expression: {}", field)))?;
return Ok(CronField::Weekday(day));
}
if field.ends_with('L') && field.len() > 1 {
let weekday_str = &field[..field.len() - 1];
let weekday: u32 = weekday_str.parse()
.map_err(|_| Error::validation(format!("Invalid last weekday expression: {}", field)))?;
return Ok(CronField::LastWeekday(weekday));
}
if field.contains('#') {
let parts: Vec<&str> = field.split('#').collect();
if parts.len() != 2 {
return Err(Error::validation(format!("Invalid nth weekday format: {}", field)));
}
let weekday: u32 = parts[0].parse()
.map_err(|_| Error::validation(format!("Invalid weekday in nth expression: {}", parts[0])))?;
let nth: u32 = parts[1].parse()
.map_err(|_| Error::validation(format!("Invalid nth value: {}", parts[1])))?;
return Ok(CronField::NthWeekday(weekday, nth));
}
let value: u32 = field.parse()
.map_err(|_| Error::validation(format!("Invalid numeric value: {}", field)))?;
if value < min || value > max {
return Err(Error::validation(format!(
"Value {} is out of range [{}, {}]",
value, min, max
)));
}
Ok(CronField::Value(value))
}
pub fn matches(&self, value: u32) -> bool {
match self {
CronField::All => true,
CronField::Value(v) => *v == value,
CronField::List(values) => values.contains(&value),
CronField::Range(start, end) => value >= *start && value <= *end,
CronField::Step(base, step) => {
if !base.matches(value) {
return false;
}
match base.as_ref() {
CronField::All => value % step == 0,
CronField::Range(start, _) => (value - start) % step == 0,
_ => value % step == 0,
}
}
CronField::Last => false, CronField::Weekday(_) => false, CronField::LastWeekday(_) => false, CronField::NthWeekday(_, _) => false, }
}
pub fn validate(&self, min: u32, max: u32, field_name: &str) -> Result<()> {
match self {
CronField::All => Ok(()),
CronField::Value(v) => {
if *v < min || *v > max {
Err(Error::validation(format!(
"{} value {} is out of range [{}, {}]",
field_name, v, min, max
)))
} else {
Ok(())
}
}
CronField::List(values) => {
for &v in values {
if v < min || v > max {
return Err(Error::validation(format!(
"{} value {} is out of range [{}, {}]",
field_name, v, min, max
)));
}
}
Ok(())
}
CronField::Range(start, end) => {
if *start < min || *end > max {
Err(Error::validation(format!(
"{} range [{}, {}] is out of bounds [{}, {}]",
field_name, start, end, min, max
)))
} else {
Ok(())
}
}
CronField::Step(base, step) => {
if *step == 0 {
return Err(Error::validation(format!("{} step cannot be zero", field_name)));
}
base.validate(min, max, field_name)
}
CronField::Last => Ok(()), CronField::Weekday(day) => {
if field_name == "day_of_month" && (*day < 1 || *day > 31) {
Err(Error::validation(format!("Weekday day {} is out of range [1, 31]", day)))
} else {
Ok(())
}
}
CronField::LastWeekday(weekday) => {
if *weekday > 7 {
Err(Error::validation(format!("Last weekday {} is out of range [0, 7]", weekday)))
} else {
Ok(())
}
}
CronField::NthWeekday(weekday, nth) => {
if *weekday > 7 {
Err(Error::validation(format!("Nth weekday {} is out of range [0, 7]", weekday)))
} else if *nth < 1 || *nth > 5 {
Err(Error::validation(format!("Nth occurrence {} is out of range [1, 5]", nth)))
} else {
Ok(())
}
}
}
}
pub fn get_values(&self, min: u32, max: u32) -> HashSet<u32> {
let mut values = HashSet::new();
match self {
CronField::All => {
for i in min..=max {
values.insert(i);
}
}
CronField::Value(v) => {
if *v >= min && *v <= max {
values.insert(*v);
}
}
CronField::List(list) => {
for &v in list {
if v >= min && v <= max {
values.insert(v);
}
}
}
CronField::Range(start, end) => {
let start = (*start).max(min);
let end = (*end).min(max);
for i in start..=end {
values.insert(i);
}
}
CronField::Step(base, step) => {
let base_values = base.get_values(min, max);
for &value in &base_values {
if value % step == 0 {
values.insert(value);
}
}
}
_ => {}
}
values
}
}
impl FromStr for CronExpression {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
CronExpression::parse(s)
}
}
impl fmt::Display for CronExpression {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut parts = Vec::new();
if let Some(ref seconds) = self.seconds {
parts.push(seconds.to_string());
}
parts.push(self.minutes.to_string());
parts.push(self.hours.to_string());
parts.push(self.day_of_month.to_string());
parts.push(self.month.to_string());
parts.push(self.day_of_week.to_string());
if let Some(ref year) = self.year {
parts.push(year.to_string());
}
write!(f, "{}", parts.join(" "))
}
}
impl fmt::Display for CronField {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CronField::All => write!(f, "*"),
CronField::Value(v) => write!(f, "{}", v),
CronField::List(values) => {
let strs: Vec<String> = values.iter().map(|v| v.to_string()).collect();
write!(f, "{}", strs.join(","))
}
CronField::Range(start, end) => write!(f, "{}-{}", start, end),
CronField::Step(base, step) => write!(f, "{}/{}", base, step),
CronField::Last => write!(f, "L"),
CronField::Weekday(day) => write!(f, "{}W", day),
CronField::LastWeekday(weekday) => write!(f, "{}L", weekday),
CronField::NthWeekday(weekday, nth) => write!(f, "{}#{}", weekday, nth),
}
}
}
pub struct CronBuilder;
impl CronBuilder {
pub fn every_minute() -> CronExpression {
CronExpression::parse("* * * * *").unwrap()
}
pub fn every_hour() -> CronExpression {
CronExpression::parse("0 * * * *").unwrap()
}
pub fn daily() -> CronExpression {
CronExpression::parse("0 0 * * *").unwrap()
}
pub fn daily_at(hour: u32, minute: u32) -> Result<CronExpression> {
CronExpression::parse(&format!("{} {} * * *", minute, hour))
}
pub fn weekly() -> CronExpression {
CronExpression::parse("0 0 * * 0").unwrap()
}
pub fn monthly() -> CronExpression {
CronExpression::parse("0 0 1 * *").unwrap()
}
pub fn every_n_minutes(n: u32) -> Result<CronExpression> {
if n == 0 || n > 59 {
return Err(Error::validation(format!("Invalid minute interval: {}", n)));
}
CronExpression::parse(&format!("*/{} * * * *", n))
}
pub fn every_n_hours(n: u32) -> Result<CronExpression> {
if n == 0 || n > 23 {
return Err(Error::validation(format!("Invalid hour interval: {}", n)));
}
CronExpression::parse(&format!("0 */{} * * *", n))
}
pub fn on_weekdays(weekdays: &[u32]) -> Result<CronExpression> {
for &day in weekdays {
if day > 7 {
return Err(Error::validation(format!("Invalid weekday: {}", day)));
}
}
let weekday_str = weekdays.iter()
.map(|d| d.to_string())
.collect::<Vec<_>>()
.join(",");
CronExpression::parse(&format!("0 0 * * {}", weekday_str))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_standard_cron() {
let expr = CronExpression::parse("*/5 0 * * 1").unwrap();
assert!(expr.seconds.is_none());
assert!(matches!(expr.minutes, CronField::Step(_, 5)));
assert!(matches!(expr.hours, CronField::Value(0)));
assert!(matches!(expr.day_of_month, CronField::All));
assert!(matches!(expr.month, CronField::All));
assert!(matches!(expr.day_of_week, CronField::Value(1)));
}
#[test]
fn test_parse_extended_cron() {
let expr = CronExpression::parse("0 */5 0 * * 1").unwrap();
assert!(expr.seconds.is_some());
assert!(matches!(expr.seconds, Some(CronField::Value(0))));
assert!(matches!(expr.minutes, CronField::Step(_, 5)));
}
#[test]
fn test_parse_range() {
let field = CronField::parse("1-5", 0, 10).unwrap();
assert!(matches!(field, CronField::Range(1, 5)));
assert!(field.matches(3));
assert!(!field.matches(6));
}
#[test]
fn test_parse_list() {
let field = CronField::parse("1,3,5", 0, 10).unwrap();
assert!(matches!(field, CronField::List(_)));
assert!(field.matches(1));
assert!(field.matches(3));
assert!(!field.matches(2));
}
#[test]
fn test_parse_step() {
let field = CronField::parse("*/2", 0, 10).unwrap();
assert!(matches!(field, CronField::Step(_, 2)));
assert!(field.matches(0));
assert!(field.matches(2));
assert!(!field.matches(1));
}
#[test]
fn test_validation() {
let expr = CronExpression::parse("0 0 1 1 0").unwrap();
assert!(expr.validate().is_ok());
let invalid_expr = CronExpression::parse("60 0 1 1 0");
assert!(invalid_expr.is_err());
}
#[test]
fn test_cron_builder() {
let expr = CronBuilder::every_minute();
assert!(matches!(expr.minutes, CronField::All));
let expr = CronBuilder::daily_at(9, 30).unwrap();
assert!(matches!(expr.hours, CronField::Value(9)));
assert!(matches!(expr.minutes, CronField::Value(30)));
let expr = CronBuilder::every_n_minutes(15).unwrap();
assert!(matches!(expr.minutes, CronField::Step(_, 15)));
}
#[test]
fn test_field_get_values() {
let field = CronField::parse("1,3,5", 0, 10).unwrap();
let values = field.get_values(0, 10);
assert_eq!(values.len(), 3);
assert!(values.contains(&1));
assert!(values.contains(&3));
assert!(values.contains(&5));
let field = CronField::parse("2-6", 0, 10).unwrap();
let values = field.get_values(0, 10);
assert_eq!(values.len(), 5);
for i in 2..=6 {
assert!(values.contains(&i));
}
}
#[cfg(feature = "chrono")]
#[test]
fn test_matches_datetime() {
use chrono::{TimeZone, Utc};
let expr = CronExpression::parse("0 9 * * 1").unwrap();
let monday_9am = Utc.with_ymd_and_hms(2023, 10, 2, 9, 0, 0).unwrap(); assert!(expr.matches(&monday_9am));
let monday_10am = Utc.with_ymd_and_hms(2023, 10, 2, 10, 0, 0).unwrap();
assert!(!expr.matches(&monday_10am));
let tuesday_9am = Utc.with_ymd_and_hms(2023, 10, 3, 9, 0, 0).unwrap();
assert!(!expr.matches(&tuesday_9am));
}
#[test]
fn test_display() {
let expr = CronExpression::parse("*/5 0 1-15 * 1,3,5").unwrap();
let displayed = expr.to_string();
assert!(displayed.contains("*/5"));
assert!(displayed.contains("1-15"));
assert!(displayed.contains("1,3,5"));
}
#[test]
fn test_error_cases() {
assert!(CronExpression::parse("* *").is_err());
assert!(CronExpression::parse("* * * * * * * *").is_err());
assert!(CronField::parse("*/0", 0, 59).is_err());
assert!(CronField::parse("60", 0, 59).is_err());
assert!(CronField::parse("1-60", 0, 59).is_err());
assert!(CronField::parse("5-1", 0, 59).is_err());
assert!(CronBuilder::every_n_minutes(0).is_err());
assert!(CronBuilder::every_n_minutes(60).is_err());
assert!(CronBuilder::daily_at(25, 0).is_err());
}
}