185 lines
5.2 KiB
Rust
185 lines
5.2 KiB
Rust
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<String>,
|
|
}
|
|
|
|
/// Calculates the next occurrences of a cron expression in local time.
|
|
pub fn next_occurrences(
|
|
expression: &str,
|
|
from: DateTime<Local>,
|
|
count: usize,
|
|
) -> Result<Vec<DateTime<Local>>, 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<Utc>,
|
|
count: usize,
|
|
) -> Result<CronPreviewResponse, CronPreviewError> {
|
|
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> {
|
|
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()
|
|
]
|
|
);
|
|
}
|
|
}
|