use chrono::{DateTime, TimeZone, Utc};
use chrono_tz::Tz;
use croner::Cron;
use serde::{Deserialize, Serialize};
use std::str::FromStr;
use thiserror::Error;
#[derive(Error, Debug, Clone, Serialize, Deserialize)]
pub enum CronError {
#[error("Invalid cron expression: {0}")]
InvalidExpression(String),
#[error("Invalid timezone: {0}")]
InvalidTimezone(String),
#[error("No next execution time found")]
NoNextExecution,
#[error("Cron parsing error: {0}")]
CronParsingError(String),
}
#[derive(Debug, Clone)]
pub struct CronEvaluator {
cron: Cron,
timezone: Tz,
expression: String,
timezone_str: String,
}
impl CronEvaluator {
pub fn new(cron_expr: &str, timezone_str: &str) -> Result<Self, CronError> {
let cron = Cron::new(cron_expr)
.with_seconds_optional() .parse()
.map_err(|e| CronError::CronParsingError(e.to_string()))?;
let timezone: Tz = timezone_str
.parse()
.map_err(|_| CronError::InvalidTimezone(timezone_str.to_string()))?;
Ok(Self {
cron,
timezone,
expression: cron_expr.to_string(),
timezone_str: timezone_str.to_string(),
})
}
pub fn next_execution(&self, after: DateTime<Utc>) -> Result<DateTime<Utc>, CronError> {
let local_time = self.timezone.from_utc_datetime(&after.naive_utc());
let next_local = self
.cron
.find_next_occurrence(&local_time, false)
.map_err(|e| CronError::CronParsingError(e.to_string()))?;
Ok(next_local.with_timezone(&Utc))
}
pub fn next_executions(
&self,
after: DateTime<Utc>,
limit: usize,
) -> Result<Vec<DateTime<Utc>>, CronError> {
let mut executions = Vec::with_capacity(limit);
let mut current_time = after;
for _ in 0..limit {
match self.next_execution(current_time) {
Ok(next_time) => {
executions.push(next_time);
current_time = next_time;
}
Err(CronError::NoNextExecution) => break,
Err(e) => return Err(e),
}
}
Ok(executions)
}
pub fn executions_between(
&self,
start: DateTime<Utc>,
end: DateTime<Utc>,
max_executions: usize,
) -> Result<Vec<DateTime<Utc>>, CronError> {
let mut executions = Vec::new();
let mut current_time = start;
for _ in 0..max_executions {
match self.next_execution(current_time) {
Ok(next_time) => {
if next_time >= end {
break;
}
executions.push(next_time);
current_time = next_time;
}
Err(CronError::NoNextExecution) => break,
Err(e) => return Err(e),
}
}
Ok(executions)
}
pub fn expression(&self) -> &str {
&self.expression
}
pub fn timezone_str(&self) -> &str {
&self.timezone_str
}
pub fn timezone(&self) -> Tz {
self.timezone
}
pub fn validate_expression(cron_expr: &str) -> Result<(), CronError> {
Cron::new(cron_expr)
.with_seconds_optional() .parse()
.map_err(|e| CronError::CronParsingError(e.to_string()))?;
Ok(())
}
pub fn validate_timezone(timezone_str: &str) -> Result<(), CronError> {
timezone_str
.parse::<Tz>()
.map_err(|_| CronError::InvalidTimezone(timezone_str.to_string()))?;
Ok(())
}
pub fn validate(cron_expr: &str, timezone_str: &str) -> Result<(), CronError> {
Self::validate_expression(cron_expr)?;
Self::validate_timezone(timezone_str)?;
Ok(())
}
}
impl FromStr for CronEvaluator {
type Err = CronError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let parts: Vec<&str> = s.split('@').collect();
if parts.len() != 2 {
return Err(CronError::InvalidExpression(
"Format should be 'expression@timezone'".to_string(),
));
}
Self::new(parts[0], parts[1])
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Datelike, TimeZone, Timelike};
#[test]
fn test_cron_evaluator_creation() {
let evaluator = CronEvaluator::new("0 9 * * *", "America/New_York").unwrap();
assert_eq!(evaluator.expression(), "0 9 * * *");
assert_eq!(evaluator.timezone_str(), "America/New_York");
}
#[test]
fn test_invalid_cron_expression() {
let result = CronEvaluator::new("invalid", "UTC");
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
CronError::CronParsingError(_)
));
}
#[test]
fn test_invalid_timezone() {
let result = CronEvaluator::new("0 9 * * *", "Invalid/Timezone");
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), CronError::InvalidTimezone(_)));
}
#[test]
fn test_next_execution_utc() {
let evaluator = CronEvaluator::new("0 12 * * *", "UTC").unwrap(); let start = Utc.with_ymd_and_hms(2025, 1, 1, 10, 0, 0).unwrap(); let next = evaluator.next_execution(start).unwrap();
assert_eq!(next.hour(), 12);
assert_eq!(next.minute(), 0);
assert_eq!(next.day(), 1);
}
#[test]
fn test_next_execution_timezone() {
let evaluator = CronEvaluator::new("0 9 * * *", "America/New_York").unwrap(); let start = Utc.with_ymd_and_hms(2025, 1, 1, 10, 0, 0).unwrap();
let next = evaluator.next_execution(start).unwrap();
assert_eq!(next.hour(), 14); assert_eq!(next.minute(), 0);
}
#[test]
fn test_next_executions() {
let evaluator = CronEvaluator::new("0 */6 * * *", "UTC").unwrap(); let start = Utc.with_ymd_and_hms(2025, 1, 1, 1, 0, 0).unwrap();
let executions = evaluator.next_executions(start, 3).unwrap();
assert_eq!(executions.len(), 3);
assert_eq!(executions[0].hour(), 6);
assert_eq!(executions[1].hour(), 12);
assert_eq!(executions[2].hour(), 18);
}
#[test]
fn test_executions_between() {
let evaluator = CronEvaluator::new("0 * * * *", "UTC").unwrap(); let start = Utc.with_ymd_and_hms(2025, 1, 1, 10, 30, 0).unwrap(); let end = Utc.with_ymd_and_hms(2025, 1, 1, 14, 0, 0).unwrap();
let executions = evaluator.executions_between(start, end, 10).unwrap();
assert_eq!(executions.len(), 3);
assert_eq!(executions[0].hour(), 11);
assert_eq!(executions[1].hour(), 12);
assert_eq!(executions[2].hour(), 13);
}
#[test]
fn test_validation_functions() {
assert!(CronEvaluator::validate_expression("0 9 * * *").is_ok());
assert!(CronEvaluator::validate_expression("invalid").is_err());
assert!(CronEvaluator::validate_timezone("UTC").is_ok());
assert!(CronEvaluator::validate_timezone("Invalid/Zone").is_err());
assert!(CronEvaluator::validate("0 9 * * *", "UTC").is_ok());
assert!(CronEvaluator::validate("invalid", "UTC").is_err());
assert!(CronEvaluator::validate("0 9 * * *", "Invalid/Zone").is_err());
}
#[test]
fn test_from_str() {
let evaluator: CronEvaluator = "0 9 * * *@America/New_York".parse().unwrap();
assert_eq!(evaluator.expression(), "0 9 * * *");
assert_eq!(evaluator.timezone_str(), "America/New_York");
let result: Result<CronEvaluator, _> = "invalid format".parse();
assert!(result.is_err());
}
#[test]
fn test_executions_between_respects_max_limit() {
let evaluator = CronEvaluator::new("* * * * *", "UTC").unwrap(); let start = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
let end = Utc.with_ymd_and_hms(2025, 1, 2, 0, 0, 0).unwrap();
let executions = evaluator.executions_between(start, end, 5).unwrap();
assert_eq!(executions.len(), 5, "Should cap at max_executions");
}
#[test]
fn test_executions_between_empty_range() {
let evaluator = CronEvaluator::new("0 12 * * *", "UTC").unwrap(); let start = Utc.with_ymd_and_hms(2025, 1, 1, 13, 0, 0).unwrap(); let end = Utc.with_ymd_and_hms(2025, 1, 1, 14, 0, 0).unwrap();
let executions = evaluator.executions_between(start, end, 10).unwrap();
assert!(
executions.is_empty(),
"No noon execution between 1PM and 2PM"
);
}
#[test]
fn test_executions_between_multiple_days() {
let evaluator = CronEvaluator::new("0 9 * * *", "UTC").unwrap(); let start = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
let end = Utc.with_ymd_and_hms(2025, 1, 4, 0, 0, 0).unwrap();
let executions = evaluator.executions_between(start, end, 100).unwrap();
assert_eq!(executions.len(), 3, "Should find 3 daily executions");
assert_eq!(executions[0].day(), 1);
assert_eq!(executions[1].day(), 2);
assert_eq!(executions[2].day(), 3);
}
#[test]
fn test_executions_between_timezone_aware() {
let evaluator = CronEvaluator::new("0 9 * * *", "America/New_York").unwrap();
let start = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
let end = Utc.with_ymd_and_hms(2025, 1, 2, 0, 0, 0).unwrap();
let executions = evaluator.executions_between(start, end, 10).unwrap();
assert_eq!(executions.len(), 1);
assert_eq!(executions[0].hour(), 14, "9 AM EST = 2 PM UTC");
}
}