use jiff::tz::TimeZone;
use jiff::{Timestamp, Zoned};
use croner::Cron;
use croner::parser::{CronParser, Seconds, Year};
#[derive(Debug, Clone)]
pub(crate) struct CronSchedule {
cron: Cron,
zone: TimeZone,
}
impl CronSchedule {
pub(crate) fn parse(expression: &str, timezone: Option<&str>) -> Result<Self, String> {
let zone = match timezone {
None => TimeZone::UTC,
Some(name) => TimeZone::get(name).map_err(|_| format!("invalid timezone: `{name}`"))?,
};
let cron = CronParser::builder()
.seconds(Seconds::Optional)
.year(Year::Disallowed)
.dom_and_dow(true)
.build()
.parse(&normalize(expression))
.map_err(|error| format!("invalid cron schedule `{expression}`: {error}"))?;
let schedule = Self { cron, zone };
if schedule.next_after(Timestamp::now()).is_none() {
return Err(format!("cron schedule `{expression}` never fires"));
}
Ok(schedule)
}
pub(crate) fn next_after(&self, after: Timestamp) -> Option<Zoned> {
self.cron
.find_next_occurrence(&after.to_zoned(self.zone.clone()), false)
.ok()
}
}
fn normalize(expression: &str) -> String {
const FULL_NAMES: [(&str, &str); 19] = [
("MONDAY", "MON"),
("TUESDAY", "TUE"),
("WEDNESDAY", "WED"),
("THURSDAY", "THU"),
("FRIDAY", "FRI"),
("SATURDAY", "SAT"),
("SUNDAY", "SUN"),
("JANUARY", "JAN"),
("FEBRUARY", "FEB"),
("MARCH", "MAR"),
("APRIL", "APR"),
("JUNE", "JUN"),
("JULY", "JUL"),
("AUGUST", "AUG"),
("SEPTEMBER", "SEP"),
("OCTOBER", "OCT"),
("NOVEMBER", "NOV"),
("DECEMBER", "DEC"),
("@MIDNIGHT", "@DAILY"),
];
let mut normalized = expression.trim().to_uppercase();
for (long, short) in FULL_NAMES {
normalized = normalized.replace(long, short);
}
normalized
}
pub(crate) fn firing_id(schedule_name: &str, at: &Zoned) -> String {
format!("sched-{schedule_name}-{}", rfc3339_seconds(at))
}
pub(crate) fn trigger_id(schedule_name: &str, now: Timestamp) -> String {
format!("sched-{schedule_name}-trigger-{now}")
}
fn rfc3339_seconds(at: &Zoned) -> String {
let formatted = at.strftime("%Y-%m-%dT%H:%M:%S%:z").to_string();
match formatted.strip_suffix("+00:00") {
Some(local) => format!("{local}Z"),
None => formatted,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn at(rfc3339: &str) -> Timestamp {
rfc3339.parse().unwrap()
}
fn firings(expression: &str, timezone: Option<&str>, start: &str, count: usize) -> Vec<String> {
let schedule = CronSchedule::parse(expression, timezone).unwrap();
let mut cursor = at(start);
let mut out = Vec::new();
for _ in 0..count {
let next = schedule.next_after(cursor).unwrap();
cursor = next.timestamp();
out.push(cursor.to_string());
}
out
}
fn refused(expression: &str) -> String {
CronSchedule::parse(expression, None).unwrap_err()
}
#[test]
fn six_fields_put_seconds_first() {
assert_eq!(
firings("*/20 * * * * *", None, "2025-01-01T00:00:00Z", 3),
[
"2025-01-01T00:00:20Z",
"2025-01-01T00:00:40Z",
"2025-01-01T00:01:00Z"
]
);
}
#[test]
fn five_fields_fire_at_second_zero() {
assert_eq!(
firings("*/5 * * * *", None, "2025-01-01T00:00:30Z", 2),
["2025-01-01T00:05:00Z", "2025-01-01T00:10:00Z"]
);
}
#[test]
fn the_next_firing_is_strictly_after_and_on_a_whole_second() {
assert_eq!(
firings("* * * * * *", None, "2025-01-01T00:00:00.5Z", 1),
["2025-01-01T00:00:01Z"]
);
assert_eq!(
firings("* * * * * *", None, "2025-01-01T00:00:01Z", 1),
["2025-01-01T00:00:02Z"]
);
}
#[test]
fn every_nickname_is_its_pattern() {
for (nickname, pattern) in [
("@yearly", "0 0 1 1 *"),
("@annually", "0 0 1 1 *"),
("@monthly", "0 0 1 * *"),
("@weekly", "0 0 * * 0"),
("@daily", "0 0 * * *"),
("@midnight", "0 0 * * *"),
("@hourly", "0 * * * *"),
("@Daily", "0 0 * * *"),
] {
assert_eq!(
firings(nickname, None, "2025-03-15T12:34:56Z", 3),
firings(pattern, None, "2025-03-15T12:34:56Z", 3),
"{nickname}"
);
}
}
#[test]
fn a_question_mark_is_a_wildcard_in_either_day_field() {
assert_eq!(
firings("0 0 ? * 1", None, "2025-01-01T00:00:00Z", 2),
firings("0 0 * * 1", None, "2025-01-01T00:00:00Z", 2),
);
assert_eq!(
firings("0 0 1 * ?", None, "2025-01-01T00:00:00Z", 2),
firings("0 0 1 * *", None, "2025-01-01T00:00:00Z", 2),
);
}
#[test]
fn names_are_accepted_short_or_full_in_any_case() {
let start = "2025-01-01T00:00:00Z";
let mondays = firings("0 0 * * 1", None, start, 3);
for spelling in ["mon", "MON", "Monday", "MONDAY", "monday"] {
assert_eq!(
firings(&format!("0 0 * * {spelling}"), None, start, 3),
mondays,
"{spelling}"
);
}
let septembers = firings("0 0 1 9 *", None, start, 2);
for spelling in ["sep", "September", "SEPTEMBER"] {
assert_eq!(
firings(&format!("0 0 1 {spelling} *"), None, start, 2),
septembers,
"{spelling}"
);
}
assert_eq!(
firings("0 0 * * Monday-Wednesday", None, "2025-01-05T00:00:00Z", 4),
[
"2025-01-06T00:00:00Z",
"2025-01-07T00:00:00Z",
"2025-01-08T00:00:00Z",
"2025-01-13T00:00:00Z"
]
);
}
#[test]
fn steps_count_from_the_start_of_their_range() {
assert_eq!(
firings("1-10/4 * * * * *", None, "2025-01-01T00:00:00Z", 4),
[
"2025-01-01T00:00:01Z",
"2025-01-01T00:00:05Z",
"2025-01-01T00:00:09Z",
"2025-01-01T00:01:01Z"
]
);
assert_eq!(
firings("0 0 */10 * *", None, "2025-01-01T00:00:00Z", 4),
[
"2025-01-11T00:00:00Z",
"2025-01-21T00:00:00Z",
"2025-01-31T00:00:00Z",
"2025-02-01T00:00:00Z"
]
);
}
#[test]
fn seven_is_sunday_too() {
assert_eq!(
firings("0 0 * * 7", None, "2025-01-01T00:00:00Z", 2),
firings("0 0 * * 0", None, "2025-01-01T00:00:00Z", 2),
);
assert_eq!(
firings("0 0 * * 5-7", None, "2025-01-01T00:00:00Z", 4),
[
"2025-01-03T00:00:00Z",
"2025-01-04T00:00:00Z",
"2025-01-05T00:00:00Z",
"2025-01-10T00:00:00Z"
]
);
}
#[test]
fn last_and_nearest_weekday_forms() {
assert_eq!(
firings("0 0 L * *", None, "2024-02-01T00:00:00Z", 1),
["2024-02-29T00:00:00Z"]
);
assert_eq!(
firings("0 0 L * *", None, "2025-02-01T00:00:00Z", 1),
["2025-02-28T00:00:00Z"]
);
assert_eq!(
firings("0 0 15W * *", None, "2025-11-01T00:00:00Z", 1),
["2025-11-14T00:00:00Z"]
);
assert_eq!(
firings("0 0 LW * *", None, "2025-05-01T00:00:00Z", 1),
["2025-05-30T00:00:00Z"]
);
assert_eq!(
firings("0 0 * * 5L", None, "2025-10-01T00:00:00Z", 1),
["2025-10-31T00:00:00Z"]
);
assert_eq!(
firings("0 0 * * 2#3", None, "2025-10-01T00:00:00Z", 1),
["2025-10-21T00:00:00Z"]
);
}
#[test]
fn both_day_fields_must_match_when_both_are_restricted() {
assert_eq!(
firings("0 0 13 * 5", None, "2025-01-01T00:00:00Z", 2),
["2025-06-13T00:00:00Z", "2026-02-13T00:00:00Z"]
);
}
#[test]
fn a_distant_firing_is_found() {
assert_eq!(
firings("0 0 29 2 *", None, "2025-01-01T00:00:00Z", 1),
["2028-02-29T00:00:00Z"]
);
assert_eq!(
firings(
"10 19 28-31 * *",
Some("America/New_York"),
"2026-09-01T00:00:00Z",
1
),
["2026-09-28T23:10:00Z"]
);
}
#[test]
fn a_timezone_moves_the_wall_clock_the_pattern_is_read_on() {
assert_eq!(
firings(
"0 0 * * *",
Some("America/New_York"),
"2025-01-01T00:00:00Z",
2
),
["2025-01-01T05:00:00Z", "2025-01-02T05:00:00Z"]
);
assert_eq!(
firings(
"0 0 0 * * *",
Some("America/Sao_Paulo"),
"2018-10-10T12:00:00Z",
1
),
["2018-10-11T03:00:00Z"]
);
assert_eq!(
firings(
"0 0 0 * * *",
Some("Europe/Rome"),
"2018-10-10T12:00:00Z",
1
),
["2018-10-10T22:00:00Z"]
);
}
#[test]
fn daylight_saving_follows_classic_cron() {
let ny = Some("America/New_York");
assert_eq!(
firings("30 2 * * *", ny, "2025-03-09T04:00:00Z", 2),
["2025-03-09T07:00:00Z", "2025-03-10T06:30:00Z"]
);
assert_eq!(
firings("30 1 * * *", ny, "2025-11-02T04:00:00Z", 2),
["2025-11-02T05:30:00Z", "2025-11-03T06:30:00Z"]
);
assert_eq!(
firings("0 */30 * * * *", ny, "2025-11-02T04:45:00Z", 4),
[
"2025-11-02T05:00:00Z",
"2025-11-02T05:30:00Z",
"2025-11-02T06:00:00Z",
"2025-11-02T06:30:00Z"
]
);
}
#[test]
fn malformed_patterns_are_refused() {
for pattern in [
"",
"* * * *",
"* * * * * * *",
"* * 32 * *",
"* 25 * * *",
"60 * * * *",
"63 * * * * *",
"* * * 13 *",
"* * * foo *",
"* * * * 9",
"* * * * foo",
"*/0 * * * *",
"*/someString * * * *",
"5/10 * * * *",
"1,2,3/2 * * * *",
"0 0 1-15W * *",
"R * * * *",
] {
let error = refused(pattern);
assert!(
error.starts_with("invalid cron schedule"),
"{pattern}: {error}"
);
}
}
#[test]
fn l_minus_n_and_wrapping_ranges_are_refused_rather_than_misread() {
for pattern in ["0 0 L-1 * *", "0 22-2 * * *", "0 0 * * Fri-Mon"] {
assert!(
refused(pattern).starts_with("invalid cron schedule"),
"{pattern}"
);
}
}
#[test]
fn a_pattern_that_never_fires_is_refused() {
for pattern in ["0 0 31 2 *", "0 0 30 2 *"] {
assert!(refused(pattern).ends_with("never fires"), "{pattern}");
}
}
#[test]
fn an_unknown_timezone_is_refused() {
assert_eq!(
CronSchedule::parse("* * * * *", Some("Mars/Olympus_Mons")).unwrap_err(),
"invalid timezone: `Mars/Olympus_Mons`"
);
}
#[test]
fn a_firing_id_is_rfc3339_to_the_second_on_the_schedules_wall_clock() {
let utc = at("2026-10-05T12:00:00Z").to_zoned(TimeZone::UTC);
assert_eq!(
firing_id("nightly", &utc),
"sched-nightly-2026-10-05T12:00:00Z"
);
let ny = at("2026-10-05T12:00:00Z").to_zoned(TimeZone::get("America/New_York").unwrap());
assert_eq!(
firing_id("nightly", &ny),
"sched-nightly-2026-10-05T08:00:00-04:00"
);
let london = at("2026-01-05T12:00:00Z").to_zoned(TimeZone::get("Europe/London").unwrap());
assert_eq!(
firing_id("nightly", &london),
"sched-nightly-2026-01-05T12:00:00Z"
);
}
#[test]
fn a_trigger_id_is_utc_with_the_fraction_trimmed() {
assert_eq!(
trigger_id("nightly", at("2026-10-05T12:00:00.250Z")),
"sched-nightly-trigger-2026-10-05T12:00:00.25Z"
);
assert_eq!(
trigger_id("nightly", at("2026-10-05T12:00:00Z")),
"sched-nightly-trigger-2026-10-05T12:00:00Z"
);
}
}