use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::str::FromStr;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CronSchedule {
expression: String,
}
impl CronSchedule {
pub fn new(expression: impl Into<String>) -> Result<Self, ScheduleError> {
let expression = expression.into();
Self::validate(&expression)?;
Ok(Self { expression })
}
fn validate(expr: &str) -> Result<(), ScheduleError> {
let parts: Vec<&str> = expr.split_whitespace().collect();
if parts.len() != 5 && parts.len() != 6 {
return Err(ScheduleError::InvalidFormat(
"Cron expression must have 5 or 6 fields".to_string(),
));
}
Ok(())
}
pub fn next_run(&self, after: DateTime<Utc>) -> Option<DateTime<Utc>> {
Some(after + chrono::Duration::hours(1))
}
}
impl FromStr for CronSchedule {
type Err = ScheduleError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Schedule {
Once(DateTime<Utc>),
Interval {
seconds: u64,
start_at: Option<DateTime<Utc>>,
},
Cron(CronSchedule),
}
impl Schedule {
pub fn once(at: DateTime<Utc>) -> Self {
Self::Once(at)
}
pub fn every(seconds: u64) -> Self {
Self::Interval {
seconds,
start_at: None,
}
}
pub fn every_starting_at(seconds: u64, start_at: DateTime<Utc>) -> Self {
Self::Interval {
seconds,
start_at: Some(start_at),
}
}
pub fn cron(expression: &str) -> Result<Self, ScheduleError> {
Ok(Self::Cron(CronSchedule::new(expression)?))
}
pub fn next_run(&self, after: DateTime<Utc>) -> Option<DateTime<Utc>> {
match self {
Self::Once(at) => {
if after < *at {
Some(*at)
} else {
None
}
}
Self::Interval { seconds, start_at } => {
let start = start_at.unwrap_or(after);
if after < start {
Some(start)
} else {
let elapsed = (after - start).num_seconds() as u64;
let intervals = (elapsed / seconds) + 1;
Some(start + chrono::Duration::seconds((intervals * seconds) as i64))
}
}
Self::Cron(cron) => cron.next_run(after),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ScheduleError {
#[error("Invalid cron format: {0}")]
InvalidFormat(String),
#[error("Invalid field value: {0}")]
InvalidValue(String),
}
pub mod schedules {
use super::*;
pub fn every_minute() -> Schedule {
Schedule::every(60)
}
pub fn every_5_minutes() -> Schedule {
Schedule::every(300)
}
pub fn hourly() -> Schedule {
Schedule::every(3600)
}
pub fn daily() -> Result<Schedule, ScheduleError> {
Schedule::cron("0 0 * * *")
}
pub fn weekly() -> Result<Schedule, ScheduleError> {
Schedule::cron("0 0 * * 1")
}
pub fn monthly() -> Result<Schedule, ScheduleError> {
Schedule::cron("0 0 1 * *")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_once_schedule() {
let future = Utc::now() + chrono::Duration::hours(1);
let schedule = Schedule::once(future);
let next = schedule.next_run(Utc::now()).unwrap();
assert!(next > Utc::now());
}
#[test]
fn test_interval_schedule() {
let schedule = Schedule::every(60); let now = Utc::now();
let next = schedule.next_run(now).unwrap();
assert!(next > now);
assert!(next - now <= chrono::Duration::seconds(60));
}
#[test]
fn test_cron_schedule() {
let schedule = Schedule::cron("0 0 * * *").unwrap();
let next = schedule.next_run(Utc::now());
assert!(next.is_some());
}
}