use chrono::{DateTime, LocalResult, NaiveDateTime, TimeZone as _, Utc};
use chrono_tz::Tz;
use crate::cron_dow::normalize_cron_dow;
use crate::routine::RoutineSchedule;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScheduledFire {
pub at: DateTime<Utc>,
pub zone: Tz,
}
impl ScheduledFire {
#[must_use]
pub fn local(&self) -> DateTime<Tz> {
self.at.with_timezone(&self.zone)
}
}
fn zone_or_utc(timezone: Option<&str>) -> Tz {
timezone
.and_then(|z| z.parse::<Tz>().ok())
.unwrap_or(Tz::UTC)
}
#[must_use]
pub fn schedule_zone(schedule: &RoutineSchedule) -> Tz {
match schedule {
RoutineSchedule::Cron { timezone, .. } | RoutineSchedule::Once { timezone, .. } => {
zone_or_utc(timezone.as_deref())
}
}
}
#[must_use]
pub fn local_realizations(tz: Tz, naive: NaiveDateTime) -> Vec<DateTime<Utc>> {
match tz.from_local_datetime(&naive) {
LocalResult::Single(local) => vec![local.with_timezone(&Utc)],
LocalResult::Ambiguous(earlier, later) => {
vec![earlier.with_timezone(&Utc), later.with_timezone(&Utc)]
}
LocalResult::None => Vec::new(),
}
}
#[must_use]
fn soonest_strictly_after(
realizations: &[DateTime<Utc>],
after: DateTime<Utc>,
) -> Option<DateTime<Utc>> {
realizations.iter().copied().filter(|i| *i > after).min()
}
#[must_use]
pub fn once_instant(at: &str, timezone: Option<&str>) -> Option<DateTime<Utc>> {
let naive: NaiveDateTime = at.parse().ok()?;
local_realizations(zone_or_utc(timezone), naive)
.into_iter()
.next()
}
fn fake_utc_of_local(instant: DateTime<Utc>, tz: Tz) -> DateTime<Utc> {
Utc.from_utc_datetime(&instant.with_timezone(&tz).naive_local())
}
fn cron_next_after(cron: &saffron::Cron, tz: Tz, after: DateTime<Utc>) -> Option<DateTime<Utc>> {
let mut cursor = fake_utc_of_local(after, tz);
loop {
let candidate = cron.next_after(cursor)?;
let realizations = local_realizations(tz, candidate.naive_utc());
if let Some(instant) = soonest_strictly_after(&realizations, after) {
return Some(instant);
}
cursor = candidate;
}
}
fn due_ticks(
cron: &saffron::Cron,
tz: Tz,
since: DateTime<Utc>,
now: DateTime<Utc>,
) -> Vec<DateTime<Utc>> {
let mut out = Vec::new();
let mut cursor = since;
while let Some(next) = cron_next_after(cron, tz, cursor) {
if next > now {
break;
}
out.push(next);
cursor = next;
}
out
}
#[must_use]
pub fn next_fire_after(schedule: &RoutineSchedule, after: DateTime<Utc>) -> Option<ScheduledFire> {
match schedule {
RoutineSchedule::Cron {
expression,
timezone,
} => {
let cron = normalize_cron_dow(expression)
.parse::<saffron::Cron>()
.ok()?;
let tz = zone_or_utc(timezone.as_deref());
cron_next_after(&cron, tz, after).map(|at| ScheduledFire { at, zone: tz })
}
RoutineSchedule::Once { at, timezone } => {
let tz = zone_or_utc(timezone.as_deref());
let naive: NaiveDateTime = at.parse().ok()?;
let realizations = local_realizations(tz, naive);
soonest_strictly_after(&realizations, after).map(|at| ScheduledFire { at, zone: tz })
}
}
}
#[must_use]
pub fn next_n_fires_after(
schedule: &RoutineSchedule,
after: DateTime<Utc>,
n: usize,
) -> Vec<ScheduledFire> {
let mut fires = Vec::with_capacity(n);
let mut cursor = after;
while fires.len() < n {
let Some(fire) = next_fire_after(schedule, cursor) else {
break;
};
cursor = fire.at;
fires.push(fire);
}
fires
}
#[must_use]
pub fn due_fires(
schedule: &RoutineSchedule,
since: DateTime<Utc>,
now: DateTime<Utc>,
) -> Option<Vec<DateTime<Utc>>> {
match schedule {
RoutineSchedule::Cron {
expression,
timezone,
} => {
let cron = normalize_cron_dow(expression)
.parse::<saffron::Cron>()
.ok()?;
let tz = zone_or_utc(timezone.as_deref());
Some(due_ticks(&cron, tz, since, now))
}
RoutineSchedule::Once { at, timezone } => {
let tz = zone_or_utc(timezone.as_deref());
let naive: NaiveDateTime = at.parse().ok()?;
let realizations = local_realizations(tz, naive);
Some(match soonest_strictly_after(&realizations, since) {
Some(instant) if instant <= now => vec![instant],
_ => Vec::new(),
})
}
}
}
#[must_use]
pub fn due_fires_and_next(
schedule: &RoutineSchedule,
since: DateTime<Utc>,
now: DateTime<Utc>,
) -> Option<(Vec<DateTime<Utc>>, Option<ScheduledFire>)> {
let due = due_fires(schedule, since, now)?;
Some((due, next_fire_after(schedule, now)))
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
fn utc(s: &str) -> DateTime<Utc> {
s.parse().expect("valid RFC3339")
}
fn cron_schedule(expr: &str, timezone: Option<&str>) -> RoutineSchedule {
RoutineSchedule::Cron {
expression: expr.to_owned(),
timezone: timezone.map(str::to_owned),
}
}
fn once_schedule(at: &str) -> RoutineSchedule {
once_schedule_in(at, None)
}
fn once_schedule_in(at: &str, timezone: Option<&str>) -> RoutineSchedule {
RoutineSchedule::Once {
at: at.to_owned(),
timezone: timezone.map(str::to_owned),
}
}
struct DueFiresCase {
name: &'static str,
schedule: RoutineSchedule,
since: DateTime<Utc>,
now: DateTime<Utc>,
expected: Vec<DateTime<Utc>>,
}
#[test]
fn due_fires_table() {
let cases = vec![
DueFiresCase {
name: "every_10_minutes_utc_finds_every_tick_in_the_window",
schedule: cron_schedule("*/10 * * * *", None),
since: utc("2026-07-20T00:00:00Z"),
now: utc("2026-07-20T00:35:00Z"),
expected: vec![
utc("2026-07-20T00:10:00Z"),
utc("2026-07-20T00:20:00Z"),
utc("2026-07-20T00:30:00Z"),
],
},
DueFiresCase {
name: "excludes_since_and_includes_now",
schedule: cron_schedule("*/10 * * * *", None),
since: utc("2026-07-20T00:10:00Z"),
now: utc("2026-07-20T00:20:00Z"),
expected: vec![utc("2026-07-20T00:20:00Z")],
},
DueFiresCase {
name: "empty_when_nothing_is_due_yet",
schedule: cron_schedule("0 9 * * *", None),
since: utc("2026-07-20T09:00:00Z"),
now: utc("2026-07-20T10:00:00Z"),
expected: vec![],
},
DueFiresCase {
name: "spring_forward_gap_is_skipped_not_fired",
schedule: cron_schedule("30 2 * * *", Some("America/New_York")),
since: utc("2026-03-07T00:00:00Z"),
now: utc("2026-03-10T00:00:00Z"),
expected: vec![utc("2026-03-07T07:30:00Z"), utc("2026-03-09T06:30:00Z")],
},
DueFiresCase {
name: "fall_back_ambiguous_time_fires_once_per_day",
schedule: cron_schedule("30 1 * * *", Some("America/New_York")),
since: utc("2026-10-31T00:00:00Z"),
now: utc("2026-11-02T00:00:00Z"),
expected: vec![utc("2026-10-31T05:30:00Z"), utc("2026-11-01T05:30:00Z")],
},
DueFiresCase {
name: "fall_back_never_regresses_before_since",
schedule: cron_schedule("30 1 * * *", Some("America/New_York")),
since: utc("2026-11-01T06:10:00Z"),
now: utc("2026-11-01T12:00:00Z"),
expected: vec![utc("2026-11-01T06:30:00Z")],
},
DueFiresCase {
name: "standard_weekday_range_fires_monday_through_friday_never_a_weekend",
schedule: cron_schedule("30 11 * * 1-5", None),
since: utc("2026-07-24T00:00:00Z"), now: utc("2026-08-01T00:00:00Z"), expected: vec![
utc("2026-07-24T11:30:00Z"), utc("2026-07-27T11:30:00Z"), utc("2026-07-28T11:30:00Z"), utc("2026-07-29T11:30:00Z"), utc("2026-07-30T11:30:00Z"), utc("2026-07-31T11:30:00Z"), ],
},
DueFiresCase {
name: "standard_sunday_zero_fires_on_sundays",
schedule: cron_schedule("0 9 * * 0", None),
since: utc("2026-07-24T00:00:00Z"),
now: utc("2026-08-02T09:00:00Z"),
expected: vec![utc("2026-07-26T09:00:00Z"), utc("2026-08-02T09:00:00Z")],
},
DueFiresCase {
name: "standard_sunday_seven_also_fires_on_sundays",
schedule: cron_schedule("0 9 * * 7", None),
since: utc("2026-07-24T00:00:00Z"),
now: utc("2026-08-02T09:00:00Z"),
expected: vec![utc("2026-07-26T09:00:00Z"), utc("2026-08-02T09:00:00Z")],
},
DueFiresCase {
name: "standard_saturday_six_fires_on_saturdays",
schedule: cron_schedule("0 9 * * 6", None),
since: utc("2026-07-24T00:00:00Z"),
now: utc("2026-08-01T09:00:00Z"),
expected: vec![utc("2026-07-25T09:00:00Z"), utc("2026-08-01T09:00:00Z")],
},
DueFiresCase {
name: "standard_weekday_step_fires_monday_wednesday_friday",
schedule: cron_schedule("0 9 * * 1-5/2", None),
since: utc("2026-07-24T00:00:00Z"),
now: utc("2026-07-31T09:00:00Z"),
expected: vec![
utc("2026-07-24T09:00:00Z"), utc("2026-07-27T09:00:00Z"), utc("2026-07-29T09:00:00Z"), utc("2026-07-31T09:00:00Z"), ],
},
DueFiresCase {
name: "once_not_yet_due_reports_nothing",
schedule: once_schedule("2026-07-20T00:10:00"),
since: utc("2026-07-20T00:05:00Z"),
now: utc("2026-07-20T00:09:00Z"),
expected: vec![],
},
DueFiresCase {
name: "once_due_fires_exactly_at_its_instant",
schedule: once_schedule("2026-07-20T00:10:00"),
since: utc("2026-07-20T00:05:00Z"),
now: utc("2026-07-20T00:10:00Z"),
expected: vec![utc("2026-07-20T00:10:00Z")],
},
DueFiresCase {
name: "once_already_fired_is_never_due_again",
schedule: once_schedule("2026-07-20T00:10:00"),
since: utc("2026-07-20T00:10:00Z"),
now: utc("2026-07-20T00:20:00Z"),
expected: vec![],
},
DueFiresCase {
name: "once_in_a_fall_back_hour_fires_at_the_earlier_realization_by_default",
schedule: once_schedule_in("2026-11-01T01:30:00", Some("America/New_York")),
since: utc("2026-10-31T00:00:00Z"),
now: utc("2026-11-02T00:00:00Z"),
expected: vec![utc("2026-11-01T05:30:00Z")],
},
DueFiresCase {
name: "once_in_a_fall_back_hour_still_fires_at_the_later_realization_after_since_passes_the_earlier_one",
schedule: once_schedule_in("2026-11-01T01:30:00", Some("America/New_York")),
since: utc("2026-11-01T06:10:00Z"),
now: utc("2026-11-01T12:00:00Z"),
expected: vec![utc("2026-11-01T06:30:00Z")],
},
];
for case in cases {
let actual = due_fires(&case.schedule, case.since, case.now)
.unwrap_or_else(|| panic!("case {}: schedule must parse", case.name));
assert_eq!(actual, case.expected, "case {}", case.name);
}
}
struct NextFireCase {
name: &'static str,
schedule: RoutineSchedule,
after: DateTime<Utc>,
expected: Option<DateTime<Utc>>,
}
#[test]
fn next_fire_after_table() {
let cases = vec![
NextFireCase {
name: "composes_a_named_timezone_outside_dst",
schedule: cron_schedule("0 9 * * *", Some("America/New_York")),
after: utc("2026-07-20T00:00:00Z"),
expected: Some(utc("2026-07-20T13:00:00Z")),
},
NextFireCase {
name: "never_regresses_before_after_across_a_fall_back_ambiguity",
schedule: cron_schedule("30 1 * * *", Some("America/New_York")),
after: utc("2026-11-01T06:10:00Z"),
expected: Some(utc("2026-11-01T06:30:00Z")),
},
NextFireCase {
name: "standard_weekday_range_skips_the_weekend_to_the_next_monday",
schedule: cron_schedule("30 11 * * 1-5", None),
after: utc("2026-07-24T12:00:00Z"), expected: Some(utc("2026-07-27T11:30:00Z")), },
NextFireCase {
name: "a_cron_that_never_matches_has_no_next_fire",
schedule: cron_schedule("* * 31 11 *", None),
after: utc("2026-07-20T00:00:00Z"),
expected: None,
},
NextFireCase {
name: "once_previews_its_own_instant_while_future",
schedule: once_schedule("2026-07-20T00:10:00"),
after: utc("2026-07-20T00:09:00Z"),
expected: Some(utc("2026-07-20T00:10:00Z")),
},
NextFireCase {
name: "once_shows_no_next_fire_once_its_instant_is_reached",
schedule: once_schedule("2026-07-20T00:10:00"),
after: utc("2026-07-20T00:10:00Z"),
expected: None,
},
NextFireCase {
name: "once_never_shows_a_next_fire_again_once_completed",
schedule: once_schedule("2026-07-20T00:10:00"),
after: utc("2026-07-20T00:20:00Z"),
expected: None,
},
NextFireCase {
name: "once_in_a_fall_back_hour_never_regresses_before_after",
schedule: once_schedule_in("2026-11-01T01:30:00", Some("America/New_York")),
after: utc("2026-11-01T06:10:00Z"),
expected: Some(utc("2026-11-01T06:30:00Z")),
},
];
for case in cases {
let actual = next_fire_after(&case.schedule, case.after).map(|f| f.at);
assert_eq!(actual, case.expected, "case {}", case.name);
}
}
#[test]
fn next_fire_after_returns_none_for_an_unparseable_cron_expression() {
let schedule = cron_schedule("not a cron expression", None);
assert_eq!(
next_fire_after(&schedule, utc("2026-07-20T00:00:00Z")),
None
);
}
#[test]
fn next_fire_after_returns_none_for_an_unparseable_once_instant() {
let schedule = once_schedule("not-a-timestamp");
assert_eq!(
next_fire_after(&schedule, utc("2026-07-20T00:00:00Z")),
None
);
}
#[test]
fn due_fires_returns_none_for_an_unparseable_cron_expression() {
let schedule = cron_schedule("not a cron expression", None);
assert_eq!(
due_fires(
&schedule,
utc("2026-07-20T00:00:00Z"),
utc("2026-07-20T00:10:00Z")
),
None
);
}
#[test]
fn due_fires_returns_none_for_an_unparseable_once_instant() {
let schedule = once_schedule("not-a-timestamp");
assert_eq!(
due_fires(
&schedule,
utc("2026-07-20T00:00:00Z"),
utc("2026-07-20T00:10:00Z")
),
None
);
}
#[test]
fn scheduled_fire_local_renders_the_schedules_own_zone() {
let tz: Tz = "America/New_York".parse().unwrap();
let fire = ScheduledFire {
at: utc("2026-07-20T13:00:00Z"),
zone: tz,
};
assert_eq!(
fire.local().naive_local(),
utc("2026-07-20T09:00:00Z").naive_utc()
);
}
#[test]
fn next_n_fires_after_returns_ascending_instants_with_zone_info() {
let schedule = cron_schedule("0 9 * * *", Some("America/New_York"));
let fires = next_n_fires_after(&schedule, utc("2026-07-20T00:00:00Z"), 3);
let tz: Tz = "America/New_York".parse().unwrap();
assert_eq!(
fires.iter().map(|f| f.at).collect::<Vec<_>>(),
vec![
utc("2026-07-20T13:00:00Z"),
utc("2026-07-21T13:00:00Z"),
utc("2026-07-22T13:00:00Z"),
]
);
assert!(fires.iter().all(|f| f.zone == tz));
}
#[test]
fn next_n_fires_after_a_once_schedule_yields_only_its_single_instant() {
let schedule = once_schedule("2026-07-20T00:10:00");
let fires = next_n_fires_after(&schedule, utc("2026-07-20T00:00:00Z"), 5);
assert_eq!(fires.len(), 1);
assert_eq!(fires[0].at, utc("2026-07-20T00:10:00Z"));
assert_eq!(fires[0].zone, Tz::UTC);
}
#[test]
fn next_n_fires_after_a_completed_once_schedule_yields_nothing() {
let schedule = once_schedule("2026-07-20T00:10:00");
let fires = next_n_fires_after(&schedule, utc("2026-07-20T00:20:00Z"), 5);
assert!(fires.is_empty());
}
#[test]
fn schedule_zone_reads_a_crons_own_timezone_and_defaults_to_utc() {
assert_eq!(
schedule_zone(&cron_schedule("0 9 * * *", Some("America/New_York"))),
"America/New_York".parse::<Tz>().unwrap()
);
assert_eq!(schedule_zone(&cron_schedule("0 9 * * *", None)), Tz::UTC);
}
#[test]
fn schedule_zone_reads_a_onces_own_timezone_and_defaults_to_utc() {
assert_eq!(
schedule_zone(&once_schedule_in(
"2026-07-20T00:10:00",
Some("America/New_York")
)),
"America/New_York".parse::<Tz>().unwrap()
);
assert_eq!(
schedule_zone(&once_schedule("2026-07-20T00:10:00")),
Tz::UTC
);
}
#[test]
fn once_schedule_resolves_at_in_its_own_timezone() {
let schedule = once_schedule_in("2026-07-20T09:00:00", Some("America/New_York"));
let fire = next_fire_after(&schedule, utc("2026-07-20T00:00:00Z")).expect("must fire");
assert_eq!(fire.at, utc("2026-07-20T13:00:00Z"));
assert_eq!(fire.zone, "America/New_York".parse::<Tz>().unwrap());
}
#[test]
fn due_fires_and_next_matches_due_fires_plus_next_fire_after() {
let schedule = cron_schedule("*/10 * * * *", None);
let since = utc("2026-07-20T00:00:00Z");
let now = utc("2026-07-20T00:35:00Z");
let (due, next) = due_fires_and_next(&schedule, since, now).expect("valid schedule");
assert_eq!(due, due_fires(&schedule, since, now).unwrap());
assert_eq!(next, next_fire_after(&schedule, now));
}
#[test]
fn due_fires_and_next_matches_for_a_day_of_week_schedule() {
let schedule = cron_schedule("30 11 * * 1-5", None);
let since = utc("2026-07-24T00:00:00Z");
let now = utc("2026-08-01T00:00:00Z");
let (due, next) = due_fires_and_next(&schedule, since, now).expect("valid schedule");
assert_eq!(due, due_fires(&schedule, since, now).unwrap());
assert_eq!(next, next_fire_after(&schedule, now));
assert_eq!(
due,
vec![
utc("2026-07-24T11:30:00Z"),
utc("2026-07-27T11:30:00Z"),
utc("2026-07-28T11:30:00Z"),
utc("2026-07-29T11:30:00Z"),
utc("2026-07-30T11:30:00Z"),
utc("2026-07-31T11:30:00Z"),
]
);
assert_eq!(
next,
Some(ScheduledFire {
at: utc("2026-08-03T11:30:00Z"),
zone: Tz::UTC
})
);
}
}