use chrono::{DateTime, LocalResult, 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, .. } => zone_or_utc(timezone.as_deref()),
RoutineSchedule::Once { .. } => Tz::UTC,
}
}
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 candidate_naive = candidate.naive_utc();
let realizations: Vec<DateTime<Utc>> = match tz.from_local_datetime(&candidate_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(),
};
if let Some(instant) = realizations.into_iter().filter(|i| *i > after).min() {
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 } => {
let instant = at.parse::<DateTime<Utc>>().ok()?;
(instant > after).then_some(ScheduledFire {
at: instant,
zone: Tz::UTC,
})
}
}
}
#[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 } => {
let instant = at.parse::<DateTime<Utc>>().ok()?;
Some(if since < instant && instant <= now {
vec![instant]
} else {
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 {
RoutineSchedule::Once { at: at.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:00Z"),
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:00Z"),
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:00Z"),
since: utc("2026-07-20T00:10:00Z"),
now: utc("2026-07-20T00:20:00Z"),
expected: vec![],
},
];
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:00Z"),
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:00Z"),
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:00Z"),
after: utc("2026-07-20T00:20:00Z"),
expected: None,
},
];
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:00Z");
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:00Z");
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_is_always_utc_for_a_once_schedule() {
assert_eq!(
schedule_zone(&once_schedule("2026-07-20T00:10:00Z")),
Tz::UTC
);
}
#[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
})
);
}
}