use chrono::{DateTime, Utc};
use chrono_tz::Tz;
use cron::Schedule;
use std::str::FromStr;
#[derive(Debug, Clone)]
pub struct CronSchedule {
expression: String,
schedule: Option<Schedule>,
}
impl Default for CronSchedule {
fn default() -> Self {
Self {
expression: "0 * * * * *".to_string(),
schedule: Schedule::from_str("0 * * * * *").ok(),
}
}
}
impl CronSchedule {
pub fn new(expression: &str) -> Result<Self, CronParseError> {
let normalized = normalize_cron_expression(expression);
let schedule = Schedule::from_str(&normalized)
.map_err(|e| CronParseError::InvalidExpression(e.to_string()))?;
Ok(Self {
expression: normalized,
schedule: Some(schedule),
})
}
pub fn expression(&self) -> &str {
&self.expression
}
pub fn next_after(&self, _after: DateTime<Utc>) -> Option<DateTime<Utc>> {
self.schedule.as_ref()?.upcoming(Utc).next()
}
pub fn next_after_in_tz(&self, after: DateTime<Utc>, timezone: &str) -> Option<DateTime<Utc>> {
let tz: Tz = timezone.parse().ok()?;
let local_time = after.with_timezone(&tz);
self.schedule
.as_ref()?
.after(&local_time)
.next()
.map(|dt| dt.with_timezone(&Utc))
}
pub fn between(&self, start: DateTime<Utc>, end: DateTime<Utc>) -> Vec<DateTime<Utc>> {
let Some(ref schedule) = self.schedule else {
return vec![];
};
schedule.after(&start).take_while(|dt| *dt <= end).collect()
}
pub fn between_in_tz(
&self,
start: DateTime<Utc>,
end: DateTime<Utc>,
timezone: &str,
) -> Vec<DateTime<Utc>> {
let Ok(tz) = timezone.parse::<Tz>() else {
return vec![];
};
let Some(ref schedule) = self.schedule else {
return vec![];
};
let local_start = start.with_timezone(&tz);
let local_end = end.with_timezone(&tz);
schedule
.after(&local_start)
.take_while(|dt| *dt <= local_end)
.map(|dt| dt.with_timezone(&Utc))
.collect()
}
}
fn normalize_cron_expression(expr: &str) -> String {
let parts: Vec<&str> = expr.split_whitespace().collect();
match parts.len() {
5 => format!("0 {}", expr), 6 => expr.to_string(), _ => expr.to_string(), }
}
#[derive(Debug, Clone)]
pub enum CronParseError {
InvalidExpression(String),
}
impl std::fmt::Display for CronParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidExpression(e) => write!(f, "Invalid cron expression: {}", e),
}
}
}
impl std::error::Error for CronParseError {}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
mod tests {
use super::*;
#[test]
fn test_parse_five_part_cron() {
let schedule = CronSchedule::new("*/5 * * * *").unwrap();
assert_eq!(schedule.expression(), "0 */5 * * * *");
}
#[test]
fn test_parse_six_part_cron() {
let schedule = CronSchedule::new("30 */5 * * * *").unwrap();
assert_eq!(schedule.expression(), "30 */5 * * * *");
}
#[test]
fn test_next_after() {
let schedule = CronSchedule::new("0 0 * * * *").unwrap(); let now = Utc::now();
let next = schedule.next_after(now);
assert!(next.is_some());
assert!(next.unwrap() > now);
}
#[test]
fn test_invalid_cron() {
let result = CronSchedule::new("invalid");
assert!(result.is_err());
}
#[test]
fn test_between() {
let schedule = CronSchedule::new("0 * * * *").unwrap(); let start = Utc::now();
let end = start + chrono::Duration::hours(1);
let times = schedule.between(start, end);
assert!(!times.is_empty());
}
}