Skip to main content

unifier/
cron.rs

1//! Cron schedule matching for directory names like `0_0_*_*_*`.
2
3use chrono::{Datelike, Local, Timelike};
4
5use crate::constants::CRON_FIELD_SEP;
6use crate::error::{Error, Result};
7
8/// Five-field cron pattern: minute hour day-of-month month day-of-week.
9#[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    /// Parse `min_hour_dom_mon_dow` directory names. `*` means any.
26    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!(
67            "invalid {label} in cron schedule: {raw} (use * or {min}-{max})"
68        ))
69    })?;
70    if n < min || n > max {
71        return Err(Error::msg(format!(
72            "{label} out of range ({min}-{max}): {n}"
73        )));
74    }
75    Ok(CronField::Exact(n))
76}
77
78fn field_matches(field: &CronField, value: u32) -> bool {
79    match field {
80        CronField::Any => true,
81        CronField::Exact(n) => *n == value,
82    }
83}
84
85/// Sunday is 0 or 7 in traditional cron.
86fn field_matches_dow(field: &CronField, value: u32) -> bool {
87    match field {
88        CronField::Any => true,
89        CronField::Exact(n) => *n == value || (*n == 7 && value == 0),
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use chrono::TimeZone;
97
98    #[test]
99    fn parse_wildcard_schedule() {
100        let s = CronSchedule::parse("*_*_*_*_*").unwrap();
101        assert_eq!(s.minute, CronField::Any);
102    }
103
104    #[test]
105    fn matches_specific_minute() {
106        let s = CronSchedule::parse("30_14_*_*_*").unwrap();
107        let when = Local.with_ymd_and_hms(2026, 6, 2, 14, 30, 0).unwrap();
108        assert!(s.matches_at(when));
109        let other = Local.with_ymd_and_hms(2026, 6, 2, 14, 31, 0).unwrap();
110        assert!(!s.matches_at(other));
111    }
112}