1use chrono::{DateTime, Datelike, Duration, LocalResult, NaiveDate, TimeZone, Timelike, Utc};
31use chrono_tz::Tz;
32use serde::{Deserialize, Serialize};
33
34const HORIZON_DAYS: i64 = 366 * 4;
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(try_from = "String", into = "String")]
43pub struct Schedule {
44 source: String,
47 minutes: u64,
48 hours: u64,
49 days: u64,
51 months: u64,
53 weekdays: u64,
55 dom_restricted: bool,
60 dow_restricted: bool,
61}
62
63impl std::fmt::Display for Schedule {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 f.write_str(&self.source)
66 }
67}
68
69impl From<Schedule> for String {
70 fn from(s: Schedule) -> String {
71 s.source
72 }
73}
74
75impl TryFrom<String> for Schedule {
76 type Error = anyhow::Error;
77 fn try_from(s: String) -> anyhow::Result<Self> {
78 Schedule::parse(&s)
79 }
80}
81
82impl std::str::FromStr for Schedule {
83 type Err = anyhow::Error;
84 fn from_str(s: &str) -> anyhow::Result<Self> {
85 Schedule::parse(s)
86 }
87}
88
89impl Schedule {
90 pub fn parse(expr: &str) -> anyhow::Result<Self> {
97 let expr = expr.trim();
98 let expanded = match expr.to_ascii_lowercase().as_str() {
99 "@yearly" | "@annually" => "0 0 1 1 *",
100 "@monthly" => "0 0 1 * *",
101 "@weekly" => "0 0 * * 0",
102 "@daily" | "@midnight" => "0 0 * * *",
103 "@hourly" => "0 * * * *",
104 "@reboot" => anyhow::bail!(
105 "`@reboot` has no meaning for a mecha trigger — there is no boot to hang \
106 it on. Use an explicit schedule."
107 ),
108 other if other.starts_with('@') => {
109 anyhow::bail!(
110 "unknown schedule alias `{expr}` (known: @hourly, @daily, @midnight, \
111 @weekly, @monthly, @yearly)"
112 )
113 }
114 _ => expr,
115 };
116
117 let fields: Vec<&str> = expanded.split_whitespace().collect();
118 anyhow::ensure!(
119 fields.len() == 5,
120 "a cron schedule has five fields — minute hour day-of-month month day-of-week \
121 — but `{expr}` has {}. (Seconds are not a field here: `0 7 * * *` is 7am.)",
122 fields.len()
123 );
124
125 let minutes =
126 parse_field(fields[0], 0, 59, &[]).map_err(|e| ctx("minute", fields[0], e))?;
127 let hours = parse_field(fields[1], 0, 23, &[]).map_err(|e| ctx("hour", fields[1], e))?;
128 let days =
129 parse_field(fields[2], 1, 31, &[]).map_err(|e| ctx("day-of-month", fields[2], e))?;
130 let months =
131 parse_field(fields[3], 1, 12, MONTHS).map_err(|e| ctx("month", fields[3], e))?;
132 let weekdays =
133 parse_field(fields[4], 0, 7, WEEKDAYS).map_err(|e| ctx("day-of-week", fields[4], e))?;
134
135 let weekdays = if weekdays & (1 << 7) != 0 {
137 (weekdays | 1) & !(1 << 7)
138 } else {
139 weekdays
140 };
141
142 Ok(Schedule {
143 source: expr.to_string(),
144 minutes,
145 hours,
146 days,
147 months,
148 weekdays,
149 dom_restricted: fields[2] != "*",
150 dow_restricted: fields[4] != "*",
151 })
152 }
153
154 pub fn source(&self) -> &str {
155 &self.source
156 }
157
158 fn matches_day(&self, date: NaiveDate) -> bool {
160 if self.months & (1 << date.month()) == 0 {
161 return false;
162 }
163 let dom = self.days & (1 << date.day()) != 0;
164 let dow = self.weekdays & (1 << date.weekday().num_days_from_sunday()) != 0;
165 match (self.dom_restricted, self.dow_restricted) {
166 (true, true) => dom || dow,
169 (true, false) => dom,
170 (false, true) => dow,
171 (false, false) => true,
172 }
173 }
174
175 pub fn next_after(&self, after: DateTime<Utc>, tz: Tz) -> Option<DateTime<Utc>> {
180 let local = after.with_timezone(&tz);
183 let mut date = local.date_naive();
184 let mut from_minute = local.hour() * 60 + local.minute() + 1;
185
186 for _ in 0..HORIZON_DAYS {
187 if self.matches_day(date) {
188 for minute in from_minute..24 * 60 {
189 if !self.matches_minute(minute) {
190 continue;
191 }
192 if let Some(utc) = self.resolve(date, minute, tz) {
193 if utc > after {
197 return Some(utc);
198 }
199 }
200 }
201 }
202 date = date.succ_opt()?;
203 from_minute = 0;
204 }
205 None
206 }
207
208 pub fn prev_at_or_before(&self, at: DateTime<Utc>, tz: Tz) -> Option<DateTime<Utc>> {
214 let local = at.with_timezone(&tz);
215 let mut date = local.date_naive();
216 let mut to_minute = local.hour() * 60 + local.minute();
217
218 for _ in 0..HORIZON_DAYS {
219 if self.matches_day(date) {
220 for minute in (0..=to_minute).rev() {
221 if !self.matches_minute(minute) {
222 continue;
223 }
224 if let Some(utc) = self.resolve(date, minute, tz) {
225 if utc <= at {
226 return Some(utc);
227 }
228 }
229 }
230 }
231 date = date.pred_opt()?;
232 to_minute = 24 * 60 - 1;
233 }
234 None
235 }
236
237 fn matches_minute(&self, minute_of_day: u32) -> bool {
238 self.hours & (1 << (minute_of_day / 60)) != 0
239 && self.minutes & (1 << (minute_of_day % 60)) != 0
240 }
241
242 fn resolve(&self, date: NaiveDate, minute_of_day: u32, tz: Tz) -> Option<DateTime<Utc>> {
254 let naive = date.and_hms_opt(minute_of_day / 60, minute_of_day % 60, 0)?;
255 match tz.from_local_datetime(&naive) {
256 LocalResult::Single(dt) => Some(dt.with_timezone(&Utc)),
257 LocalResult::Ambiguous(earlier, _) => Some(earlier.with_timezone(&Utc)),
258 LocalResult::None => {
259 let mut probe = naive;
262 for _ in 0..180 {
263 probe += Duration::minutes(1);
264 match tz.from_local_datetime(&probe) {
265 LocalResult::Single(dt) => return Some(dt.with_timezone(&Utc)),
266 LocalResult::Ambiguous(earlier, _) => {
267 return Some(earlier.with_timezone(&Utc))
268 }
269 LocalResult::None => continue,
270 }
271 }
272 None
273 }
274 }
275 }
276}
277
278const MONTHS: &[(&str, u32)] = &[
279 ("jan", 1),
280 ("feb", 2),
281 ("mar", 3),
282 ("apr", 4),
283 ("may", 5),
284 ("jun", 6),
285 ("jul", 7),
286 ("aug", 8),
287 ("sep", 9),
288 ("oct", 10),
289 ("nov", 11),
290 ("dec", 12),
291];
292
293const WEEKDAYS: &[(&str, u32)] = &[
294 ("sun", 0),
295 ("mon", 1),
296 ("tue", 2),
297 ("wed", 3),
298 ("thu", 4),
299 ("fri", 5),
300 ("sat", 6),
301];
302
303fn ctx(field: &str, text: &str, e: anyhow::Error) -> anyhow::Error {
304 anyhow::anyhow!("{field} field `{text}`: {e}")
305}
306
307fn parse_field(text: &str, min: u32, max: u32, names: &[(&str, u32)]) -> anyhow::Result<u64> {
310 anyhow::ensure!(!text.is_empty(), "is empty");
311 let mut mask = 0u64;
312
313 for part in text.split(',') {
314 let part = part.trim();
315 anyhow::ensure!(!part.is_empty(), "has an empty item (a stray comma?)");
316
317 let (range, step) = match part.split_once('/') {
318 Some((r, s)) => {
319 let step: u32 = s
320 .parse()
321 .map_err(|_| anyhow::anyhow!("step `{s}` is not a number"))?;
322 anyhow::ensure!(step > 0, "a step of 0 matches nothing");
323 (r, step)
324 }
325 None => (part, 1),
326 };
327
328 let (lo, hi) = if range == "*" {
329 (min, max)
330 } else if let Some((a, b)) = range.split_once('-') {
331 (value(a, min, max, names)?, value(b, min, max, names)?)
332 } else {
333 let v = value(range, min, max, names)?;
334 if step > 1 {
337 (v, max)
338 } else {
339 (v, v)
340 }
341 };
342 anyhow::ensure!(lo <= hi, "range {lo}-{hi} runs backwards");
343
344 let mut v = lo;
345 while v <= hi {
346 mask |= 1 << v;
347 v += step;
348 }
349 }
350 Ok(mask)
351}
352
353fn value(text: &str, min: u32, max: u32, names: &[(&str, u32)]) -> anyhow::Result<u32> {
354 let text = text.trim();
355 let n = match text.parse::<u32>() {
356 Ok(n) => n,
357 Err(_) => {
358 let lower = text.to_ascii_lowercase();
359 *names
360 .iter()
361 .find(|(name, _)| lower.starts_with(name))
362 .map(|(_, v)| v)
363 .ok_or_else(|| anyhow::anyhow!("`{text}` is not a number or a known name"))?
364 }
365 };
366 anyhow::ensure!(n >= min && n <= max, "{n} is outside {min}-{max}");
367 Ok(n)
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 fn utc(s: &str) -> DateTime<Utc> {
375 DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
376 }
377
378 fn ny() -> Tz {
379 chrono_tz::America::New_York
380 }
381
382 #[test]
383 fn five_fields_are_five_fields() {
384 let s = Schedule::parse("0 7 * * *").unwrap();
387 let next = s.next_after(utc("2026-08-05T00:00:00Z"), ny()).unwrap();
388 assert_eq!(
389 next.with_timezone(&ny()).to_string(),
390 "2026-08-05 07:00:00 EDT"
391 );
392
393 let err = Schedule::parse("0 0 7 * * *").unwrap_err().to_string();
395 assert!(err.contains("five fields"), "{err}");
396 assert!(
397 err.contains("7am"),
398 "the message has to say what the user meant: {err}"
399 );
400 }
401
402 #[test]
403 fn steps_ranges_lists_and_names_all_parse() {
404 let s = Schedule::parse("*/15 9-17 * * mon-fri").unwrap();
405 let start = utc("2026-08-05T12:07:00Z"); let next = s.next_after(start, ny()).unwrap();
407 assert_eq!(
408 next.with_timezone(&ny()).to_string(),
409 "2026-08-05 09:00:00 EDT"
410 );
411
412 let s = Schedule::parse("30 3 1,15 jan,jul *").unwrap();
413 let next = s.next_after(utc("2026-08-05T00:00:00Z"), ny()).unwrap();
414 assert_eq!(
415 next.with_timezone(&ny()).to_string(),
416 "2027-01-01 03:30:00 EST"
417 );
418
419 let s = Schedule::parse("0 10 * * sat,sun").unwrap();
421 let next = s.next_after(utc("2026-08-05T00:00:00Z"), ny()).unwrap();
422 assert_eq!(next.with_timezone(&ny()).weekday(), chrono::Weekday::Sat);
423 }
424
425 #[test]
426 fn aliases_expand_and_reboot_is_refused() {
427 assert_eq!(Schedule::parse("@daily").unwrap().minutes, 1);
428 assert_eq!(Schedule::parse("@hourly").unwrap().hours, u64::MAX >> 40);
429 let err = Schedule::parse("@reboot").unwrap_err().to_string();
430 assert!(err.contains("no meaning"), "{err}");
431 assert!(Schedule::parse("@yesterday").is_err());
432 }
433
434 #[test]
435 fn a_bad_field_says_which_field_and_what_was_wrong() {
436 let err = Schedule::parse("0 25 * * *").unwrap_err().to_string();
437 assert!(err.contains("hour"), "{err}");
438 assert!(err.contains("outside 0-23"), "{err}");
439
440 let err = Schedule::parse("0 7 * * funday").unwrap_err().to_string();
441 assert!(err.contains("day-of-week"), "{err}");
442
443 let err = Schedule::parse("*/0 * * * *").unwrap_err().to_string();
444 assert!(err.contains("step of 0"), "{err}");
445 }
446
447 #[test]
450 fn day_of_month_and_day_of_week_are_a_union_when_both_are_set() {
451 let s = Schedule::parse("0 0 13 * fri").unwrap();
453 let after = utc("2026-08-05T00:00:00Z"); let first = s.next_after(after, ny()).unwrap();
455 assert_eq!(
456 first.with_timezone(&ny()).day(),
457 7,
458 "Friday the 7th comes first"
459 );
460 let second = s.next_after(first, ny()).unwrap();
461 assert_eq!(
462 second.with_timezone(&ny()).day(),
463 13,
464 "then the 13th, itself a Thursday"
465 );
466
467 let s = Schedule::parse("0 0 13 * *").unwrap();
469 let only = s.next_after(after, ny()).unwrap();
470 assert_eq!(only.with_timezone(&ny()).day(), 13);
471 }
472
473 #[test]
474 fn an_impossible_date_terminates_instead_of_searching_forever() {
475 let s = Schedule::parse("0 0 30 2 *").unwrap();
476 assert_eq!(s.next_after(utc("2026-08-05T00:00:00Z"), ny()), None);
477 assert_eq!(s.prev_at_or_before(utc("2026-08-05T00:00:00Z"), ny()), None);
478 }
479
480 #[test]
483 fn a_job_inside_the_spring_forward_gap_still_fires() {
484 let s = Schedule::parse("30 2 * * *").unwrap();
486 let next = s.next_after(utc("2027-03-13T12:00:00Z"), ny()).unwrap();
487 let local = next.with_timezone(&ny());
488 assert_eq!(local.date_naive().to_string(), "2027-03-14");
489 assert_eq!(
490 local.to_string(),
491 "2027-03-14 03:00:00 EDT",
492 "the run is late, not lost — a schedule that silently skips a day twice a \
493 year is a schedule you cannot build on"
494 );
495 }
496
497 #[test]
499 fn a_job_inside_the_repeated_hour_fires_once() {
500 let s = Schedule::parse("30 1 * * *").unwrap();
502 let first = s.next_after(utc("2026-10-31T12:00:00Z"), ny()).unwrap();
503 assert_eq!(
504 first.to_rfc3339(),
505 "2026-11-01T05:30:00+00:00",
506 "the earlier 01:30, EDT"
507 );
508
509 let second = s.next_after(first, ny()).unwrap();
510 assert_eq!(
511 second.with_timezone(&ny()).date_naive().to_string(),
512 "2026-11-02",
513 "the next fire is the following day, not the repeated 01:30 in EST"
514 );
515
516 let during = utc("2026-11-01T06:30:00Z");
519 assert_eq!(s.prev_at_or_before(during, ny()).unwrap(), first);
520 }
521
522 #[test]
524 fn the_most_recent_slot_is_one_slot_however_long_the_gap() {
525 let s = Schedule::parse("0 7 * * *").unwrap();
526 let now = utc("2026-08-05T12:30:00Z"); let prev = s.prev_at_or_before(now, ny()).unwrap();
528 assert_eq!(
529 prev.with_timezone(&ny()).to_string(),
530 "2026-08-05 07:00:00 EDT"
531 );
532
533 let long_ago = utc("2026-07-01T00:00:00Z");
535 assert!(prev > long_ago, "one slot owed, not thirty-five");
536 assert_eq!(s.prev_at_or_before(now, ny()).unwrap(), prev);
537 }
538
539 #[test]
540 fn prev_and_next_agree_on_a_slot_boundary() {
541 let s = Schedule::parse("*/10 * * * *").unwrap();
542 let exactly = utc("2026-08-05T12:30:00Z");
543 assert_eq!(s.prev_at_or_before(exactly, ny()).unwrap(), exactly);
545 assert_eq!(
547 s.next_after(exactly, ny()).unwrap(),
548 utc("2026-08-05T12:40:00Z")
549 );
550 }
551
552 #[test]
553 fn the_timezone_is_the_users_not_the_machines() {
554 let s = Schedule::parse("0 7 * * *").unwrap();
555 let at = utc("2026-08-05T00:00:00Z");
556 let in_ny = s.next_after(at, ny()).unwrap();
557 let in_utc = s.next_after(at, chrono_tz::UTC).unwrap();
558 assert_ne!(in_ny, in_utc, "07:00 is a wall-clock claim, not an instant");
559 assert_eq!(in_utc.to_rfc3339(), "2026-08-05T07:00:00+00:00");
560 assert_eq!(in_ny.to_rfc3339(), "2026-08-05T11:00:00+00:00");
561 }
562
563 #[test]
564 fn a_schedule_round_trips_through_serde_as_what_the_user_typed() {
565 let s = Schedule::parse("*/15 9-17 * * mon-fri").unwrap();
566 let toml = toml::to_string(&serde_json::json!({"schedule": s.clone()})).unwrap();
567 assert!(
568 toml.contains(r#"schedule = "*/15 9-17 * * mon-fri""#),
569 "{toml}"
570 );
571 let back: Schedule = serde_json::from_str(r#""*/15 9-17 * * mon-fri""#).unwrap();
572 assert_eq!(back, s);
573 assert!(serde_json::from_str::<Schedule>(r#""nonsense""#).is_err());
574 }
575}