Files
task_pilot/backend/src/cron.rs
T

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()
]
);
}
}