use std::fmt;
#[derive(Debug, PartialEq, Eq)]
pub enum CronError {
WrongFieldCount(usize),
InvalidField(&'static str, String),
}
impl fmt::Display for CronError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CronError::WrongFieldCount(n) => {
write!(f, "cron expression must have 5 fields, got {n}")
}
CronError::InvalidField(name, raw) => write!(f, "invalid {name} field: {raw}"),
}
}
}
impl std::error::Error for CronError {}
#[derive(Debug, Clone)]
pub struct CronSchedule {
minute: Vec<u8>,
hour: Vec<u8>,
dom: Vec<u8>,
month: Vec<u8>,
dow: Vec<u8>,
}
impl CronSchedule {
pub fn parse(expr: &str) -> Result<Self, CronError> {
let fields: Vec<&str> = expr.split_whitespace().collect();
if fields.len() != 5 {
return Err(CronError::WrongFieldCount(fields.len()));
}
Ok(Self {
minute: parse_field(fields[0], 0, 59, "minute")?,
hour: parse_field(fields[1], 0, 23, "hour")?,
dom: parse_field(fields[2], 1, 31, "day-of-month")?,
month: parse_field(fields[3], 1, 12, "month")?,
dow: parse_field(fields[4], 0, 6, "day-of-week")?,
})
}
pub fn minutes(&self) -> &[u8] {
&self.minute
}
pub fn hours(&self) -> &[u8] {
&self.hour
}
pub fn days_of_month(&self) -> &[u8] {
&self.dom
}
pub fn months(&self) -> &[u8] {
&self.month
}
pub fn days_of_week(&self) -> &[u8] {
&self.dow
}
pub fn next_after(&self, after_epoch: u64) -> Option<u64> {
let mut t = after_epoch.div_ceil(60) * 60;
let cap = after_epoch + 5 * 365 * 24 * 60 * 60;
while t < cap {
let (year, month, dom, dow, hour, minute) = civil_from_epoch(t);
if !self.minute.contains(&(minute as u8)) {
t += 60;
continue;
}
if !self.hour.contains(&(hour as u8)) {
t += 60;
continue;
}
if !self.month.contains(&(month as u8)) {
t += 60;
continue;
}
if !self.dom.contains(&(dom as u8)) {
t += 60;
continue;
}
if !self.dow.contains(&(dow as u8)) {
t += 60;
continue;
}
let _ = year;
return Some(t);
}
None
}
}
fn parse_field(s: &str, lo: u32, hi: u32, name: &'static str) -> Result<Vec<u8>, CronError> {
let mut out = Vec::new();
for part in s.split(',') {
let part = part.trim();
if part.is_empty() {
return Err(CronError::InvalidField(name, s.to_string()));
}
if let Some(rest) = part.strip_prefix("*/") {
let step: u32 = rest
.parse()
.map_err(|_| CronError::InvalidField(name, s.to_string()))?;
if step == 0 {
return Err(CronError::InvalidField(name, s.to_string()));
}
let mut v = lo;
while v <= hi {
out.push(v as u8);
v += step;
}
continue;
}
if part == "*" {
for v in lo..=hi {
out.push(v as u8);
}
continue;
}
if let Some((a, b)) = part.split_once('-') {
let a: u32 = a
.parse()
.map_err(|_| CronError::InvalidField(name, s.to_string()))?;
let b: u32 = b
.parse()
.map_err(|_| CronError::InvalidField(name, s.to_string()))?;
if a < lo || b > hi || a > b {
return Err(CronError::InvalidField(name, s.to_string()));
}
for v in a..=b {
out.push(v as u8);
}
continue;
}
let v: u32 = part
.parse()
.map_err(|_| CronError::InvalidField(name, s.to_string()))?;
if v < lo || v > hi {
return Err(CronError::InvalidField(name, s.to_string()));
}
out.push(v as u8);
}
out.sort_unstable();
out.dedup();
Ok(out)
}
fn civil_from_epoch(epoch: u64) -> (i32, u32, u32, u32, u32, u32) {
let days_since_epoch = (epoch / 86_400) as i64;
let secs_today = (epoch % 86_400) as u32;
let hour = secs_today / 3600;
let minute = (secs_today % 3600) / 60;
let dow = (((days_since_epoch + 4) % 7 + 7) % 7) as u32;
let z = days_since_epoch + 719_468;
let era = if z >= 0 {
z / 146_097
} else {
(z - 146_096) / 146_097
};
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let dom = (doy - (153 * mp + 2) / 5 + 1) as u32;
let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let year = (y + if month <= 2 { 1 } else { 0 }) as i32;
(year, month, dom, dow, hour, minute)
}
pub struct CronScheduler {
schedule: CronSchedule,
last_fire_epoch: u64,
}
impl CronScheduler {
pub fn new(schedule: CronSchedule, now_epoch: u64) -> Self {
Self {
schedule,
last_fire_epoch: now_epoch,
}
}
pub fn schedule(&self) -> &CronSchedule {
&self.schedule
}
pub fn next_fire(&self, now_epoch: u64) -> Option<u64> {
let after = now_epoch.max(self.last_fire_epoch + 1);
self.schedule.next_after(after)
}
pub fn record_fire(&mut self, epoch: u64) {
self.last_fire_epoch = epoch;
}
}
#[cfg(test)]
#[path = "cron_tests.rs"]
mod tests;