use crate::manifest::validate::validate_schedule;
use crate::{ScheduleDefinition, ScheduleSpec};
use chrono::{DateTime, TimeZone, Utc};
use chrono_tz::Tz;
use croner::Cron;
const SPACING_SAMPLE: usize = 20;
#[derive(Debug, Clone)]
pub enum CompiledSpec {
Cron { cron: Box<Cron>, timezone: Tz },
Every { secs: u32 },
}
pub fn compile(path: &str, schedule: &ScheduleDefinition) -> Result<CompiledSpec, String> {
validate_schedule(path, schedule)?;
match &schedule.spec {
ScheduleSpec::Every { secs } => Ok(CompiledSpec::Every { secs: *secs }),
ScheduleSpec::Cron { expr } => {
let timezone = schedule.timezone.as_deref().map_or(Ok(Tz::UTC), |zone| {
zone.parse::<Tz>()
.map_err(|_| format!("{path}.timezone: '{zone}' is not an IANA time zone name"))
})?;
let cron = Cron::new(expr)
.with_seconds_optional()
.parse()
.map_err(|e| format!("{path}.spec.expr: '{expr}' is not a cron expression: {e}"))?;
let compiled = CompiledSpec::Cron {
cron: Box::new(cron),
timezone,
};
match compiled.closest_fires(spacing_reference()) {
Some(gap) if gap < i64::from(ScheduleDefinition::MIN_INTERVAL_SECS) => {
Err(format!(
"{path}.spec.expr: '{expr}' fires {gap} s apart, closer than the {} second minimum",
ScheduleDefinition::MIN_INTERVAL_SECS
))
}
Some(_) => Ok(compiled),
None => Err(format!("{path}.spec.expr: '{expr}' never fires")),
}
}
}
}
fn spacing_reference() -> DateTime<Utc> {
DateTime::UNIX_EPOCH + chrono::Duration::days(20_000)
}
impl CompiledSpec {
pub fn next_fire(&self, after: DateTime<Utc>) -> Option<DateTime<Utc>> {
match self {
CompiledSpec::Every { secs } => {
let secs = i64::from(*secs).max(1);
after
.timestamp()
.div_euclid(secs)
.checked_add(1)
.and_then(|n| n.checked_mul(secs))
.and_then(|at| Utc.timestamp_opt(at, 0).single())
}
CompiledSpec::Cron { cron, timezone } => {
next_cron_fire(cron, after.with_timezone(timezone), after)
}
}
}
pub fn fires_after(&self, after: DateTime<Utc>) -> impl Iterator<Item = DateTime<Utc>> + '_ {
std::iter::successors(self.next_fire(after), |fire| self.next_fire(*fire))
}
fn closest_fires(&self, from: DateTime<Utc>) -> Option<i64> {
let fires: Vec<DateTime<Utc>> = self.fires_after(from).take(SPACING_SAMPLE).collect();
fires.first()?;
Some(
fires
.windows(2)
.map(|pair| (pair[1] - pair[0]).num_seconds())
.min()
.unwrap_or(i64::MAX),
)
}
}
fn next_cron_fire(cron: &Cron, from: DateTime<Tz>, after: DateTime<Utc>) -> Option<DateTime<Utc>> {
let found = cron.find_next_occurrence(&from, false).ok()?;
if found.with_timezone(&Utc) > after {
Some(found.with_timezone(&Utc))
} else if found.naive_local() > from.naive_local() {
next_cron_fire(cron, found, after)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{CatchUp, Overlap};
fn schedule(spec: ScheduleSpec, timezone: Option<&str>) -> ScheduleDefinition {
ScheduleDefinition {
name: "s".into(),
handler: "h".into(),
spec,
timezone: timezone.map(str::to_string),
overlap: Overlap::Skip,
catch_up: CatchUp::Latest,
job: None,
timeout_secs: None,
}
}
fn cron(expr: &str, timezone: Option<&str>) -> Result<CompiledSpec, String> {
compile(
"schedule",
&schedule(ScheduleSpec::Cron { expr: expr.into() }, timezone),
)
}
fn every(secs: u32) -> CompiledSpec {
compile("schedule", &schedule(ScheduleSpec::Every { secs }, None)).expect("compiles")
}
fn at(rfc3339: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(rfc3339)
.expect("timestamp")
.with_timezone(&Utc)
}
fn fires(spec: &CompiledSpec, after: &str, count: usize) -> Vec<String> {
spec.fires_after(at(after))
.take(count)
.map(|fire| fire.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
.collect()
}
#[test]
fn every_fires_at_epoch_multiples_whoever_computes_it() {
let spec = every(30);
assert_eq!(
fires(&spec, "2026-09-28T10:00:07Z", 3),
[
"2026-09-28T10:00:30Z",
"2026-09-28T10:01:00Z",
"2026-09-28T10:01:30Z"
]
);
for replica_clock in ["2026-09-28T10:00:00Z", "2026-09-28T10:00:29Z"] {
assert_eq!(
spec.next_fire(at(replica_clock)),
Some(at("2026-09-28T10:00:30Z")),
"{replica_clock}"
);
}
assert_eq!(
every(300).next_fire(at("2026-09-28T10:00:00Z")),
Some(at("2026-09-28T10:05:00Z"))
);
assert_eq!(
every(3600).next_fire(at("1969-12-31T23:30:00Z")),
Some(at("1970-01-01T00:00:00Z"))
);
}
#[test]
fn a_fire_is_strictly_after_the_instant_asked() {
let hourly = cron("0 * * * *", None).expect("compiles");
assert_eq!(
hourly.next_fire(at("2026-09-28T10:00:00Z")),
Some(at("2026-09-28T11:00:00Z"))
);
assert_eq!(
hourly.next_fire(at("2026-09-28T09:59:59Z")),
Some(at("2026-09-28T10:00:00Z"))
);
}
#[test]
fn cron_takes_five_fields_or_six_with_seconds() {
let minutes = cron("*/15 * * * *", None).expect("compiles");
assert_eq!(
fires(&minutes, "2026-09-28T10:01:00Z", 2),
["2026-09-28T10:15:00Z", "2026-09-28T10:30:00Z"]
);
let seconds = cron("*/20 * * * * *", None).expect("compiles");
assert_eq!(
fires(&seconds, "2026-09-28T10:00:00Z", 2),
["2026-09-28T10:00:20Z", "2026-09-28T10:00:40Z"]
);
}
#[test]
fn cron_is_read_in_its_time_zone() {
let nine = cron("0 9 * * *", Some("Europe/London")).expect("compiles");
assert_eq!(
fires(&nine, "2026-07-01T00:00:00Z", 1),
["2026-07-01T08:00:00Z"]
);
assert_eq!(
fires(&nine, "2026-12-01T00:00:00Z", 1),
["2026-12-01T09:00:00Z"]
);
}
#[test]
fn a_time_the_clocks_skip_fires_when_they_resume() {
let daily = cron("30 1 * * *", Some("Europe/London")).expect("compiles");
assert_eq!(
fires(&daily, "2026-03-28T12:00:00Z", 3),
[
"2026-03-29T01:00:00Z",
"2026-03-30T00:30:00Z",
"2026-03-31T00:30:00Z"
]
);
}
#[test]
fn a_time_the_clocks_repeat_fires_once() {
let daily = cron("30 1 * * *", Some("Europe/London")).expect("compiles");
assert_eq!(
fires(&daily, "2026-10-24T12:00:00Z", 3),
[
"2026-10-25T00:30:00Z",
"2026-10-26T01:30:00Z",
"2026-10-27T01:30:00Z"
]
);
assert_eq!(
daily.next_fire(at("2026-10-25T01:15:00Z")),
Some(at("2026-10-26T01:30:00Z")),
"asked during the hour's second pass"
);
let hourly = cron("30 * * * *", Some("Europe/London")).expect("compiles");
assert_eq!(
fires(&hourly, "2026-10-24T23:00:00Z", 3),
[
"2026-10-24T23:30:00Z",
"2026-10-25T00:30:00Z",
"2026-10-25T02:30:00Z"
]
);
}
#[test]
fn a_schedule_is_refused_with_the_field_it_breaks() {
for (result, path, reason) in [
(
cron("* * * * * *", None),
"schedule.spec.expr",
"fires 1 s apart",
),
(
cron("*/5 * * * * *", None),
"schedule.spec.expr",
"fires 5 s apart",
),
(
cron("0,9 * * * * *", None),
"schedule.spec.expr",
"fires 9 s apart",
),
(
cron("61 * * * *", None),
"schedule.spec.expr",
"is not a cron expression",
),
(
cron("not a cron at all", None),
"schedule.spec.expr",
"is not a cron expression",
),
(cron("0 * *", None), "schedule.spec.expr", "five fields"),
(
cron("0 0 31 2 *", None),
"schedule.spec.expr",
"never fires",
),
(
cron("0 * * * *", Some("Mars/Olympus")),
"schedule.timezone",
"is not an IANA time zone name",
),
(
compile("schedule", &schedule(ScheduleSpec::Every { secs: 5 }, None)),
"schedule.spec.secs",
"below the 10 second minimum",
),
] {
let error = result.expect_err(reason);
assert!(error.starts_with(path), "'{error}' does not name {path}");
assert!(error.contains(reason), "'{error}' lacks '{reason}'");
}
}
#[test]
fn ten_seconds_apart_is_allowed() {
assert!(cron("*/10 * * * * *", None).is_ok());
assert!(cron("* * * * *", None).is_ok());
assert!(matches!(every(10), CompiledSpec::Every { secs: 10 }));
}
}