struct Field {
allowed: Vec<bool>,
lo: u32,
wildcard: bool,
}
impl Field {
fn parse(spec: &str, lo: u32, hi: u32) -> Result<Self, String> {
if spec.is_empty() {
return Err("empty field".to_string());
}
let mut allowed = vec![false; (hi - lo + 1) as usize];
for part in spec.split(',') {
let (range, step) = match part.split_once('/') {
Some((range, step)) => {
let step: u32 = step.parse().map_err(|_| format!("bad step in {part:?}"))?;
if step == 0 {
return Err(format!("zero step in {part:?}"));
}
(range, step)
}
None => (part, 1),
};
let (start, end) = if range == "*" {
(lo, hi)
} else if let Some((a, b)) = range.split_once('-') {
let (a, b) = (num(a, lo, hi)?, num(b, lo, hi)?);
if a > b {
return Err(format!("descending range {range:?}"));
}
(a, b)
} else {
if part.contains('/') {
return Err(format!("step on a single value {part:?}"));
}
let v = num(range, lo, hi)?;
(v, v)
};
let mut v = start;
while v <= end {
allowed[(v - lo) as usize] = true;
v += step;
}
}
Ok(Self {
allowed,
lo,
wildcard: spec == "*",
})
}
fn matches(&self, value: u32) -> bool {
value
.checked_sub(self.lo)
.and_then(|i| self.allowed.get(i as usize))
.copied()
.unwrap_or(false)
}
}
fn num(s: &str, lo: u32, hi: u32) -> Result<u32, String> {
let n: u32 = s.parse().map_err(|_| format!("not a number: {s:?}"))?;
if n < lo || n > hi {
return Err(format!("{n} out of range {lo}..={hi}"));
}
Ok(n)
}
pub struct CronSchedule {
minute: Field,
hour: Field,
dom: Field,
month: Field,
dow: Field,
}
impl CronSchedule {
pub fn parse(schedule: &str) -> Result<Self, String> {
let fields: Vec<&str> = schedule.split_whitespace().collect();
if fields.len() != 5 {
return Err(format!(
"schedule {schedule:?} must have 5 fields (minute hour day-of-month month day-of-week)"
));
}
let field = |i: usize, lo, hi| {
Field::parse(fields[i], lo, hi).map_err(|e| format!("field {}: {e}", i + 1))
};
Ok(Self {
minute: field(0, 0, 59)?,
hour: field(1, 0, 23)?,
dom: field(2, 1, 31)?,
month: field(3, 1, 12)?,
dow: field(4, 0, 7)?,
})
}
pub fn fires_at(&self, minute: u32, hour: u32, dom: u32, month: u32, dow: u32) -> bool {
let dow_match = self.dow.matches(dow) || (dow == 0 && self.dow.matches(7));
let dom_match = self.dom.matches(dom);
let day = if !self.dom.wildcard && !self.dow.wildcard {
dom_match || dow_match
} else {
dom_match && dow_match
};
self.minute.matches(minute) && self.hour.matches(hour) && self.month.matches(month) && day
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_and_rejects() {
assert!(CronSchedule::parse("* * * * *").is_ok());
assert!(CronSchedule::parse("*/5 0-6 1,15 */2 1-5").is_ok());
assert!(CronSchedule::parse("* * * *").is_err()); assert!(CronSchedule::parse("60 * * * *").is_err()); assert!(CronSchedule::parse("5-1 * * * *").is_err()); assert!(CronSchedule::parse("*/0 * * * *").is_err()); assert!(CronSchedule::parse("5/2 * * * *").is_err()); }
#[test]
fn every_minute_always_fires() {
let s = CronSchedule::parse("* * * * *").unwrap();
assert!(s.fires_at(0, 0, 1, 1, 0));
assert!(s.fires_at(37, 13, 28, 6, 3));
}
#[test]
fn specific_minute_hour() {
let s = CronSchedule::parse("30 9 * * *").unwrap();
assert!(s.fires_at(30, 9, 15, 6, 2));
assert!(!s.fires_at(31, 9, 15, 6, 2));
assert!(!s.fires_at(30, 10, 15, 6, 2));
}
#[test]
fn step_and_list() {
let s = CronSchedule::parse("*/15 * * * *").unwrap();
for m in [0, 15, 30, 45] {
assert!(s.fires_at(m, 0, 1, 1, 0), "minute {m}");
}
assert!(!s.fires_at(7, 0, 1, 1, 0));
}
#[test]
fn dow_sunday_is_zero_or_seven() {
let zero = CronSchedule::parse("0 0 * * 0").unwrap();
let seven = CronSchedule::parse("0 0 * * 7").unwrap();
assert!(zero.fires_at(0, 0, 3, 8, 0)); assert!(seven.fires_at(0, 0, 3, 8, 0));
assert!(!zero.fires_at(0, 0, 4, 8, 1)); }
#[test]
fn dom_and_dow_or_when_both_restricted() {
let s = CronSchedule::parse("0 0 1 * 1").unwrap();
assert!(s.fires_at(0, 0, 1, 6, 3)); assert!(s.fires_at(0, 0, 9, 6, 1)); assert!(!s.fires_at(0, 0, 9, 6, 3)); }
}