use std::str::FromStr; use chrono::{DateTime, Local, Utc}; use chrono_tz::Tz; use croner::Cron; use serde::{Deserialize, Serialize}; use thiserror::Error; use utoipa::ToSchema; const MAX_PREVIEW_RUNS: usize = 100; #[derive(Debug, Error)] pub enum CronPreviewError { #[error("invalid cron expression: {0}")] InvalidExpression(String), #[error("cron preview can return at most {MAX_PREVIEW_RUNS} runs")] TooManyRuns, #[error("cron expression has no future occurrence")] NoFutureOccurrence, #[error("invalid IANA timezone: {0}")] InvalidTimezone(String), } #[derive(Debug, Deserialize, ToSchema)] #[serde(deny_unknown_fields)] pub struct CronPreviewRequest { pub expression: String, pub timezone: String, pub count: usize, } #[derive(Debug, Serialize, ToSchema)] pub struct CronPreviewResponse { pub timezone: String, pub runs: Vec, } /// Calculates the next occurrences of a cron expression in local time. pub fn next_occurrences( expression: &str, from: DateTime, count: usize, ) -> Result>, CronPreviewError> { if count > MAX_PREVIEW_RUNS { return Err(CronPreviewError::TooManyRuns); } let cron = Cron::from_str(expression) .map_err(|error| CronPreviewError::InvalidExpression(error.to_string()))?; let mut runs = Vec::with_capacity(count); let mut cursor = from; for _ in 0..count { let next = cron .find_next_occurrence(&cursor, false) .map_err(|error| CronPreviewError::InvalidExpression(error.to_string()))?; runs.push(next); cursor = next; } Ok(runs) } pub fn preview_in_timezone( expression: &str, timezone: &str, from: DateTime, count: usize, ) -> Result { if count > MAX_PREVIEW_RUNS { return Err(CronPreviewError::TooManyRuns); } let timezone: Tz = timezone .parse() .map_err(|_| CronPreviewError::InvalidTimezone(timezone.to_owned()))?; let cron = Cron::from_str(expression) .map_err(|error| CronPreviewError::InvalidExpression(error.to_string()))?; let mut cursor = from.with_timezone(&timezone); let mut runs = Vec::with_capacity(count); for _ in 0..count { let next = cron .find_next_occurrence(&cursor, false) .map_err(|error| CronPreviewError::InvalidExpression(error.to_string()))?; runs.push(next.format("%Y-%m-%d %H:%M:%S").to_string()); cursor = next; } Ok(CronPreviewResponse { timezone: timezone.name().to_owned(), runs, }) } #[cfg(test)] mod tests { use chrono::{Datelike, TimeZone, Weekday}; use super::*; fn local_time( year: i32, month: u32, day: u32, hour: u32, minute: u32, second: u32, ) -> DateTime { Local .with_ymd_and_hms(year, month, day, hour, minute, second) .single() .expect("test timestamp should be unambiguous") } #[test] fn parses_five_field_expression() { let runs = next_occurrences("0 9 * * *", local_time(2026, 1, 1, 8, 59, 0), 1) .expect("five field expression should parse"); assert_eq!(runs, vec![local_time(2026, 1, 1, 9, 0, 0)]); } #[test] fn parses_six_field_expression_with_seconds() { let runs = next_occurrences("*/15 * * * * *", local_time(2026, 1, 1, 12, 0, 1), 2) .expect("six field expression should parse"); assert_eq!( runs, vec![ local_time(2026, 1, 1, 12, 0, 15), local_time(2026, 1, 1, 12, 0, 30) ] ); } #[test] fn supports_hourly_alias() { let runs = next_occurrences("@hourly", local_time(2026, 1, 1, 12, 34, 56), 1) .expect("hourly alias should parse"); assert_eq!(runs, vec![local_time(2026, 1, 1, 13, 0, 0)]); } #[test] fn rejects_zero_step() { let error = next_occurrences("*/0 * * * * *", local_time(2026, 1, 1, 0, 0, 0), 1) .expect_err("zero step should be rejected"); assert!(matches!(error, CronPreviewError::InvalidExpression(_))); } #[test] fn uses_posix_or_semantics_for_day_of_month_and_weekday() { let runs = next_occurrences("0 0 0 1 * MON", local_time(2026, 1, 2, 0, 0, 0), 1) .expect("POSIX cron expression should parse"); assert_eq!(runs[0].day(), 5); assert_eq!(runs[0].weekday(), Weekday::Mon); } #[test] fn supports_explicit_day_and_weekday_and_semantics() { let runs = next_occurrences("0 0 0 1 * +MON", local_time(2026, 1, 2, 0, 0, 0), 1) .expect("AND semantics should parse"); assert_eq!(runs[0], local_time(2026, 6, 1, 0, 0, 0)); } #[test] fn previews_runs_in_iana_timezone() { let from = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(); let preview = preview_in_timezone("0 0 2 * * *", "Asia/Shanghai", from, 2).unwrap(); assert_eq!(preview.timezone, "Asia/Shanghai"); assert_eq!( preview.runs, vec![ "2026-01-02 02:00:00".to_owned(), "2026-01-03 02:00:00".to_owned() ] ); } }