use chrono::{Datelike, Local, Timelike};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DayOfWeek {
Sunday = 0,
Monday = 1,
Tuesday = 2,
Wednesday = 3,
Thursday = 4,
Friday = 5,
Saturday = 6,
}
impl DayOfWeek {
pub fn from_chrono(weekday: chrono::Weekday) -> Self {
match weekday {
chrono::Weekday::Sun => DayOfWeek::Sunday,
chrono::Weekday::Mon => DayOfWeek::Monday,
chrono::Weekday::Tue => DayOfWeek::Tuesday,
chrono::Weekday::Wed => DayOfWeek::Wednesday,
chrono::Weekday::Thu => DayOfWeek::Thursday,
chrono::Weekday::Fri => DayOfWeek::Friday,
chrono::Weekday::Sat => DayOfWeek::Saturday,
}
}
}
#[derive(Debug, Clone)]
pub struct CronExpression {
raw: String,
minute: CronField,
hour: CronField,
day_of_month: CronField,
month: CronField,
day_of_week: CronField,
}
#[derive(Debug, Clone)]
enum CronField {
Any, Value(u32), Range(u32, u32), Step(u32), List(Vec<u32>), StepFrom(u32, u32), }
impl CronField {
fn matches(&self, value: u32) -> bool {
match self {
CronField::Any => true,
CronField::Value(v) => *v == value,
CronField::Range(start, end) => value >= *start && value <= *end,
CronField::Step(step) => value.is_multiple_of(*step),
CronField::StepFrom(start, step) => {
value >= *start && (value - start).is_multiple_of(*step)
}
CronField::List(values) => values.contains(&value),
}
}
fn parse(s: &str) -> Result<Self, String> {
if s == "*" {
return Ok(CronField::Any);
}
if let Some(step_str) = s.strip_prefix("*/") {
let step: u32 = step_str
.parse()
.map_err(|_| format!("Invalid step value in '{s}'"))?;
return Ok(CronField::Step(step));
}
if s.contains('/') && !s.starts_with('*') {
let parts: Vec<&str> = s.split('/').collect();
if parts.len() == 2 {
let start: u32 = parts[0]
.parse()
.map_err(|_| format!("Invalid start value in '{s}'"))?;
let step: u32 = parts[1]
.parse()
.map_err(|_| format!("Invalid step value in '{s}'"))?;
return Ok(CronField::StepFrom(start, step));
}
}
if s.contains(',') {
let values: Result<Vec<u32>, _> = s.split(',').map(|v| v.trim().parse()).collect();
return Ok(CronField::List(
values.map_err(|_| format!("Invalid list value in '{s}'"))?,
));
}
if s.contains('-') {
let parts: Vec<&str> = s.split('-').collect();
if parts.len() == 2 {
let start: u32 = parts[0]
.parse()
.map_err(|_| format!("Invalid range start in '{s}'"))?;
let end: u32 = parts[1]
.parse()
.map_err(|_| format!("Invalid range end in '{s}'"))?;
return Ok(CronField::Range(start, end));
}
}
let value: u32 = s.parse().map_err(|_| format!("Invalid value in '{s}'"))?;
Ok(CronField::Value(value))
}
}
impl std::fmt::Display for CronField {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CronField::Any => write!(f, "*"),
CronField::Value(v) => write!(f, "{v}"),
CronField::Range(s, e) => write!(f, "{s}-{e}"),
CronField::Step(s) => write!(f, "*/{s}"),
CronField::StepFrom(start, step) => write!(f, "{start}/{step}"),
CronField::List(l) => {
let s: String = l
.iter()
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join(",");
write!(f, "{s}")
}
}
}
}
impl CronExpression {
pub fn parse(expression: &str) -> Result<Self, String> {
let parts: Vec<&str> = expression.split_whitespace().collect();
if parts.len() != 5 {
return Err(format!(
"Cron expression must have 5 fields, got {}",
parts.len()
));
}
Ok(Self {
raw: expression.to_string(),
minute: CronField::parse(parts[0])?,
hour: CronField::parse(parts[1])?,
day_of_month: CronField::parse(parts[2])?,
month: CronField::parse(parts[3])?,
day_of_week: CronField::parse(parts[4])?,
})
}
pub fn is_due(&self) -> bool {
let now = Local::now();
self.minute.matches(now.minute())
&& self.hour.matches(now.hour())
&& self.day_of_month.matches(now.day())
&& self.month.matches(now.month())
&& self
.day_of_week
.matches(now.weekday().num_days_from_sunday())
}
pub fn expression(&self) -> &str {
&self.raw
}
pub fn at(mut self, time: &str) -> Self {
let parts: Vec<&str> = time.split(':').collect();
if parts.len() == 2 {
if let (Ok(hour), Ok(minute)) = (parts[0].parse::<u32>(), parts[1].parse::<u32>()) {
self.hour = CronField::Value(hour);
self.minute = CronField::Value(minute);
self.raw = format!(
"{} {} {} {} {}",
minute, hour, self.day_of_month, self.month, self.day_of_week,
);
}
}
self
}
pub fn every_minute() -> Self {
Self::parse("* * * * *").expect("valid cron: every minute")
}
pub fn every_n_minutes(n: u32) -> Self {
assert!(n > 0, "interval must be > 0");
Self::parse(&format!("*/{n} * * * *")).expect("valid cron: every N minutes")
}
pub fn hourly() -> Self {
Self::parse("0 * * * *").expect("valid cron: hourly")
}
pub fn hourly_at(minute: u32) -> Self {
assert!(minute < 60, "minute must be 0-59, got {minute}");
Self::parse(&format!("{minute} * * * *")).expect("valid cron: hourly at")
}
pub fn daily() -> Self {
Self::parse("0 0 * * *").expect("valid cron: daily")
}
pub fn daily_at(time: &str) -> Self {
let parts: Vec<&str> = time.split(':').collect();
if parts.len() == 2 {
let hour: u32 = parts[0].parse().unwrap_or(0);
let minute: u32 = parts[1].parse().unwrap_or(0);
Self::parse(&format!("{} {} * * *", minute.min(59), hour.min(23)))
.expect("valid cron: daily at")
} else {
Self::daily()
}
}
pub fn weekly() -> Self {
Self::parse("0 0 * * 0").expect("valid cron: weekly")
}
pub fn weekly_on(day: DayOfWeek) -> Self {
Self::parse(&format!("0 0 * * {}", day as u32)).expect("valid cron: weekly on")
}
pub fn on_days(days: &[DayOfWeek]) -> Self {
assert!(!days.is_empty(), "days must not be empty");
let days_str: Vec<String> = days.iter().map(|d| (*d as u32).to_string()).collect();
Self::parse(&format!("0 0 * * {}", days_str.join(","))).expect("valid cron: on days")
}
pub fn monthly() -> Self {
Self::parse("0 0 1 * *").expect("valid cron: monthly")
}
pub fn monthly_on(day: u32) -> Self {
assert!((1..=31).contains(&day), "day must be 1-31, got {day}");
Self::parse(&format!("0 0 {day} * *")).expect("valid cron: monthly on")
}
pub fn quarterly() -> Self {
Self::parse("0 0 1 1,4,7,10 *").expect("valid cron: quarterly")
}
pub fn yearly() -> Self {
Self::parse("0 0 1 1 *").expect("valid cron: yearly")
}
pub fn weekdays() -> Self {
Self::parse("0 0 * * 1-5").expect("valid cron: weekdays")
}
pub fn weekends() -> Self {
Self::parse("0 0 * * 0,6").expect("valid cron: weekends")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_every_minute() {
let expr = CronExpression::parse("* * * * *").unwrap();
assert_eq!(expr.expression(), "* * * * *");
}
#[test]
fn test_parse_specific_time() {
let expr = CronExpression::parse("30 14 * * *").unwrap();
assert_eq!(expr.expression(), "30 14 * * *");
}
#[test]
fn test_parse_invalid_expression() {
let result = CronExpression::parse("* * *");
assert!(result.is_err());
}
#[test]
fn test_factory_methods() {
assert_eq!(CronExpression::every_minute().expression(), "* * * * *");
assert_eq!(CronExpression::hourly().expression(), "0 * * * *");
assert_eq!(CronExpression::daily().expression(), "0 0 * * *");
assert_eq!(CronExpression::weekly().expression(), "0 0 * * 0");
assert_eq!(CronExpression::monthly().expression(), "0 0 1 * *");
}
#[test]
fn test_daily_at() {
let expr = CronExpression::daily_at("03:30");
assert_eq!(expr.expression(), "30 3 * * *");
}
#[test]
fn test_at_modifier() {
let expr = CronExpression::daily().at("14:30");
assert_eq!(expr.expression(), "30 14 * * *");
}
}