use crate::error::{CliError, CliResult};
use crate::schedule::spec::{OverlapPolicy, ScheduleOnFailure, ScheduleSpec};
use chrono::{DateTime, Utc};
use chrono_tz::Tz;
use croner::Cron;
use croner::parser::{CronParser, Seconds};
use std::time::Duration;
#[derive(Debug)]
pub struct CompiledSchedule {
cron: Cron,
tz: Tz,
pub overlap_policy: OverlapPolicy,
pub on_failure: ScheduleOnFailure,
pub max_runs: Option<u64>,
pub max_consecutive_failures: Option<u64>,
pub start_immediately: bool,
pub run_timeout: Option<Duration>,
pub shutdown_grace: Duration,
}
impl CompiledSchedule {
pub fn compile(spec: &ScheduleSpec) -> CliResult<Self> {
let tz: Tz = spec.timezone.parse().map_err(|_| {
CliError::Config(format!("schedule: unknown timezone '{}'", spec.timezone))
})?;
let cron = CronParser::builder()
.seconds(Seconds::Optional)
.build()
.parse(&spec.cron)
.map_err(|e| {
CliError::Config(format!("schedule: invalid cron '{}': {e}", spec.cron))
})?;
if matches!(spec.max_runs, Some(0)) {
return Err(CliError::Config(
"schedule: max_runs must be >= 1 (use `faucet schedule --once` for a single run, or remove the schedule block)".into(),
));
}
if matches!(spec.max_consecutive_failures, Some(0)) {
return Err(CliError::Config(
"schedule: max_consecutive_failures must be >= 1".into(),
));
}
if matches!(spec.run_timeout_secs, Some(0)) {
return Err(CliError::Config(
"schedule: run_timeout_secs must be >= 1 (omit it for no timeout)".into(),
));
}
let compiled = Self {
cron,
tz,
overlap_policy: spec.overlap_policy,
on_failure: spec.on_failure,
max_runs: spec.max_runs,
max_consecutive_failures: spec.max_consecutive_failures,
start_immediately: spec.start_immediately,
run_timeout: spec.run_timeout_secs.map(Duration::from_secs),
shutdown_grace: Duration::from_secs(spec.shutdown_grace_secs),
};
if compiled.next_after(Utc::now()).is_none() {
return Err(CliError::Config(format!(
"schedule: cron '{}' has no upcoming occurrence in timezone '{}'",
spec.cron, spec.timezone
)));
}
Ok(compiled)
}
pub fn clock_at(
&self,
at: chrono::DateTime<chrono::Utc>,
) -> chrono::DateTime<chrono::FixedOffset> {
at.with_timezone(&self.tz).fixed_offset()
}
pub fn next_after(&self, after: DateTime<Utc>) -> Option<DateTime<Utc>> {
let local = after.with_timezone(&self.tz);
self.cron
.find_next_occurrence(&local, false)
.ok()
.map(|dt| dt.with_timezone(&Utc))
}
pub fn next_due_after_tick(
&self,
fired: DateTime<Utc>,
now: DateTime<Utc>,
) -> Option<DateTime<Utc>> {
let next = self.next_after(fired)?;
if next > now {
return Some(next);
}
match self.next_after(next) {
Some(after) if after <= now => self.next_after(now),
_ => Some(next),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schedule::spec::ScheduleSpec;
use chrono::TimeZone;
fn spec(cron: &str, tz: &str) -> ScheduleSpec {
serde_yaml::from_str(&format!("cron: \"{cron}\"\ntimezone: \"{tz}\"\n")).unwrap()
}
#[test]
fn compiles_standard_five_field_cron() {
assert!(CompiledSchedule::compile(&spec("0 2 * * *", "UTC")).is_ok());
}
#[test]
fn compiles_six_field_seconds_cron() {
assert!(CompiledSchedule::compile(&spec("*/30 * * * * *", "UTC")).is_ok());
}
#[test]
fn rejects_bad_cron() {
let err = CompiledSchedule::compile(&spec("not a cron", "UTC")).unwrap_err();
assert!(err.to_string().contains("invalid cron"));
}
#[test]
fn rejects_unknown_timezone() {
let err = CompiledSchedule::compile(&spec("0 2 * * *", "Mars/Olympus")).unwrap_err();
assert!(err.to_string().contains("unknown timezone"));
}
#[test]
fn rejects_zero_max_runs() {
let mut s = spec("0 2 * * *", "UTC");
s.max_runs = Some(0);
let err = CompiledSchedule::compile(&s).unwrap_err();
assert!(err.to_string().contains("max_runs"));
}
#[test]
fn rejects_zero_max_consecutive_failures() {
let mut s = spec("0 2 * * *", "UTC");
s.max_consecutive_failures = Some(0);
let err = CompiledSchedule::compile(&s).unwrap_err();
assert!(err.to_string().contains("max_consecutive_failures"));
}
#[test]
fn rejects_zero_run_timeout() {
let mut s = spec("0 2 * * *", "UTC");
s.run_timeout_secs = Some(0);
let err = CompiledSchedule::compile(&s).unwrap_err();
assert!(err.to_string().contains("run_timeout_secs"));
}
#[test]
fn rejects_never_firing_cron() {
let err = CompiledSchedule::compile(&spec("0 0 30 2 *", "UTC")).unwrap_err();
assert!(err.to_string().contains("no upcoming occurrence"));
}
#[test]
fn next_after_is_strictly_after_and_skips_missed() {
let c = CompiledSchedule::compile(&spec("0 0 * * *", "UTC")).unwrap(); let after = Utc.with_ymd_and_hms(2026, 3, 10, 6, 0, 0).unwrap();
let next = c.next_after(after).unwrap();
assert_eq!(next, Utc.with_ymd_and_hms(2026, 3, 11, 0, 0, 0).unwrap());
let just_before = Utc.with_ymd_and_hms(2026, 3, 10, 23, 59, 59).unwrap();
assert_eq!(
c.next_after(just_before).unwrap(),
Utc.with_ymd_and_hms(2026, 3, 11, 0, 0, 0).unwrap()
);
}
#[test]
fn next_due_after_tick_runs_a_single_missed_sub_minute_occurrence() {
let c = CompiledSchedule::compile(&spec("* * * * * *", "UTC")).unwrap();
let fired = Utc.with_ymd_and_hms(2026, 3, 10, 6, 0, 0).unwrap();
let now = Utc.with_ymd_and_hms(2026, 3, 10, 6, 0, 1).unwrap()
+ chrono::Duration::milliseconds(300);
let due = c.next_due_after_tick(fired, now).unwrap();
assert_eq!(due, Utc.with_ymd_and_hms(2026, 3, 10, 6, 0, 1).unwrap());
assert_ne!(due, c.next_after(now).unwrap());
}
#[test]
fn next_due_after_tick_returns_future_occurrence_when_on_schedule() {
let c = CompiledSchedule::compile(&spec("* * * * * *", "UTC")).unwrap();
let fired = Utc.with_ymd_and_hms(2026, 3, 10, 6, 0, 0).unwrap();
let due = c.next_due_after_tick(fired, fired).unwrap();
assert_eq!(due, Utc.with_ymd_and_hms(2026, 3, 10, 6, 0, 1).unwrap());
}
#[test]
fn next_due_after_tick_collapses_a_long_backlog_to_one_catch_up() {
let c = CompiledSchedule::compile(&spec("* * * * * *", "UTC")).unwrap();
let fired = Utc.with_ymd_and_hms(2026, 3, 10, 6, 0, 0).unwrap();
let now = Utc.with_ymd_and_hms(2026, 3, 10, 6, 1, 40).unwrap(); let due = c.next_due_after_tick(fired, now).unwrap();
assert_eq!(due, Utc.with_ymd_and_hms(2026, 3, 10, 6, 1, 41).unwrap());
assert!(due > now, "collapsed catch-up must be in the future");
}
#[test]
fn dst_spring_forward_rolls_to_next_valid_time() {
let c = CompiledSchedule::compile(&spec("30 2 * * *", "America/Los_Angeles")).unwrap();
let after = Utc.with_ymd_and_hms(2026, 3, 8, 9, 0, 0).unwrap();
let next = c
.next_after(after)
.expect("must produce a valid occurrence");
assert!(next > after);
}
#[test]
fn dst_fall_back_does_not_double_fire() {
let c = CompiledSchedule::compile(&spec("30 1 * * *", "America/Los_Angeles")).unwrap();
let after = Utc.with_ymd_and_hms(2026, 11, 1, 8, 0, 0).unwrap(); let first = c.next_after(after).unwrap();
let second = c.next_after(first).unwrap();
assert!(
(second - first) >= chrono::Duration::hours(23),
"fall-back produced a duplicate same-day fire: {first} -> {second}"
);
}
}