use chrono::{DateTime, Datelike, Duration, NaiveDateTime, Timelike};
pub struct DurableScheduler;
impl DurableScheduler {
pub fn evaluate_cron(cron_expr: &str, _last_run: Option<&str>, now: &str) -> bool {
let now_dt = match parse_iso(now) {
Some(dt) => dt,
None => return false,
};
let fields: Vec<&str> = cron_expr.split_whitespace().collect();
if fields.len() != 5 {
return false;
}
let checks: [(u32, &str); 5] = [
(now_dt.minute(), fields[0]),
(now_dt.hour(), fields[1]),
(now_dt.day(), fields[2]),
(now_dt.month(), fields[3]),
(now_dt.weekday().num_days_from_sunday(), fields[4]),
];
checks
.iter()
.all(|(actual, pattern)| match_field(*actual, pattern))
}
pub fn evaluate_interval(last_run: Option<&str>, interval_ms: i64, now: &str) -> bool {
let now_dt = match parse_iso(now) {
Some(dt) => dt,
None => return false,
};
match last_run.and_then(parse_iso) {
Some(last) => {
let elapsed = now_dt.signed_duration_since(last).num_milliseconds();
elapsed >= interval_ms
}
None => true,
}
}
pub fn evaluate_at(schedule_expr: &str, now: &str) -> bool {
let target = match parse_iso(schedule_expr) {
Some(dt) => dt,
None => return false,
};
let now_dt = match parse_iso(now) {
Some(dt) => dt,
None => return false,
};
now_dt >= target
}
pub fn calculate_next_run(
schedule_kind: &str,
schedule_expr: Option<&str>,
schedule_every_ms: Option<i64>,
now: &str,
) -> Option<String> {
let now_dt = parse_iso(now)?;
match schedule_kind {
"interval" => {
let ms = schedule_every_ms?;
let next = now_dt + Duration::milliseconds(ms);
Some(next.and_utc().to_rfc3339())
}
"at" => {
let expr = schedule_expr?;
let target = parse_iso(expr)?;
if now_dt >= target {
None
} else {
Some(target.and_utc().to_rfc3339())
}
}
"cron" => {
let next = now_dt + Duration::seconds(60);
Some(next.and_utc().to_rfc3339())
}
_ => None,
}
}
}
fn parse_iso(s: &str) -> Option<NaiveDateTime> {
DateTime::parse_from_rfc3339(s)
.map(|dt| dt.naive_utc())
.ok()
.or_else(|| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S").ok())
}
fn match_field(actual: u32, pattern: &str) -> bool {
if pattern == "*" {
return true;
}
pattern.parse::<u32>() == Ok(actual)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn interval_due() {
let last = "2025-01-01T00:00:00+00:00";
let now = "2025-01-01T00:01:00+00:00";
assert!(DurableScheduler::evaluate_interval(Some(last), 60_000, now));
}
#[test]
fn interval_not_due() {
let last = "2025-01-01T00:00:00+00:00";
let now = "2025-01-01T00:00:30+00:00";
assert!(!DurableScheduler::evaluate_interval(
Some(last),
60_000,
now
));
}
#[test]
fn interval_no_last_run() {
assert!(DurableScheduler::evaluate_interval(
None,
60_000,
"2025-01-01T00:00:00+00:00"
));
}
#[test]
fn at_schedule_past() {
assert!(DurableScheduler::evaluate_at(
"2025-01-01T00:00:00+00:00",
"2025-01-01T01:00:00+00:00"
));
}
#[test]
fn at_schedule_future() {
assert!(!DurableScheduler::evaluate_at(
"2025-01-01T02:00:00+00:00",
"2025-01-01T01:00:00+00:00"
));
}
#[test]
fn next_run_interval() {
let result = DurableScheduler::calculate_next_run(
"interval",
None,
Some(60_000),
"2025-01-01T00:00:00+00:00",
);
assert!(result.is_some());
let next = result.unwrap();
assert!(next.contains("00:01:00"));
}
#[test]
fn next_run_at_already_passed() {
let result = DurableScheduler::calculate_next_run(
"at",
Some("2025-01-01T00:00:00+00:00"),
None,
"2025-01-01T01:00:00+00:00",
);
assert!(result.is_none());
}
#[test]
fn cron_matches() {
assert!(DurableScheduler::evaluate_cron(
"0 12 * * *",
None,
"2025-01-01T12:00:00+00:00"
));
}
#[test]
fn cron_no_match() {
assert!(!DurableScheduler::evaluate_cron(
"30 12 * * *",
None,
"2025-01-01T12:00:00+00:00"
));
}
#[test]
fn next_run_at_future() {
let result = DurableScheduler::calculate_next_run(
"at",
Some("2025-01-01T02:00:00+00:00"),
None,
"2025-01-01T01:00:00+00:00",
);
assert!(result.is_some());
}
#[test]
fn next_run_cron() {
let result = DurableScheduler::calculate_next_run(
"cron",
Some("0 12 * * *"),
None,
"2025-01-01T00:00:00+00:00",
);
assert!(result.is_some());
}
#[test]
fn next_run_unknown_kind() {
let result =
DurableScheduler::calculate_next_run("weekly", None, None, "2025-01-01T00:00:00+00:00");
assert!(result.is_none());
}
#[test]
fn next_run_interval_missing_ms() {
let result = DurableScheduler::calculate_next_run(
"interval",
None,
None,
"2025-01-01T00:00:00+00:00",
);
assert!(result.is_none());
}
#[test]
fn cron_wrong_field_count() {
assert!(!DurableScheduler::evaluate_cron(
"0 12 *",
None,
"2025-01-01T12:00:00+00:00"
));
}
#[test]
fn interval_invalid_now() {
assert!(!DurableScheduler::evaluate_interval(
None,
60_000,
"not-a-date"
));
}
#[test]
fn at_invalid_target() {
assert!(!DurableScheduler::evaluate_at(
"bad",
"2025-01-01T00:00:00+00:00"
));
}
#[test]
fn at_invalid_now() {
assert!(!DurableScheduler::evaluate_at(
"2025-01-01T00:00:00+00:00",
"bad"
));
}
#[test]
fn cron_with_last_run_still_matches() {
assert!(DurableScheduler::evaluate_cron(
"0 12 * * *",
Some("2024-12-31T12:00:00+00:00"),
"2025-01-01T12:00:00+00:00"
));
}
#[test]
fn interval_exact_boundary_is_due() {
assert!(DurableScheduler::evaluate_interval(
Some("2025-01-01T00:00:00+00:00"),
60_000,
"2025-01-01T00:01:00+00:00"
));
}
}