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!("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
83/// Sunday is 0 or 7 in traditional cron.
84fn 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}