use core::fmt;
use chrono::{DateTime, Utc};
use chrono_tz::Tz;
use croner::Cron;
use croner::errors::CronError;
use croner::parser::{CronParser, Seconds};
const NICKNAMES: [(&str, &str); 7] = [
("@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 * * * *"),
];
const NAMES: [&str; 19] = [
"JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC", "SUN",
"MON", "TUE", "WED", "THU", "FRI", "SAT",
];
#[derive(Debug, Clone)]
pub struct CronSchedule {
pattern: String,
zone: Tz,
cron: Cron,
}
impl CronSchedule {
pub fn parse(pattern: &str, timezone: Option<&str>) -> Result<Self, CronParseError> {
let zone = match timezone {
Some(name) => parse_timezone_name(name).ok_or_else(|| CronParseError::Timezone {
name: name.to_string(),
})?,
None => Tz::UTC,
};
let trimmed = pattern.trim();
let candidate = if is_single_at_token(trimmed) {
expand_nickname(trimmed, pattern)?
} else {
trimmed.to_string()
};
reject_extension_characters(&candidate, pattern)?;
let cron = cron_parser()
.parse(&candidate)
.map_err(|e| CronParseError::Pattern {
pattern: pattern.to_string(),
reason: e.to_string(),
})?;
Ok(Self {
pattern: pattern.to_string(),
zone,
cron,
})
}
pub fn next_after(
&self,
after: DateTime<Utc>,
) -> Result<Option<DateTime<Utc>>, CronScheduleError> {
let start = after.with_timezone(&self.zone);
match self.cron.find_next_occurrence(&start, false) {
Ok(dt) => Ok(Some(dt.with_timezone(&Utc))),
Err(CronError::TimeSearchLimitExceeded) => Ok(None),
Err(e) => Err(CronScheduleError::Search {
reason: e.to_string(),
}),
}
}
#[must_use]
pub fn pattern(&self) -> &str {
&self.pattern
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CronParseError {
Pattern {
pattern: String,
reason: String,
},
Timezone {
name: String,
},
}
impl fmt::Display for CronParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Pattern { pattern, reason } => {
write!(f, "invalid cron_restart pattern `{pattern}`: {reason}")
}
Self::Timezone { name } => write!(f, "`{name}` is not a recognized IANA timezone"),
}
}
}
impl core::error::Error for CronParseError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CronScheduleError {
Search {
reason: String,
},
}
impl fmt::Display for CronScheduleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Search { reason } => write!(f, "cron schedule search failed: {reason}"),
}
}
}
impl core::error::Error for CronScheduleError {}
fn cron_parser() -> CronParser {
CronParser::builder().seconds(Seconds::Disallowed).build()
}
pub(super) fn parse_timezone_name(name: &str) -> Option<Tz> {
name.parse::<Tz>().ok()
}
fn is_single_at_token(trimmed: &str) -> bool {
trimmed.starts_with('@') && trimmed.split_whitespace().count() == 1
}
fn expand_nickname(trimmed: &str, original: &str) -> Result<String, CronParseError> {
if trimmed.eq_ignore_ascii_case("@reboot") {
return Err(CronParseError::Pattern {
pattern: original.to_string(),
reason: "shep's own restart policy already decides when a sheep starts".to_string(),
});
}
for (name, expansion) in NICKNAMES {
if trimmed.eq_ignore_ascii_case(name) {
return Ok(expansion.to_string());
}
}
Err(CronParseError::Pattern {
pattern: original.to_string(),
reason: format!(
"`{trimmed}` is not a recognized cron_restart nickname (expected one of @yearly, \
@annually, @monthly, @weekly, @daily, @midnight, @hourly)"
),
})
}
fn reject_extension_characters(candidate: &str, original: &str) -> Result<(), CronParseError> {
for field in candidate.split_whitespace() {
if let Some(bad) = field_has_bad_char(field) {
return Err(CronParseError::Pattern {
pattern: original.to_string(),
reason: format!(
"cron_restart pattern contains `{bad}`, a croner extension character \
shep's five-field dialect does not accept"
),
});
}
}
Ok(())
}
fn field_has_bad_char(field: &str) -> Option<char> {
let chars: Vec<char> = field.chars().collect();
let mut i = 0;
while i < chars.len() {
if i + 3 <= chars.len() {
let window: String = chars[i..i + 3].iter().collect();
if NAMES.iter().any(|name| name.eq_ignore_ascii_case(&window)) {
i += 3;
continue;
}
}
if matches!(chars[i].to_ascii_uppercase(), 'L' | 'W' | '#' | '?') {
return Some(chars[i]);
}
i += 1;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn dt(s: &str) -> DateTime<Utc> {
s.parse().expect("valid RFC3339 timestamp")
}
fn occurrence_sequence(
schedule: &CronSchedule,
start: DateTime<Utc>,
n: usize,
) -> Vec<DateTime<Utc>> {
let mut cursor = start;
let mut out = Vec::with_capacity(n);
for _ in 0..n {
let next = schedule
.next_after(cursor)
.expect("search succeeds")
.expect("has a next occurrence");
out.push(next);
cursor = next;
}
out
}
fn assert_extension_char_rejected(pattern: &str, bad: char) {
match CronSchedule::parse(pattern, None) {
Err(CronParseError::Pattern {
pattern: got_pattern,
reason,
}) => {
assert_eq!(got_pattern, pattern);
assert_eq!(
reason,
format!(
"cron_restart pattern contains `{bad}`, a croner extension character \
shep's five-field dialect does not accept"
)
);
}
other => panic!("expected Pattern error, got {other:?}"),
}
}
#[test]
fn five_field_pattern_produces_pinned_occurrence_sequence() {
let schedule = CronSchedule::parse("0 3 * * *", None).unwrap();
let seq = occurrence_sequence(&schedule, dt("2026-01-01T00:00:00Z"), 3);
assert_eq!(
seq,
vec![
dt("2026-01-01T03:00:00Z"),
dt("2026-01-02T03:00:00Z"),
dt("2026-01-03T03:00:00Z"),
]
);
}
#[test]
fn six_field_pattern_is_rejected() {
match CronSchedule::parse("30 0 3 * * *", None) {
Err(CronParseError::Pattern { pattern, .. }) => assert_eq!(pattern, "30 0 3 * * *"),
other => panic!("expected Pattern error, got {other:?}"),
}
}
#[test]
fn year_field_pattern_is_rejected() {
match CronSchedule::parse("0 3 * * * 2027", None) {
Err(CronParseError::Pattern { pattern, .. }) => assert_eq!(pattern, "0 3 * * * 2027"),
other => panic!("expected Pattern error, got {other:?}"),
}
}
#[test]
fn nicknames_expand_to_the_same_occurrence_sequence_as_their_five_field_form() {
let anchor = dt("2026-01-01T00:00:00Z");
let expected_expansions: [(&str, &str); 7] = [
("@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 * * * *"),
];
for (nickname, five_field) in expected_expansions {
let via_nickname = CronSchedule::parse(nickname, None).unwrap();
let via_five_field = CronSchedule::parse(five_field, None).unwrap();
assert_eq!(
occurrence_sequence(&via_nickname, anchor, 3),
occurrence_sequence(&via_five_field, anchor, 3),
"{nickname} vs {five_field}"
);
}
}
#[test]
fn nickname_matching_is_ascii_case_insensitive() {
let anchor = dt("2026-01-01T00:00:00Z");
let upper = CronSchedule::parse("@DAILY", None).unwrap();
let lower = CronSchedule::parse("@daily", None).unwrap();
assert_eq!(
occurrence_sequence(&upper, anchor, 3),
occurrence_sequence(&lower, anchor, 3)
);
}
#[test]
fn nickname_pattern_keeps_its_own_spelling() {
let schedule = CronSchedule::parse("@daily", None).unwrap();
assert_eq!(schedule.pattern(), "@daily");
}
#[test]
fn reboot_nickname_is_rejected_with_its_own_message() {
match CronSchedule::parse("@reboot", None) {
Err(CronParseError::Pattern { pattern, reason }) => {
assert_eq!(pattern, "@reboot");
assert_eq!(
reason,
"shep's own restart policy already decides when a sheep starts"
);
}
other => panic!("expected Pattern error, got {other:?}"),
}
}
#[test]
fn unrecognized_nickname_is_rejected_without_reaching_croner() {
match CronSchedule::parse("@fortnightly", None) {
Err(CronParseError::Pattern { pattern, reason }) => {
assert_eq!(pattern, "@fortnightly");
assert_eq!(
reason,
"`@fortnightly` is not a recognized cron_restart nickname (expected one of \
@yearly, @annually, @monthly, @weekly, @daily, @midnight, @hourly)"
);
}
other => panic!("expected Pattern error, got {other:?}"),
}
}
#[test]
fn zone_offset_is_applied_before_searching() {
let schedule = CronSchedule::parse("0 3 * * *", Some("Europe/Oslo")).unwrap();
let seq = occurrence_sequence(&schedule, dt("2026-01-05T00:00:00Z"), 3);
assert_eq!(
seq,
vec![
dt("2026-01-05T02:00:00Z"),
dt("2026-01-06T02:00:00Z"),
dt("2026-01-07T02:00:00Z"),
]
);
}
#[test]
fn zone_offset_can_move_the_occurrence_to_a_different_utc_date() {
let schedule = CronSchedule::parse("30 23 * * *", Some("Pacific/Auckland")).unwrap();
let seq = occurrence_sequence(&schedule, dt("2026-07-05T00:00:00Z"), 3);
assert_eq!(
seq,
vec![
dt("2026-07-05T11:30:00Z"),
dt("2026-07-06T11:30:00Z"),
dt("2026-07-07T11:30:00Z"),
]
);
}
#[test]
fn spring_forward_gap_lands_on_the_first_valid_instant() {
let schedule = CronSchedule::parse("30 2 * * *", Some("America/New_York")).unwrap();
let seq = occurrence_sequence(&schedule, dt("2026-03-06T12:00:00Z"), 4);
assert_eq!(
seq,
vec![
dt("2026-03-07T07:30:00Z"),
dt("2026-03-08T07:00:00Z"), dt("2026-03-09T06:30:00Z"),
dt("2026-03-10T06:30:00Z"),
]
);
}
#[test]
fn spring_forward_wildcard_skips_nonexistent_slots() {
let schedule = CronSchedule::parse("*/15 * * * *", Some("America/New_York")).unwrap();
let seq = occurrence_sequence(&schedule, dt("2026-03-08T06:40:00Z"), 10);
assert_eq!(
seq,
vec![
dt("2026-03-08T06:45:00Z"),
dt("2026-03-08T07:00:00Z"), dt("2026-03-08T07:15:00Z"),
dt("2026-03-08T07:30:00Z"),
dt("2026-03-08T07:45:00Z"),
dt("2026-03-08T08:00:00Z"),
dt("2026-03-08T08:15:00Z"),
dt("2026-03-08T08:30:00Z"),
dt("2026-03-08T08:45:00Z"),
dt("2026-03-08T09:00:00Z"),
]
);
}
#[test]
fn fall_back_repeated_hour_fires_once() {
let schedule = CronSchedule::parse("30 1 * * *", Some("America/New_York")).unwrap();
let seq = occurrence_sequence(&schedule, dt("2026-10-30T12:00:00Z"), 4);
assert_eq!(
seq,
vec![
dt("2026-10-31T05:30:00Z"),
dt("2026-11-01T05:30:00Z"), dt("2026-11-02T06:30:00Z"),
dt("2026-11-03T06:30:00Z"),
]
);
}
#[test]
fn pattern_that_never_matches_returns_none() {
let schedule = CronSchedule::parse("0 0 30 2 *", None).unwrap();
assert_eq!(schedule.next_after(dt("2026-01-01T00:00:00Z")), Ok(None));
}
#[test]
fn search_failure_other_than_exhaustion_surfaces_as_err() {
let schedule = CronSchedule::parse("0 3 * * *", None).unwrap();
match schedule.next_after(DateTime::<Utc>::MAX_UTC) {
Err(CronScheduleError::Search { reason }) => {
assert_eq!(reason, "CronScheduler encountered an invalid time.");
}
other => panic!("expected Err(Search), got {other:?}"),
}
}
#[test]
fn malformed_pattern_is_rejected() {
match CronSchedule::parse("not a cron", None) {
Err(CronParseError::Pattern { pattern, .. }) => assert_eq!(pattern, "not a cron"),
other => panic!("expected Pattern error, got {other:?}"),
}
}
#[test]
fn five_tokens_of_garbage_are_rejected() {
match CronSchedule::parse("99 99 99 99 99", None) {
Err(CronParseError::Pattern { pattern, .. }) => assert_eq!(pattern, "99 99 99 99 99"),
other => panic!("expected Pattern error, got {other:?}"),
}
}
#[test]
fn unknown_timezone_is_rejected_at_parse_time() {
match CronSchedule::parse("0 3 * * *", Some("Mars/Olympus")) {
Err(CronParseError::Timezone { name }) => assert_eq!(name, "Mars/Olympus"),
other => panic!("expected Timezone error, got {other:?}"),
}
}
#[test]
fn day_of_month_last_day_extension_is_rejected() {
assert_extension_char_rejected("0 0 L * *", 'L');
}
#[test]
fn day_of_month_nearest_weekday_extension_is_rejected() {
assert_extension_char_rejected("0 0 1W * *", 'W');
}
#[test]
fn day_of_week_nth_occurrence_extension_is_rejected() {
assert_extension_char_rejected("0 0 * * 5#3", '#');
}
#[test]
fn day_of_week_any_extension_is_rejected() {
assert_extension_char_rejected("0 0 ? * *", '?');
}
#[test]
fn month_and_weekday_names_are_not_mistaken_for_extension_characters() {
let schedule = CronSchedule::parse("0 0 * JUL WED", None).unwrap();
assert_eq!(schedule.pattern(), "0 0 * JUL WED");
}
#[test]
fn weekday_range_names_are_not_mistaken_for_extension_characters() {
let schedule = CronSchedule::parse("0 0 * * MON-FRI", None).unwrap();
assert_eq!(schedule.pattern(), "0 0 * * MON-FRI");
}
}