Skip to main content

rust_web_server/scheduler/
cron.rs

1use std::collections::HashSet;
2
3/// One field of a 6-part cron expression.
4#[derive(Debug, Clone)]
5pub(crate) enum Field {
6    Any,
7    Values(HashSet<u32>),
8}
9
10impl Field {
11    pub(crate) fn matches(&self, v: u32) -> bool {
12        match self {
13            Field::Any => true,
14            Field::Values(set) => set.contains(&v),
15        }
16    }
17
18    /// Parse one cron field. Supports `*`, exact values, `*/step`, `N-M` ranges,
19    /// and comma-separated combinations of the above.
20    pub(crate) fn parse(s: &str, min: u32, max: u32) -> Result<Self, String> {
21        if s == "*" {
22            return Ok(Field::Any);
23        }
24        let mut values = HashSet::new();
25        for part in s.split(',') {
26            if let Some(step_expr) = part.strip_prefix("*/") {
27                let step: u32 = step_expr
28                    .parse()
29                    .map_err(|_| format!("invalid step '{}' in cron field", part))?;
30                if step == 0 {
31                    return Err("cron step must be > 0".to_string());
32                }
33                let mut v = min;
34                while v <= max {
35                    values.insert(v);
36                    v += step;
37                }
38            } else if let Some(dash) = part.find('-') {
39                let lo: u32 = part[..dash]
40                    .parse()
41                    .map_err(|_| format!("invalid range start in '{}'", part))?;
42                let hi: u32 = part[dash + 1..]
43                    .parse()
44                    .map_err(|_| format!("invalid range end in '{}'", part))?;
45                if lo > hi {
46                    return Err(format!("range {}-{} is empty", lo, hi));
47                }
48                if lo < min || hi > max {
49                    return Err(format!(
50                        "range {}-{} is out of bounds [{}, {}]",
51                        lo, hi, min, max
52                    ));
53                }
54                for v in lo..=hi {
55                    values.insert(v);
56                }
57            } else {
58                let v: u32 = part
59                    .parse()
60                    .map_err(|_| format!("invalid cron value '{}'", part))?;
61                if v < min || v > max {
62                    return Err(format!(
63                        "value {} is out of bounds [{}, {}]",
64                        v, min, max
65                    ));
66                }
67                values.insert(v);
68            }
69        }
70        Ok(Field::Values(values))
71    }
72}
73
74/// A parsed 6-field cron expression: `second minute hour day-of-month month day-of-week`.
75///
76/// Field ranges:
77/// - second: 0–59
78/// - minute: 0–59
79/// - hour:   0–23
80/// - day:    1–31
81/// - month:  1–12
82/// - weekday: 0–6 (0 = Sunday)
83#[derive(Debug, Clone)]
84pub struct CronSchedule {
85    pub(crate) seconds: Field,
86    pub(crate) minutes: Field,
87    pub(crate) hours: Field,
88    pub(crate) days_of_month: Field,
89    pub(crate) months: Field,
90    pub(crate) days_of_week: Field,
91}
92
93impl CronSchedule {
94    /// Parse a 6-field cron expression.
95    ///
96    /// Each field supports `*`, exact values, `*/step`, `N-M` ranges, and
97    /// comma-separated combinations, e.g. `0,15,30,45 * * * * *`.
98    pub fn parse(expr: &str) -> Result<Self, String> {
99        let parts: Vec<&str> = expr.split_whitespace().collect();
100        if parts.len() != 6 {
101            return Err(format!(
102                "expected 6 cron fields (sec min hour day month weekday), got {}",
103                parts.len()
104            ));
105        }
106        Ok(CronSchedule {
107            seconds: Field::parse(parts[0], 0, 59)?,
108            minutes: Field::parse(parts[1], 0, 59)?,
109            hours: Field::parse(parts[2], 0, 23)?,
110            days_of_month: Field::parse(parts[3], 1, 31)?,
111            months: Field::parse(parts[4], 1, 12)?,
112            days_of_week: Field::parse(parts[5], 0, 6)?,
113        })
114    }
115
116    /// Returns `true` if the current wall-clock time (UTC) matches this schedule.
117    pub fn matches_now(&self) -> bool {
118        let secs = std::time::SystemTime::now()
119            .duration_since(std::time::UNIX_EPOCH)
120            .unwrap_or_default()
121            .as_secs();
122        self.matches_epoch(secs)
123    }
124
125    /// Returns `true` if the given Unix timestamp (seconds since epoch, UTC) matches.
126    pub fn matches_epoch(&self, epoch_secs: u64) -> bool {
127        let (sec, min, hour, day, month, dow) = epoch_to_datetime(epoch_secs);
128        self.seconds.matches(sec)
129            && self.minutes.matches(min)
130            && self.hours.matches(hour)
131            && self.days_of_month.matches(day)
132            && self.months.matches(month)
133            && self.days_of_week.matches(dow)
134    }
135}
136
137/// Decompose a Unix timestamp (UTC) into `(second, minute, hour, day, month, day_of_week)`.
138/// `day` is 1-based, `month` is 1-based, `dow` is 0=Sunday..6=Saturday.
139pub(crate) fn epoch_to_datetime(epoch_secs: u64) -> (u32, u32, u32, u32, u32, u32) {
140    let sec = (epoch_secs % 60) as u32;
141    let mins_total = epoch_secs / 60;
142    let min = (mins_total % 60) as u32;
143    let hours_total = mins_total / 60;
144    let hour = (hours_total % 24) as u32;
145    let days_total = hours_total / 24;
146
147    // 1970-01-01 was a Thursday (4)
148    let dow = ((days_total + 4) % 7) as u32;
149
150    let (_, month, day) = days_to_ymd(days_total);
151    (sec, min, hour, day, month, dow)
152}
153
154/// Gregorian calendar decomposition of days-since-1970-01-01 into (year, month, day).
155/// Uses Howard Hinnant's civil-from-days algorithm.
156pub(crate) fn days_to_ymd(days: u64) -> (u32, u32, u32) {
157    let z = days + 719468;
158    let era = z / 146097;
159    let doe = z - era * 146097;
160    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
161    let y = yoe + era * 400;
162    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
163    let mp = (5 * doy + 2) / 153;
164    let d = doy - (153 * mp + 2) / 5 + 1;
165    let m = if mp < 10 { mp + 3 } else { mp - 9 };
166    let y = if m <= 2 { y + 1 } else { y };
167    (y as u32, m as u32, d as u32)
168}