1use crate::error::{CliError, CliResult};
6use crate::schedule::spec::{OverlapPolicy, ScheduleOnFailure, ScheduleSpec};
7use chrono::{DateTime, Utc};
8use chrono_tz::Tz;
9use croner::Cron;
10use croner::parser::{CronParser, Seconds};
11use std::time::Duration;
12
13#[derive(Debug)]
16pub struct CompiledSchedule {
17 cron: Cron,
18 tz: Tz,
19 pub overlap_policy: OverlapPolicy,
20 pub on_failure: ScheduleOnFailure,
21 pub max_runs: Option<u64>,
22 pub max_consecutive_failures: Option<u64>,
23 pub start_immediately: bool,
24 pub run_timeout: Option<Duration>,
25 pub shutdown_grace: Duration,
26}
27
28impl CompiledSchedule {
29 pub fn compile(spec: &ScheduleSpec) -> CliResult<Self> {
31 let tz: Tz = spec.timezone.parse().map_err(|_| {
32 CliError::Config(format!("schedule: unknown timezone '{}'", spec.timezone))
33 })?;
34
35 let cron = CronParser::builder()
36 .seconds(Seconds::Optional)
37 .build()
38 .parse(&spec.cron)
39 .map_err(|e| {
40 CliError::Config(format!("schedule: invalid cron '{}': {e}", spec.cron))
41 })?;
42
43 if matches!(spec.max_runs, Some(0)) {
44 return Err(CliError::Config(
45 "schedule: max_runs must be >= 1 (use `faucet schedule --once` for a single run, or remove the schedule block)".into(),
46 ));
47 }
48 if matches!(spec.max_consecutive_failures, Some(0)) {
49 return Err(CliError::Config(
50 "schedule: max_consecutive_failures must be >= 1".into(),
51 ));
52 }
53 if matches!(spec.run_timeout_secs, Some(0)) {
54 return Err(CliError::Config(
55 "schedule: run_timeout_secs must be >= 1 (omit it for no timeout)".into(),
56 ));
57 }
58
59 let compiled = Self {
60 cron,
61 tz,
62 overlap_policy: spec.overlap_policy,
63 on_failure: spec.on_failure,
64 max_runs: spec.max_runs,
65 max_consecutive_failures: spec.max_consecutive_failures,
66 start_immediately: spec.start_immediately,
67 run_timeout: spec.run_timeout_secs.map(Duration::from_secs),
68 shutdown_grace: Duration::from_secs(spec.shutdown_grace_secs),
69 };
70
71 if compiled.next_after(Utc::now()).is_none() {
74 return Err(CliError::Config(format!(
75 "schedule: cron '{}' has no upcoming occurrence in timezone '{}'",
76 spec.cron, spec.timezone
77 )));
78 }
79 Ok(compiled)
80 }
81
82 pub fn clock_at(
85 &self,
86 at: chrono::DateTime<chrono::Utc>,
87 ) -> chrono::DateTime<chrono::FixedOffset> {
88 at.with_timezone(&self.tz).fixed_offset()
89 }
90
91 pub fn next_after(&self, after: DateTime<Utc>) -> Option<DateTime<Utc>> {
99 let local = after.with_timezone(&self.tz);
100 self.cron
101 .find_next_occurrence(&local, false)
102 .ok()
103 .map(|dt| dt.with_timezone(&Utc))
104 }
105
106 pub fn next_due_after_tick(
119 &self,
120 fired: DateTime<Utc>,
121 now: DateTime<Utc>,
122 ) -> Option<DateTime<Utc>> {
123 let next = self.next_after(fired)?;
124 if next > now {
125 return Some(next);
127 }
128 match self.next_after(next) {
132 Some(after) if after <= now => self.next_after(now),
133 _ => Some(next),
134 }
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use crate::schedule::spec::ScheduleSpec;
142 use chrono::TimeZone;
143
144 fn spec(cron: &str, tz: &str) -> ScheduleSpec {
145 serde_yaml::from_str(&format!("cron: \"{cron}\"\ntimezone: \"{tz}\"\n")).unwrap()
146 }
147
148 #[test]
149 fn compiles_standard_five_field_cron() {
150 assert!(CompiledSchedule::compile(&spec("0 2 * * *", "UTC")).is_ok());
151 }
152
153 #[test]
154 fn compiles_six_field_seconds_cron() {
155 assert!(CompiledSchedule::compile(&spec("*/30 * * * * *", "UTC")).is_ok());
156 }
157
158 #[test]
159 fn rejects_bad_cron() {
160 let err = CompiledSchedule::compile(&spec("not a cron", "UTC")).unwrap_err();
161 assert!(err.to_string().contains("invalid cron"));
162 }
163
164 #[test]
165 fn rejects_unknown_timezone() {
166 let err = CompiledSchedule::compile(&spec("0 2 * * *", "Mars/Olympus")).unwrap_err();
167 assert!(err.to_string().contains("unknown timezone"));
168 }
169
170 #[test]
171 fn rejects_zero_max_runs() {
172 let mut s = spec("0 2 * * *", "UTC");
173 s.max_runs = Some(0);
174 let err = CompiledSchedule::compile(&s).unwrap_err();
175 assert!(err.to_string().contains("max_runs"));
176 }
177
178 #[test]
179 fn rejects_zero_max_consecutive_failures() {
180 let mut s = spec("0 2 * * *", "UTC");
181 s.max_consecutive_failures = Some(0);
182 let err = CompiledSchedule::compile(&s).unwrap_err();
183 assert!(err.to_string().contains("max_consecutive_failures"));
184 }
185
186 #[test]
187 fn rejects_zero_run_timeout() {
188 let mut s = spec("0 2 * * *", "UTC");
189 s.run_timeout_secs = Some(0);
190 let err = CompiledSchedule::compile(&s).unwrap_err();
191 assert!(err.to_string().contains("run_timeout_secs"));
192 }
193
194 #[test]
195 fn rejects_never_firing_cron() {
196 let err = CompiledSchedule::compile(&spec("0 0 30 2 *", "UTC")).unwrap_err();
198 assert!(err.to_string().contains("no upcoming occurrence"));
199 }
200
201 #[test]
202 fn next_after_is_strictly_after_and_skips_missed() {
203 let c = CompiledSchedule::compile(&spec("0 0 * * *", "UTC")).unwrap(); let after = Utc.with_ymd_and_hms(2026, 3, 10, 6, 0, 0).unwrap();
206 let next = c.next_after(after).unwrap();
207 assert_eq!(next, Utc.with_ymd_and_hms(2026, 3, 11, 0, 0, 0).unwrap());
208 let just_before = Utc.with_ymd_and_hms(2026, 3, 10, 23, 59, 59).unwrap();
210 assert_eq!(
211 c.next_after(just_before).unwrap(),
212 Utc.with_ymd_and_hms(2026, 3, 11, 0, 0, 0).unwrap()
213 );
214 }
215
216 #[test]
217 fn next_due_after_tick_runs_a_single_missed_sub_minute_occurrence() {
218 let c = CompiledSchedule::compile(&spec("* * * * * *", "UTC")).unwrap();
223 let fired = Utc.with_ymd_and_hms(2026, 3, 10, 6, 0, 0).unwrap();
224 let now = Utc.with_ymd_and_hms(2026, 3, 10, 6, 0, 1).unwrap()
225 + chrono::Duration::milliseconds(300);
226 let due = c.next_due_after_tick(fired, now).unwrap();
227 assert_eq!(due, Utc.with_ymd_and_hms(2026, 3, 10, 6, 0, 1).unwrap());
228 assert_ne!(due, c.next_after(now).unwrap());
230 }
231
232 #[test]
233 fn next_due_after_tick_returns_future_occurrence_when_on_schedule() {
234 let c = CompiledSchedule::compile(&spec("* * * * * *", "UTC")).unwrap();
235 let fired = Utc.with_ymd_and_hms(2026, 3, 10, 6, 0, 0).unwrap();
236 let due = c.next_due_after_tick(fired, fired).unwrap();
238 assert_eq!(due, Utc.with_ymd_and_hms(2026, 3, 10, 6, 0, 1).unwrap());
239 }
240
241 #[test]
242 fn next_due_after_tick_collapses_a_long_backlog_to_one_catch_up() {
243 let c = CompiledSchedule::compile(&spec("* * * * * *", "UTC")).unwrap();
246 let fired = Utc.with_ymd_and_hms(2026, 3, 10, 6, 0, 0).unwrap();
247 let now = Utc.with_ymd_and_hms(2026, 3, 10, 6, 1, 40).unwrap(); let due = c.next_due_after_tick(fired, now).unwrap();
249 assert_eq!(due, Utc.with_ymd_and_hms(2026, 3, 10, 6, 1, 41).unwrap());
250 assert!(due > now, "collapsed catch-up must be in the future");
251 }
252
253 #[test]
254 fn dst_spring_forward_rolls_to_next_valid_time() {
255 let c = CompiledSchedule::compile(&spec("30 2 * * *", "America/Los_Angeles")).unwrap();
258 let after = Utc.with_ymd_and_hms(2026, 3, 8, 9, 0, 0).unwrap();
260 let next = c
261 .next_after(after)
262 .expect("must produce a valid occurrence");
263 assert!(next > after);
265 }
266
267 #[test]
268 fn dst_fall_back_does_not_double_fire() {
269 let c = CompiledSchedule::compile(&spec("30 1 * * *", "America/Los_Angeles")).unwrap();
274 let after = Utc.with_ymd_and_hms(2026, 11, 1, 8, 0, 0).unwrap(); let first = c.next_after(after).unwrap();
276 let second = c.next_after(first).unwrap();
277 assert!(
278 (second - first) >= chrono::Duration::hours(23),
279 "fall-back produced a duplicate same-day fire: {first} -> {second}"
280 );
281 }
282}