use chrono::{Datelike, Local, Timelike};
use crate::constants::CRON_FIELD_SEP;
use crate::error::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CronSchedule {
pub minute: CronField,
pub hour: CronField,
pub day_of_month: CronField,
pub month: CronField,
pub day_of_week: CronField,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CronField {
Any,
Exact(u32),
}
impl CronSchedule {
pub fn parse(name: &str) -> Result<Self> {
let parts: Vec<&str> = name.split(CRON_FIELD_SEP).collect();
if parts.len() != 5 {
return Err(Error::msg(format!(
"cron schedule must have 5 fields separated by '{CRON_FIELD_SEP}', got: {name}"
)));
}
Ok(Self {
minute: parse_field(parts[0], 0, 59, "minute")?,
hour: parse_field(parts[1], 0, 23, "hour")?,
day_of_month: parse_field(parts[2], 1, 31, "day-of-month")?,
month: parse_field(parts[3], 1, 12, "month")?,
day_of_week: parse_field(parts[4], 0, 7, "day-of-week")?,
})
}
pub fn matches_now(&self) -> bool {
self.matches_at(Local::now())
}
pub fn matches_at<Tz: chrono::TimeZone>(&self, when: chrono::DateTime<Tz>) -> bool {
let minute = when.minute();
let hour = when.hour();
let dom = when.day();
let month = when.month();
let dow = when.weekday().num_days_from_sunday();
field_matches(&self.minute, minute)
&& field_matches(&self.hour, hour)
&& field_matches(&self.day_of_month, dom)
&& field_matches(&self.month, month)
&& field_matches_dow(&self.day_of_week, dow)
}
}
fn parse_field(raw: &str, min: u32, max: u32, label: &str) -> Result<CronField> {
if raw == "*" {
return Ok(CronField::Any);
}
let n: u32 = raw.parse().map_err(|_| {
Error::msg(format!(
"invalid {label} in cron schedule: {raw} (use * or {min}-{max})"
))
})?;
if n < min || n > max {
return Err(Error::msg(format!(
"{label} out of range ({min}-{max}): {n}"
)));
}
Ok(CronField::Exact(n))
}
fn field_matches(field: &CronField, value: u32) -> bool {
match field {
CronField::Any => true,
CronField::Exact(n) => *n == value,
}
}
fn field_matches_dow(field: &CronField, value: u32) -> bool {
match field {
CronField::Any => true,
CronField::Exact(n) => *n == value || (*n == 7 && value == 0),
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn parse_wildcard_schedule() {
let s = CronSchedule::parse("*_*_*_*_*").unwrap();
assert_eq!(s.minute, CronField::Any);
}
#[test]
fn matches_specific_minute() {
let s = CronSchedule::parse("30_14_*_*_*").unwrap();
let when = Local.with_ymd_and_hms(2026, 6, 2, 14, 30, 0).unwrap();
assert!(s.matches_at(when));
let other = Local.with_ymd_and_hms(2026, 6, 2, 14, 31, 0).unwrap();
assert!(!s.matches_at(other));
}
}