use chrono::{DateTime, Datelike, Duration, LocalResult, NaiveDate, TimeZone, Timelike, Utc};
use chrono_tz::Tz;
use serde::{Deserialize, Serialize};
const HORIZON_DAYS: i64 = 366 * 4;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct Schedule {
source: String,
minutes: u64,
hours: u64,
days: u64,
months: u64,
weekdays: u64,
dom_restricted: bool,
dow_restricted: bool,
}
impl std::fmt::Display for Schedule {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.source)
}
}
impl From<Schedule> for String {
fn from(s: Schedule) -> String {
s.source
}
}
impl TryFrom<String> for Schedule {
type Error = anyhow::Error;
fn try_from(s: String) -> anyhow::Result<Self> {
Schedule::parse(&s)
}
}
impl std::str::FromStr for Schedule {
type Err = anyhow::Error;
fn from_str(s: &str) -> anyhow::Result<Self> {
Schedule::parse(s)
}
}
impl Schedule {
pub fn parse(expr: &str) -> anyhow::Result<Self> {
let expr = expr.trim();
let expanded = match expr.to_ascii_lowercase().as_str() {
"@yearly" | "@annually" => "0 0 1 1 *",
"@monthly" => "0 0 1 * *",
"@weekly" => "0 0 * * 0",
"@daily" | "@midnight" => "0 0 * * *",
"@hourly" => "0 * * * *",
"@reboot" => anyhow::bail!(
"`@reboot` has no meaning for a mecha trigger — there is no boot to hang \
it on. Use an explicit schedule."
),
other if other.starts_with('@') => {
anyhow::bail!(
"unknown schedule alias `{expr}` (known: @hourly, @daily, @midnight, \
@weekly, @monthly, @yearly)"
)
}
_ => expr,
};
let fields: Vec<&str> = expanded.split_whitespace().collect();
anyhow::ensure!(
fields.len() == 5,
"a cron schedule has five fields — minute hour day-of-month month day-of-week \
— but `{expr}` has {}. (Seconds are not a field here: `0 7 * * *` is 7am.)",
fields.len()
);
let minutes =
parse_field(fields[0], 0, 59, &[]).map_err(|e| ctx("minute", fields[0], e))?;
let hours = parse_field(fields[1], 0, 23, &[]).map_err(|e| ctx("hour", fields[1], e))?;
let days =
parse_field(fields[2], 1, 31, &[]).map_err(|e| ctx("day-of-month", fields[2], e))?;
let months =
parse_field(fields[3], 1, 12, MONTHS).map_err(|e| ctx("month", fields[3], e))?;
let weekdays =
parse_field(fields[4], 0, 7, WEEKDAYS).map_err(|e| ctx("day-of-week", fields[4], e))?;
let weekdays = if weekdays & (1 << 7) != 0 {
(weekdays | 1) & !(1 << 7)
} else {
weekdays
};
Ok(Schedule {
source: expr.to_string(),
minutes,
hours,
days,
months,
weekdays,
dom_restricted: fields[2] != "*",
dow_restricted: fields[4] != "*",
})
}
pub fn source(&self) -> &str {
&self.source
}
fn matches_day(&self, date: NaiveDate) -> bool {
if self.months & (1 << date.month()) == 0 {
return false;
}
let dom = self.days & (1 << date.day()) != 0;
let dow = self.weekdays & (1 << date.weekday().num_days_from_sunday()) != 0;
match (self.dom_restricted, self.dow_restricted) {
(true, true) => dom || dow,
(true, false) => dom,
(false, true) => dow,
(false, false) => true,
}
}
pub fn next_after(&self, after: DateTime<Utc>, tz: Tz) -> Option<DateTime<Utc>> {
let local = after.with_timezone(&tz);
let mut date = local.date_naive();
let mut from_minute = local.hour() * 60 + local.minute() + 1;
for _ in 0..HORIZON_DAYS {
if self.matches_day(date) {
for minute in from_minute..24 * 60 {
if !self.matches_minute(minute) {
continue;
}
if let Some(utc) = self.resolve(date, minute, tz) {
if utc > after {
return Some(utc);
}
}
}
}
date = date.succ_opt()?;
from_minute = 0;
}
None
}
pub fn prev_at_or_before(&self, at: DateTime<Utc>, tz: Tz) -> Option<DateTime<Utc>> {
let local = at.with_timezone(&tz);
let mut date = local.date_naive();
let mut to_minute = local.hour() * 60 + local.minute();
for _ in 0..HORIZON_DAYS {
if self.matches_day(date) {
for minute in (0..=to_minute).rev() {
if !self.matches_minute(minute) {
continue;
}
if let Some(utc) = self.resolve(date, minute, tz) {
if utc <= at {
return Some(utc);
}
}
}
}
date = date.pred_opt()?;
to_minute = 24 * 60 - 1;
}
None
}
fn matches_minute(&self, minute_of_day: u32) -> bool {
self.hours & (1 << (minute_of_day / 60)) != 0
&& self.minutes & (1 << (minute_of_day % 60)) != 0
}
fn resolve(&self, date: NaiveDate, minute_of_day: u32, tz: Tz) -> Option<DateTime<Utc>> {
let naive = date.and_hms_opt(minute_of_day / 60, minute_of_day % 60, 0)?;
match tz.from_local_datetime(&naive) {
LocalResult::Single(dt) => Some(dt.with_timezone(&Utc)),
LocalResult::Ambiguous(earlier, _) => Some(earlier.with_timezone(&Utc)),
LocalResult::None => {
let mut probe = naive;
for _ in 0..180 {
probe += Duration::minutes(1);
match tz.from_local_datetime(&probe) {
LocalResult::Single(dt) => return Some(dt.with_timezone(&Utc)),
LocalResult::Ambiguous(earlier, _) => {
return Some(earlier.with_timezone(&Utc))
}
LocalResult::None => continue,
}
}
None
}
}
}
}
const MONTHS: &[(&str, u32)] = &[
("jan", 1),
("feb", 2),
("mar", 3),
("apr", 4),
("may", 5),
("jun", 6),
("jul", 7),
("aug", 8),
("sep", 9),
("oct", 10),
("nov", 11),
("dec", 12),
];
const WEEKDAYS: &[(&str, u32)] = &[
("sun", 0),
("mon", 1),
("tue", 2),
("wed", 3),
("thu", 4),
("fri", 5),
("sat", 6),
];
fn ctx(field: &str, text: &str, e: anyhow::Error) -> anyhow::Error {
anyhow::anyhow!("{field} field `{text}`: {e}")
}
fn parse_field(text: &str, min: u32, max: u32, names: &[(&str, u32)]) -> anyhow::Result<u64> {
anyhow::ensure!(!text.is_empty(), "is empty");
let mut mask = 0u64;
for part in text.split(',') {
let part = part.trim();
anyhow::ensure!(!part.is_empty(), "has an empty item (a stray comma?)");
let (range, step) = match part.split_once('/') {
Some((r, s)) => {
let step: u32 = s
.parse()
.map_err(|_| anyhow::anyhow!("step `{s}` is not a number"))?;
anyhow::ensure!(step > 0, "a step of 0 matches nothing");
(r, step)
}
None => (part, 1),
};
let (lo, hi) = if range == "*" {
(min, max)
} else if let Some((a, b)) = range.split_once('-') {
(value(a, min, max, names)?, value(b, min, max, names)?)
} else {
let v = value(range, min, max, names)?;
if step > 1 {
(v, max)
} else {
(v, v)
}
};
anyhow::ensure!(lo <= hi, "range {lo}-{hi} runs backwards");
let mut v = lo;
while v <= hi {
mask |= 1 << v;
v += step;
}
}
Ok(mask)
}
fn value(text: &str, min: u32, max: u32, names: &[(&str, u32)]) -> anyhow::Result<u32> {
let text = text.trim();
let n = match text.parse::<u32>() {
Ok(n) => n,
Err(_) => {
let lower = text.to_ascii_lowercase();
*names
.iter()
.find(|(name, _)| lower.starts_with(name))
.map(|(_, v)| v)
.ok_or_else(|| anyhow::anyhow!("`{text}` is not a number or a known name"))?
}
};
anyhow::ensure!(n >= min && n <= max, "{n} is outside {min}-{max}");
Ok(n)
}
#[cfg(test)]
mod tests {
use super::*;
fn utc(s: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
}
fn ny() -> Tz {
chrono_tz::America::New_York
}
#[test]
fn five_fields_are_five_fields() {
let s = Schedule::parse("0 7 * * *").unwrap();
let next = s.next_after(utc("2026-08-05T00:00:00Z"), ny()).unwrap();
assert_eq!(
next.with_timezone(&ny()).to_string(),
"2026-08-05 07:00:00 EDT"
);
let err = Schedule::parse("0 0 7 * * *").unwrap_err().to_string();
assert!(err.contains("five fields"), "{err}");
assert!(
err.contains("7am"),
"the message has to say what the user meant: {err}"
);
}
#[test]
fn steps_ranges_lists_and_names_all_parse() {
let s = Schedule::parse("*/15 9-17 * * mon-fri").unwrap();
let start = utc("2026-08-05T12:07:00Z"); let next = s.next_after(start, ny()).unwrap();
assert_eq!(
next.with_timezone(&ny()).to_string(),
"2026-08-05 09:00:00 EDT"
);
let s = Schedule::parse("30 3 1,15 jan,jul *").unwrap();
let next = s.next_after(utc("2026-08-05T00:00:00Z"), ny()).unwrap();
assert_eq!(
next.with_timezone(&ny()).to_string(),
"2027-01-01 03:30:00 EST"
);
let s = Schedule::parse("0 10 * * sat,sun").unwrap();
let next = s.next_after(utc("2026-08-05T00:00:00Z"), ny()).unwrap();
assert_eq!(next.with_timezone(&ny()).weekday(), chrono::Weekday::Sat);
}
#[test]
fn aliases_expand_and_reboot_is_refused() {
assert_eq!(Schedule::parse("@daily").unwrap().minutes, 1);
assert_eq!(Schedule::parse("@hourly").unwrap().hours, u64::MAX >> 40);
let err = Schedule::parse("@reboot").unwrap_err().to_string();
assert!(err.contains("no meaning"), "{err}");
assert!(Schedule::parse("@yesterday").is_err());
}
#[test]
fn a_bad_field_says_which_field_and_what_was_wrong() {
let err = Schedule::parse("0 25 * * *").unwrap_err().to_string();
assert!(err.contains("hour"), "{err}");
assert!(err.contains("outside 0-23"), "{err}");
let err = Schedule::parse("0 7 * * funday").unwrap_err().to_string();
assert!(err.contains("day-of-week"), "{err}");
let err = Schedule::parse("*/0 * * * *").unwrap_err().to_string();
assert!(err.contains("step of 0"), "{err}");
}
#[test]
fn day_of_month_and_day_of_week_are_a_union_when_both_are_set() {
let s = Schedule::parse("0 0 13 * fri").unwrap();
let after = utc("2026-08-05T00:00:00Z"); let first = s.next_after(after, ny()).unwrap();
assert_eq!(
first.with_timezone(&ny()).day(),
7,
"Friday the 7th comes first"
);
let second = s.next_after(first, ny()).unwrap();
assert_eq!(
second.with_timezone(&ny()).day(),
13,
"then the 13th, itself a Thursday"
);
let s = Schedule::parse("0 0 13 * *").unwrap();
let only = s.next_after(after, ny()).unwrap();
assert_eq!(only.with_timezone(&ny()).day(), 13);
}
#[test]
fn an_impossible_date_terminates_instead_of_searching_forever() {
let s = Schedule::parse("0 0 30 2 *").unwrap();
assert_eq!(s.next_after(utc("2026-08-05T00:00:00Z"), ny()), None);
assert_eq!(s.prev_at_or_before(utc("2026-08-05T00:00:00Z"), ny()), None);
}
#[test]
fn a_job_inside_the_spring_forward_gap_still_fires() {
let s = Schedule::parse("30 2 * * *").unwrap();
let next = s.next_after(utc("2027-03-13T12:00:00Z"), ny()).unwrap();
let local = next.with_timezone(&ny());
assert_eq!(local.date_naive().to_string(), "2027-03-14");
assert_eq!(
local.to_string(),
"2027-03-14 03:00:00 EDT",
"the run is late, not lost — a schedule that silently skips a day twice a \
year is a schedule you cannot build on"
);
}
#[test]
fn a_job_inside_the_repeated_hour_fires_once() {
let s = Schedule::parse("30 1 * * *").unwrap();
let first = s.next_after(utc("2026-10-31T12:00:00Z"), ny()).unwrap();
assert_eq!(
first.to_rfc3339(),
"2026-11-01T05:30:00+00:00",
"the earlier 01:30, EDT"
);
let second = s.next_after(first, ny()).unwrap();
assert_eq!(
second.with_timezone(&ny()).date_naive().to_string(),
"2026-11-02",
"the next fire is the following day, not the repeated 01:30 in EST"
);
let during = utc("2026-11-01T06:30:00Z");
assert_eq!(s.prev_at_or_before(during, ny()).unwrap(), first);
}
#[test]
fn the_most_recent_slot_is_one_slot_however_long_the_gap() {
let s = Schedule::parse("0 7 * * *").unwrap();
let now = utc("2026-08-05T12:30:00Z"); let prev = s.prev_at_or_before(now, ny()).unwrap();
assert_eq!(
prev.with_timezone(&ny()).to_string(),
"2026-08-05 07:00:00 EDT"
);
let long_ago = utc("2026-07-01T00:00:00Z");
assert!(prev > long_ago, "one slot owed, not thirty-five");
assert_eq!(s.prev_at_or_before(now, ny()).unwrap(), prev);
}
#[test]
fn prev_and_next_agree_on_a_slot_boundary() {
let s = Schedule::parse("*/10 * * * *").unwrap();
let exactly = utc("2026-08-05T12:30:00Z");
assert_eq!(s.prev_at_or_before(exactly, ny()).unwrap(), exactly);
assert_eq!(
s.next_after(exactly, ny()).unwrap(),
utc("2026-08-05T12:40:00Z")
);
}
#[test]
fn the_timezone_is_the_users_not_the_machines() {
let s = Schedule::parse("0 7 * * *").unwrap();
let at = utc("2026-08-05T00:00:00Z");
let in_ny = s.next_after(at, ny()).unwrap();
let in_utc = s.next_after(at, chrono_tz::UTC).unwrap();
assert_ne!(in_ny, in_utc, "07:00 is a wall-clock claim, not an instant");
assert_eq!(in_utc.to_rfc3339(), "2026-08-05T07:00:00+00:00");
assert_eq!(in_ny.to_rfc3339(), "2026-08-05T11:00:00+00:00");
}
#[test]
fn a_schedule_round_trips_through_serde_as_what_the_user_typed() {
let s = Schedule::parse("*/15 9-17 * * mon-fri").unwrap();
let toml = toml::to_string(&serde_json::json!({"schedule": s.clone()})).unwrap();
assert!(
toml.contains(r#"schedule = "*/15 9-17 * * mon-fri""#),
"{toml}"
);
let back: Schedule = serde_json::from_str(r#""*/15 9-17 * * mon-fri""#).unwrap();
assert_eq!(back, s);
assert!(serde_json::from_str::<Schedule>(r#""nonsense""#).is_err());
}
}