1use chrono::{Datelike, Local, Timelike};
4
5use crate::constants::CRON_FIELD_SEP;
6use crate::error::{Error, Result};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct CronSchedule {
11 pub minute: CronField,
12 pub hour: CronField,
13 pub day_of_month: CronField,
14 pub month: CronField,
15 pub day_of_week: CronField,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum CronField {
20 Any,
21 Exact(u32),
22}
23
24impl CronSchedule {
25 pub fn parse(name: &str) -> Result<Self> {
27 let parts: Vec<&str> = name.split(CRON_FIELD_SEP).collect();
28 if parts.len() != 5 {
29 return Err(Error::msg(format!(
30 "cron schedule must have 5 fields separated by '{CRON_FIELD_SEP}', got: {name}"
31 )));
32 }
33 Ok(Self {
34 minute: parse_field(parts[0], 0, 59, "minute")?,
35 hour: parse_field(parts[1], 0, 23, "hour")?,
36 day_of_month: parse_field(parts[2], 1, 31, "day-of-month")?,
37 month: parse_field(parts[3], 1, 12, "month")?,
38 day_of_week: parse_field(parts[4], 0, 7, "day-of-week")?,
39 })
40 }
41
42 pub fn matches_now(&self) -> bool {
43 self.matches_at(Local::now())
44 }
45
46 pub fn matches_at<Tz: chrono::TimeZone>(&self, when: chrono::DateTime<Tz>) -> bool {
47 let minute = when.minute();
48 let hour = when.hour();
49 let dom = when.day();
50 let month = when.month();
51 let dow = when.weekday().num_days_from_sunday();
52
53 field_matches(&self.minute, minute)
54 && field_matches(&self.hour, hour)
55 && field_matches(&self.day_of_month, dom)
56 && field_matches(&self.month, month)
57 && field_matches_dow(&self.day_of_week, dow)
58 }
59}
60
61fn parse_field(raw: &str, min: u32, max: u32, label: &str) -> Result<CronField> {
62 if raw == "*" {
63 return Ok(CronField::Any);
64 }
65 let n: u32 = raw.parse().map_err(|_| {
66 Error::msg(format!("invalid {label} in cron schedule: {raw} (use * or {min}-{max})"))
67 })?;
68 if n < min || n > max {
69 return Err(Error::msg(format!(
70 "{label} out of range ({min}-{max}): {n}"
71 )));
72 }
73 Ok(CronField::Exact(n))
74}
75
76fn field_matches(field: &CronField, value: u32) -> bool {
77 match field {
78 CronField::Any => true,
79 CronField::Exact(n) => *n == value,
80 }
81}
82
83fn field_matches_dow(field: &CronField, value: u32) -> bool {
85 match field {
86 CronField::Any => true,
87 CronField::Exact(n) => *n == value || (*n == 7 && value == 0),
88 }
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94 use chrono::TimeZone;
95
96 #[test]
97 fn parse_wildcard_schedule() {
98 let s = CronSchedule::parse("*_*_*_*_*").unwrap();
99 assert_eq!(s.minute, CronField::Any);
100 }
101
102 #[test]
103 fn matches_specific_minute() {
104 let s = CronSchedule::parse("30_14_*_*_*").unwrap();
105 let when = Local.with_ymd_and_hms(2026, 6, 2, 14, 30, 0).unwrap();
106 assert!(s.matches_at(when));
107 let other = Local.with_ymd_and_hms(2026, 6, 2, 14, 31, 0).unwrap();
108 assert!(!s.matches_at(other));
109 }
110}