Skip to main content

rws_server/scheduler/
cron.rs

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