use chrono::{DateTime, Timelike, Utc};
use chrono_tz::Tz;
use cron::Schedule;
use serde::{Deserialize, Serialize};
use std::str::FromStr;
use thiserror::Error;
#[derive(Error, Debug)]
#[non_exhaustive]
pub enum CronError {
#[error("Invalid cron expression: {0}")]
InvalidExpression(String),
#[error("Invalid timezone: {0}")]
InvalidTimezone(String),
#[error("Cron parsing error: {0}")]
ParseError(#[from] cron::error::Error),
}
#[derive(Deserialize)]
struct CronScheduleData {
expression: String,
timezone: String,
}
impl TryFrom<CronScheduleData> for CronSchedule {
type Error = CronError;
fn try_from(data: CronScheduleData) -> Result<Self, Self::Error> {
CronSchedule::with_timezone(&data.expression, &data.timezone)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(try_from = "CronScheduleData")]
pub struct CronSchedule {
pub expression: String,
pub timezone: String,
#[serde(skip)]
schedule: Option<Schedule>,
#[serde(skip)]
tz: Option<Tz>,
}
impl CronSchedule {
pub fn new(expression: &str) -> Result<Self, CronError> {
Self::with_timezone(expression, "UTC")
}
pub fn with_timezone(expression: &str, timezone: &str) -> Result<Self, CronError> {
let schedule = Schedule::from_str(expression)
.map_err(|e| CronError::InvalidExpression(format!("{}: {}", expression, e)))?;
let tz = timezone
.parse::<Tz>()
.map_err(|_| CronError::InvalidTimezone(timezone.to_string()))?;
Ok(CronSchedule {
expression: expression.to_string(),
timezone: timezone.to_string(),
schedule: Some(schedule),
tz: Some(tz),
})
}
pub fn next_execution(&self, after: DateTime<Utc>) -> Option<DateTime<Utc>> {
let schedule = self.schedule.as_ref()?;
let tz = self.tz.as_ref()?;
let after_tz = after.with_timezone(tz);
let next_tz = schedule.after(&after_tz).next()?;
Some(next_tz.with_timezone(&Utc))
}
pub fn next_execution_from_now(&self) -> Option<DateTime<Utc>> {
self.next_execution(Utc::now())
}
pub fn matches(&self, datetime: DateTime<Utc>) -> bool {
let schedule = match self.schedule.as_ref() {
Some(s) => s,
None => return false,
};
let tz = match self.tz.as_ref() {
Some(t) => t,
None => return false,
};
let datetime_tz = datetime.with_timezone(tz);
let into_minute = chrono::Duration::seconds(i64::from(datetime_tz.second()))
+ chrono::Duration::nanoseconds(i64::from(datetime_tz.nanosecond()));
let minute_start = datetime_tz - into_minute;
let check_time = minute_start - chrono::Duration::seconds(1);
match schedule.after(&check_time).next() {
Some(next_time) => next_time < minute_start + chrono::Duration::minutes(1),
None => false,
}
}
pub fn validate(expression: &str) -> Result<(), CronError> {
Schedule::from_str(expression)
.map_err(|e| CronError::InvalidExpression(format!("{}: {}", expression, e)))?;
Ok(())
}
pub fn every_minute() -> Result<Self, CronError> {
Self::new("0 * * * * *")
}
pub fn every_hour() -> Result<Self, CronError> {
Self::new("0 0 * * * *")
}
pub fn every_day_at_midnight() -> Result<Self, CronError> {
Self::new("0 0 0 * * *")
}
pub fn every_weekday_at_9am() -> Result<Self, CronError> {
Self::new("0 0 9 * * Mon-Fri")
}
pub fn every_monday_at_noon() -> Result<Self, CronError> {
Self::new("0 0 12 * * Mon")
}
pub fn reinitialize(&mut self) -> Result<(), CronError> {
self.schedule =
Some(Schedule::from_str(&self.expression).map_err(|e| {
CronError::InvalidExpression(format!("{}: {}", self.expression, e))
})?);
self.tz = Some(
self.timezone
.parse::<Tz>()
.map_err(|_| CronError::InvalidTimezone(self.timezone.clone()))?,
);
Ok(())
}
}
impl FromStr for CronSchedule {
type Err = CronError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
pub mod presets {
use super::CronSchedule;
pub fn every_minute() -> CronSchedule {
CronSchedule::every_minute().unwrap()
}
pub fn every_hour() -> CronSchedule {
CronSchedule::every_hour().unwrap()
}
pub fn daily_at_midnight() -> CronSchedule {
CronSchedule::every_day_at_midnight().unwrap()
}
pub fn weekdays_at_9am() -> CronSchedule {
CronSchedule::every_weekday_at_9am().unwrap()
}
pub fn mondays_at_noon() -> CronSchedule {
CronSchedule::every_monday_at_noon().unwrap()
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Datelike, TimeZone, Timelike};
#[test]
fn test_deserialized_schedule_fires() {
let original = CronSchedule::with_timezone("0 0 9 * * *", "America/New_York").unwrap();
let json = serde_json::to_string(&original).unwrap();
let restored: CronSchedule = serde_json::from_str(&json).unwrap();
let after = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
assert!(restored.next_execution(after).is_some());
assert_eq!(
restored.next_execution(after),
original.next_execution(after)
);
assert!(restored.matches(Utc.with_ymd_and_hms(2024, 1, 1, 14, 0, 30).unwrap()));
assert!(
serde_json::from_str::<CronSchedule>(r#"{"expression":"nonsense","timezone":"UTC"}"#)
.is_err()
);
assert!(
serde_json::from_str::<CronSchedule>(
r#"{"expression":"0 0 9 * * *","timezone":"Not/AZone"}"#
)
.is_err()
);
}
#[test]
fn test_cron_schedule_creation() {
let schedule = CronSchedule::new("0 0 9 * * Mon-Fri").unwrap();
assert_eq!(schedule.expression, "0 0 9 * * Mon-Fri");
assert_eq!(schedule.timezone, "UTC");
}
#[test]
fn test_cron_schedule_with_timezone() {
let schedule =
CronSchedule::with_timezone("0 0 9 * * Mon-Fri", "America/New_York").unwrap();
assert_eq!(schedule.expression, "0 0 9 * * Mon-Fri");
assert_eq!(schedule.timezone, "America/New_York");
}
#[test]
fn test_invalid_cron_expression() {
let result = CronSchedule::new("invalid cron");
assert!(result.is_err());
}
#[test]
fn test_invalid_timezone() {
let result = CronSchedule::with_timezone("0 9 * * Mon-Fri", "Invalid/Timezone");
assert!(result.is_err());
}
#[test]
fn test_next_execution() {
let schedule = CronSchedule::new("0 0 9 * * *").unwrap(); let now = Utc.with_ymd_and_hms(2023, 1, 1, 8, 0, 0).unwrap();
let next = schedule.next_execution(now).unwrap();
assert_eq!(next.hour(), 9);
assert_eq!(next.minute(), 0);
assert_eq!(next.day(), 1);
}
#[test]
fn test_next_execution_with_timezone() {
let schedule = CronSchedule::with_timezone("0 0 9 * * *", "America/New_York").unwrap();
let now = Utc.with_ymd_and_hms(2023, 1, 1, 12, 0, 0).unwrap(); let next = schedule.next_execution(now);
assert!(next.is_some());
}
#[test]
fn test_presets() {
let every_minute = presets::every_minute();
assert_eq!(every_minute.expression, "0 * * * * *");
let every_hour = presets::every_hour();
assert_eq!(every_hour.expression, "0 0 * * * *");
let daily = presets::daily_at_midnight();
assert_eq!(daily.expression, "0 0 0 * * *");
let weekdays = presets::weekdays_at_9am();
assert_eq!(weekdays.expression, "0 0 9 * * Mon-Fri");
let mondays = presets::mondays_at_noon();
assert_eq!(mondays.expression, "0 0 12 * * Mon");
}
#[test]
fn test_weekday_presets_fire_on_the_right_days() {
use chrono::Weekday;
let start = Utc.with_ymd_and_hms(2023, 1, 1, 0, 0, 0).unwrap();
let weekdays = presets::weekdays_at_9am();
let mut after = start;
let mut days = Vec::new();
for _ in 0..10 {
let next = weekdays.next_execution(after).unwrap();
assert_eq!(next.hour(), 9);
days.push(next.weekday());
after = next;
}
assert!(
days.iter()
.all(|d| !matches!(d, Weekday::Sat | Weekday::Sun)),
"weekday preset fired on {days:?}"
);
assert_eq!(days[0], Weekday::Mon);
let mondays = presets::mondays_at_noon();
let next = mondays.next_execution(start).unwrap();
assert_eq!(next.weekday(), Weekday::Mon);
assert_eq!(next.hour(), 12);
}
#[test]
fn test_matches_any_fire_time_in_the_minute() {
let noon = Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 0).unwrap();
assert!(CronSchedule::new("* * * * * *").unwrap().matches(noon));
assert!(CronSchedule::new("0,30 * * * * *").unwrap().matches(noon));
let ten_past = noon + chrono::Duration::seconds(10);
assert!(CronSchedule::new("30 * * * * *").unwrap().matches(ten_past));
assert!(CronSchedule::new("0 * * * * *").unwrap().matches(ten_past));
assert!(!CronSchedule::new("0 5 * * * *").unwrap().matches(noon));
assert!(!CronSchedule::new("0 0 13 * * *").unwrap().matches(noon));
}
#[test]
fn test_cron_validation() {
assert!(CronSchedule::validate("0 0 9 * * Mon-Fri").is_ok());
assert!(CronSchedule::validate("0 * * * * *").is_ok());
assert!(CronSchedule::validate("0 0 0 * * *").is_ok());
assert!(CronSchedule::validate("invalid").is_err());
}
#[test]
fn test_serialization() {
let schedule =
CronSchedule::with_timezone("0 0 9 * * Mon-Fri", "America/New_York").unwrap();
let json = serde_json::to_string(&schedule).unwrap();
let mut deserialized: CronSchedule = serde_json::from_str(&json).unwrap();
deserialized.reinitialize().unwrap();
assert_eq!(deserialized.expression, schedule.expression);
assert_eq!(deserialized.timezone, schedule.timezone);
}
#[test]
fn test_from_str() {
let schedule: CronSchedule = "0 0 9 * * Mon-Fri".parse().unwrap();
assert_eq!(schedule.expression, "0 0 9 * * Mon-Fri");
assert_eq!(schedule.timezone, "UTC");
}
}