use crate::config::ScheduleError;
use crate::conflict::enumerate_next_occurrences;
use crate::schedule::Schedule;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub const MAX_MISSED_OCCURRENCES: usize = 100_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum CatchupPolicy {
FireAll,
#[default]
FireLatestOnly,
Skip,
}
impl CatchupPolicy {
pub fn apply(&self, missed: &[DateTime<Utc>]) -> Vec<DateTime<Utc>> {
match self {
CatchupPolicy::FireAll => missed.to_vec(),
CatchupPolicy::FireLatestOnly => missed.last().copied().into_iter().collect(),
CatchupPolicy::Skip => Vec::new(),
}
}
pub fn as_str(&self) -> &'static str {
match self {
CatchupPolicy::FireAll => "fire_all",
CatchupPolicy::FireLatestOnly => "fire_latest_only",
CatchupPolicy::Skip => "skip",
}
}
}
impl std::fmt::Display for CatchupPolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MissedOccurrences {
pub instants: Vec<DateTime<Utc>>,
pub truncated: bool,
}
impl MissedOccurrences {
pub fn len(&self) -> usize {
self.instants.len()
}
pub fn is_empty(&self) -> bool {
self.instants.is_empty()
}
pub fn earliest(&self) -> Option<DateTime<Utc>> {
self.instants.first().copied()
}
pub fn latest(&self) -> Option<DateTime<Utc>> {
self.instants.last().copied()
}
pub fn apply_policy(&self, policy: CatchupPolicy) -> Vec<DateTime<Utc>> {
policy.apply(&self.instants)
}
}
pub fn compute_missed(
schedule: &Schedule,
last_run: DateTime<Utc>,
now: DateTime<Utc>,
) -> Result<MissedOccurrences, ScheduleError> {
if now <= last_run {
return Ok(MissedOccurrences {
instants: Vec::new(),
truncated: false,
});
}
let mut instants = Vec::new();
let mut cursor = last_run;
let mut truncated = false;
loop {
let next = enumerate_next_occurrences(schedule, cursor, 1)?;
let Some(instant) = next.into_iter().next() else {
break;
};
if instant > now {
break;
}
instants.push(instant);
cursor = instant;
if instants.len() >= MAX_MISSED_OCCURRENCES {
let peek = enumerate_next_occurrences(schedule, cursor, 1)?;
if let Some(next_instant) = peek.into_iter().next() {
if next_instant <= now {
truncated = true;
}
}
break;
}
}
Ok(MissedOccurrences {
instants,
truncated,
})
}
pub fn compute_missed_occurrences(
schedule: &Schedule,
last_run: DateTime<Utc>,
now: DateTime<Utc>,
) -> Result<Vec<DateTime<Utc>>, ScheduleError> {
Ok(compute_missed(schedule, last_run, now)?.instants)
}
pub fn catch_up(
schedule: &Schedule,
last_run: DateTime<Utc>,
now: DateTime<Utc>,
policy: CatchupPolicy,
) -> Result<Vec<DateTime<Utc>>, ScheduleError> {
let missed = compute_missed(schedule, last_run, now)?;
Ok(policy.apply(&missed.instants))
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn ts(y: i32, mo: u32, d: u32, h: u32, mi: u32, s: u32) -> DateTime<Utc> {
Utc.with_ymd_and_hms(y, mo, d, h, mi, s)
.single()
.expect("valid timestamp")
}
#[test]
fn policy_apply_fire_all() {
let missed = vec![
ts(2026, 6, 13, 1, 0, 0),
ts(2026, 6, 13, 2, 0, 0),
ts(2026, 6, 13, 3, 0, 0),
];
assert_eq!(CatchupPolicy::FireAll.apply(&missed), missed);
}
#[test]
fn policy_apply_latest_only() {
let missed = vec![
ts(2026, 6, 13, 1, 0, 0),
ts(2026, 6, 13, 2, 0, 0),
ts(2026, 6, 13, 3, 0, 0),
];
let out = CatchupPolicy::FireLatestOnly.apply(&missed);
assert_eq!(out, vec![ts(2026, 6, 13, 3, 0, 0)]);
}
#[test]
fn policy_apply_skip() {
let missed = vec![ts(2026, 6, 13, 1, 0, 0)];
assert!(CatchupPolicy::Skip.apply(&missed).is_empty());
}
#[test]
fn policy_apply_on_empty() {
let empty: Vec<DateTime<Utc>> = Vec::new();
assert!(CatchupPolicy::FireAll.apply(&empty).is_empty());
assert!(CatchupPolicy::FireLatestOnly.apply(&empty).is_empty());
assert!(CatchupPolicy::Skip.apply(&empty).is_empty());
}
#[test]
fn policy_default_is_latest_only() {
assert_eq!(CatchupPolicy::default(), CatchupPolicy::FireLatestOnly);
}
#[test]
fn policy_display_and_str() {
assert_eq!(CatchupPolicy::FireAll.as_str(), "fire_all");
assert_eq!(
CatchupPolicy::FireLatestOnly.to_string(),
"fire_latest_only"
);
assert_eq!(CatchupPolicy::Skip.to_string(), "skip");
}
#[test]
fn missed_interval_simple() {
let sched = Schedule::interval(60);
let last_run = ts(2026, 6, 13, 0, 0, 0);
let now = ts(2026, 6, 13, 0, 3, 30);
let missed = compute_missed(&sched, last_run, now).expect("missed");
assert_eq!(missed.len(), 3);
assert_eq!(missed.earliest(), Some(ts(2026, 6, 13, 0, 1, 0)));
assert_eq!(missed.latest(), Some(ts(2026, 6, 13, 0, 3, 0)));
assert!(!missed.truncated);
}
#[test]
fn missed_includes_occurrence_exactly_at_now() {
let sched = Schedule::interval(60);
let last_run = ts(2026, 6, 13, 0, 0, 0);
let now = ts(2026, 6, 13, 0, 2, 0);
let missed = compute_missed(&sched, last_run, now).expect("missed");
assert_eq!(missed.len(), 2);
assert_eq!(missed.latest(), Some(now));
}
#[test]
fn missed_none_when_now_before_last_run() {
let sched = Schedule::interval(60);
let last_run = ts(2026, 6, 13, 1, 0, 0);
let now = ts(2026, 6, 13, 0, 0, 0);
let missed = compute_missed(&sched, last_run, now).expect("missed");
assert!(missed.is_empty());
}
#[test]
fn missed_none_when_no_time_elapsed() {
let sched = Schedule::interval(60);
let t = ts(2026, 6, 13, 1, 0, 0);
let missed = compute_missed(&sched, t, t).expect("missed");
assert!(missed.is_empty());
}
#[test]
fn missed_onetime_in_window() {
let run_at = ts(2026, 6, 13, 1, 0, 0);
let sched = Schedule::onetime(run_at);
let last_run = ts(2026, 6, 13, 0, 0, 0);
let now = ts(2026, 6, 13, 2, 0, 0);
let missed = compute_missed(&sched, last_run, now).expect("missed");
assert_eq!(missed.instants, vec![run_at]);
}
#[test]
fn missed_onetime_in_future_is_empty() {
let run_at = ts(2026, 6, 13, 5, 0, 0);
let sched = Schedule::onetime(run_at);
let last_run = ts(2026, 6, 13, 0, 0, 0);
let now = ts(2026, 6, 13, 2, 0, 0);
let missed = compute_missed(&sched, last_run, now).expect("missed");
assert!(missed.is_empty());
}
#[test]
fn catch_up_combines_compute_and_policy() {
let sched = Schedule::interval(60);
let last_run = ts(2026, 6, 13, 0, 0, 0);
let now = ts(2026, 6, 13, 0, 3, 30);
let all = catch_up(&sched, last_run, now, CatchupPolicy::FireAll).expect("all");
assert_eq!(all.len(), 3);
let latest =
catch_up(&sched, last_run, now, CatchupPolicy::FireLatestOnly).expect("latest");
assert_eq!(latest, vec![ts(2026, 6, 13, 0, 3, 0)]);
let skip = catch_up(&sched, last_run, now, CatchupPolicy::Skip).expect("skip");
assert!(skip.is_empty());
}
#[test]
fn compute_missed_occurrences_helper_matches() {
let sched = Schedule::interval(60);
let last_run = ts(2026, 6, 13, 0, 0, 0);
let now = ts(2026, 6, 13, 0, 3, 0);
let list = compute_missed_occurrences(&sched, last_run, now).expect("list");
let full = compute_missed(&sched, last_run, now).expect("full");
assert_eq!(list, full.instants);
}
#[test]
fn apply_policy_via_missed_struct() {
let sched = Schedule::interval(60);
let last_run = ts(2026, 6, 13, 0, 0, 0);
let now = ts(2026, 6, 13, 0, 3, 0);
let missed = compute_missed(&sched, last_run, now).expect("missed");
assert_eq!(missed.apply_policy(CatchupPolicy::FireLatestOnly).len(), 1);
assert_eq!(missed.apply_policy(CatchupPolicy::FireAll).len(), 3);
}
#[cfg(feature = "cron")]
#[test]
fn missed_weekday_schedule_skips_weekend() {
let sched = Schedule::crontab("0", "9", "1-5", "*", "*");
let last_run = ts(2026, 6, 12, 9, 0, 0);
let now = ts(2026, 6, 15, 10, 0, 0);
let missed = compute_missed(&sched, last_run, now).expect("missed");
assert_eq!(missed.len(), 1);
assert_eq!(missed.latest(), Some(ts(2026, 6, 15, 9, 0, 0)));
}
#[cfg(feature = "cron")]
#[test]
fn missed_hourly_three_runs() {
let sched = Schedule::crontab("0", "*", "*", "*", "*");
let last_run = ts(2026, 6, 13, 0, 0, 0);
let now = ts(2026, 6, 13, 3, 30, 0);
let missed = compute_missed(&sched, last_run, now).expect("missed");
assert_eq!(missed.len(), 3);
assert_eq!(missed.instants[0], ts(2026, 6, 13, 1, 0, 0));
assert_eq!(missed.instants[2], ts(2026, 6, 13, 3, 0, 0));
let latest = CatchupPolicy::FireLatestOnly.apply(&missed.instants);
assert_eq!(latest, vec![ts(2026, 6, 13, 3, 0, 0)]);
}
}