use std::collections::BTreeSet;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CronSchedule {
pub minute: Vec<u32>,
pub hour: Vec<u32>,
pub day_of_month: Vec<u32>,
pub month: Vec<u32>,
pub day_of_week: Vec<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CronError {
WrongFieldCount { found: usize },
BadField {
index: usize,
name: &'static str,
value: String,
reason: String,
},
}
impl fmt::Display for CronError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CronError::WrongFieldCount { found } => write!(
f,
"a cron expression needs exactly 5 fields \
(minute hour day-of-month month day-of-week), found {found}"
),
CronError::BadField { name, value, reason, .. } => {
write!(f, "cron {name} field '{value}': {reason}")
}
}
}
}
const FIELD_BOUNDS: [(&str, u32, u32); 5] = [
("minute", 0, 59),
("hour", 0, 23),
("day-of-month", 1, 31),
("month", 1, 12),
("day-of-week", 0, 7),
];
impl CronSchedule {
pub fn parse(expr: &str) -> Result<CronSchedule, CronError> {
let fields: Vec<&str> = expr.split_whitespace().collect();
if fields.len() != 5 {
return Err(CronError::WrongFieldCount { found: fields.len() });
}
let mut sets: Vec<Vec<u32>> = Vec::with_capacity(5);
for (i, (name, lo, hi)) in FIELD_BOUNDS.iter().enumerate() {
sets.push(parse_field(fields[i], i, name, *lo, *hi)?);
}
let mut dow: BTreeSet<u32> = sets[4].iter().map(|&v| if v == 7 { 0 } else { v }).collect();
let _ = &mut dow;
Ok(CronSchedule {
minute: sets[0].clone(),
hour: sets[1].clone(),
day_of_month: sets[2].clone(),
month: sets[3].clone(),
day_of_week: dow.into_iter().collect(),
})
}
}
fn bad(index: usize, name: &'static str, value: &str, reason: impl Into<String>) -> CronError {
CronError::BadField {
index,
name,
value: value.to_string(),
reason: reason.into(),
}
}
fn parse_field(
spec: &str,
index: usize,
name: &'static str,
lo: u32,
hi: u32,
) -> Result<Vec<u32>, CronError> {
if spec.is_empty() {
return Err(bad(index, name, spec, "empty field"));
}
let mut values: BTreeSet<u32> = BTreeSet::new();
for item in spec.split(',') {
let (range_part, step) = match item.split_once('/') {
Some((r, s)) => {
let step = s
.parse::<u32>()
.map_err(|_| bad(index, name, spec, format!("invalid step '{s}'")))?;
if step == 0 {
return Err(bad(index, name, spec, "step cannot be 0"));
}
(r, step)
}
None => (item, 1u32),
};
let (start, end) = if range_part == "*" {
(lo, hi)
} else if let Some((a, b)) = range_part.split_once('-') {
let a = a
.parse::<u32>()
.map_err(|_| bad(index, name, spec, format!("invalid range start '{a}'")))?;
let b = b
.parse::<u32>()
.map_err(|_| bad(index, name, spec, format!("invalid range end '{b}'")))?;
(a, b)
} else {
let v = range_part
.parse::<u32>()
.map_err(|_| bad(index, name, spec, format!("invalid value '{range_part}'")))?;
(v, v)
};
if start > end {
return Err(bad(
index,
name,
spec,
format!("range start {start} is greater than end {end}"),
));
}
if start < lo || end > hi {
return Err(bad(
index,
name,
spec,
format!("value out of range (allowed {lo}–{hi})"),
));
}
let mut v = start;
while v <= end {
values.insert(v);
v += step;
}
}
Ok(values.into_iter().collect())
}
pub fn cron_expr(channel: &str) -> Option<&str> {
channel.strip_prefix("cron:").map(str::trim)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_five_minutes_expands() {
let s = CronSchedule::parse("*/5 * * * *").expect("valid");
assert_eq!(s.minute, vec![0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55]);
assert_eq!(s.hour.len(), 24);
assert_eq!(s.day_of_week, (0..=6).collect::<Vec<_>>());
}
#[test]
fn ranges_lists_and_steps() {
let s = CronSchedule::parse("0 9-17 * * 1-5").expect("valid weekday business hours");
assert_eq!(s.minute, vec![0]);
assert_eq!(s.hour, (9..=17).collect::<Vec<_>>());
assert_eq!(s.day_of_week, vec![1, 2, 3, 4, 5]);
let s2 = CronSchedule::parse("0,30 0-6/2 1 * *").expect("valid");
assert_eq!(s2.minute, vec![0, 30]);
assert_eq!(s2.hour, vec![0, 2, 4, 6]);
assert_eq!(s2.day_of_month, vec![1]);
}
#[test]
fn sunday_seven_folds_to_zero() {
let s = CronSchedule::parse("0 0 * * 7").expect("valid");
assert_eq!(s.day_of_week, vec![0], "7 is normalised to 0 (Sunday)");
}
#[test]
fn wrong_field_count_rejected() {
assert_eq!(
CronSchedule::parse("*/5 * * *"),
Err(CronError::WrongFieldCount { found: 4 })
);
assert!(matches!(
CronSchedule::parse("* * * * * *"),
Err(CronError::WrongFieldCount { found: 6 })
));
}
#[test]
fn out_of_range_and_malformed_rejected() {
assert!(matches!(
CronSchedule::parse("60 * * * *"),
Err(CronError::BadField { name: "minute", .. })
));
assert!(matches!(
CronSchedule::parse("0 0 1 13 *"),
Err(CronError::BadField { name: "month", .. })
));
assert!(matches!(
CronSchedule::parse("*/0 * * * *"),
Err(CronError::BadField { .. })
));
assert!(matches!(
CronSchedule::parse("abc * * * *"),
Err(CronError::BadField { .. })
));
assert!(matches!(
CronSchedule::parse("30-10 * * * *"),
Err(CronError::BadField { .. })
));
}
#[test]
fn cron_expr_recogniser() {
assert_eq!(cron_expr("cron:*/5 * * * *"), Some("*/5 * * * *"));
assert_eq!(cron_expr("cron: 0 0 * * * "), Some("0 0 * * *"));
assert_eq!(cron_expr("user_events"), None);
assert_eq!(cron_expr("ticks"), None);
}
}