use chrono::{DateTime, Datelike, Duration as ChronoDuration, Timelike, Utc};
pub(crate) const MAX_MISSED_RUNS_COUNTED: i64 = 1000;
pub(crate) fn missed_cron_occurrences(
cron: &str,
due_at: DateTime<Utc>,
now: DateTime<Utc>,
) -> i64 {
let mut missed = 0i64;
let mut cursor = due_at;
while missed < MAX_MISSED_RUNS_COUNTED {
match next_cron_after(cron, cursor) {
Some(next) if next <= now => {
missed += 1;
cursor = next;
}
_ => break,
}
}
missed
}
fn expand_cron_macro(expr: &str) -> String {
match expr.trim() {
"@hourly" => "0 * * * *".to_string(),
"@daily" | "@midnight" => "0 0 * * *".to_string(),
"@weekly" => "0 0 * * 0".to_string(),
"@monthly" => "0 0 1 * *".to_string(),
"@yearly" | "@annually" => "0 0 1 1 *".to_string(),
other => other.to_string(),
}
}
fn parse_cron_field(field: &str, min: u8, max: u8) -> Option<Vec<u8>> {
let mut out = Vec::new();
for part in field.split(',') {
let part = part.trim();
if part.is_empty() {
return None;
}
let (range, step) = match part.split_once('/') {
Some((r, s)) => (r, s.trim().parse::<u8>().ok().filter(|v| *v > 0)?),
None => (part, 1u8),
};
let (lo, hi) = if range == "*" {
(min, max)
} else if let Some((a, b)) = range.split_once('-') {
(a.trim().parse::<u8>().ok()?, b.trim().parse::<u8>().ok()?)
} else {
let v = range.trim().parse::<u8>().ok()?;
(v, v)
};
if lo < min || hi > max || lo > hi {
return None;
}
let mut v = lo;
while v <= hi {
out.push(v);
v = v.saturating_add(step);
if v == 0 {
break;
}
}
}
out.sort_unstable();
out.dedup();
if out.is_empty() { None } else { Some(out) }
}
pub(crate) fn next_cron_after(expr: &str, after: DateTime<Utc>) -> Option<DateTime<Utc>> {
let normalized = expand_cron_macro(expr);
let fields: Vec<&str> = normalized.split_whitespace().collect();
if fields.len() != 5 {
return None;
}
let minutes = parse_cron_field(fields[0], 0, 59)?;
let hours = parse_cron_field(fields[1], 0, 23)?;
let doms = parse_cron_field(fields[2], 1, 31)?;
let months = parse_cron_field(fields[3], 1, 12)?;
let dows = parse_cron_field(fields[4], 0, 6)?;
let dom_restricted = fields[2].trim() != "*";
let dow_restricted = fields[4].trim() != "*";
let mut t = (after + ChronoDuration::minutes(1))
.with_second(0)?
.with_nanosecond(0)?;
let horizon = after + ChronoDuration::days(366);
while t <= horizon {
let minute = t.minute() as u8;
let hour = t.hour() as u8;
let dom = t.day() as u8;
let month = t.month() as u8;
let dow = t.weekday().num_days_from_sunday() as u8; let day_ok = match (dom_restricted, dow_restricted) {
(false, false) => true,
(true, false) => doms.contains(&dom),
(false, true) => dows.contains(&dow),
(true, true) => doms.contains(&dom) || dows.contains(&dow),
};
if minutes.contains(&minute) && hours.contains(&hour) && months.contains(&month) && day_ok {
return Some(t);
}
t = t + ChronoDuration::minutes(1);
}
None
}