Skip to main content

pylon_runtime/
cron.rs

1//! Cron expression parser and matcher.
2//!
3//! Supports standard 5-field cron expressions:
4//!   minute hour day-of-month month day-of-week
5//!
6//! Field syntax: `*`, `*/N`, `N`, `N-M`, `N,M,O`, and combinations thereof.
7
8use std::time::{SystemTime, UNIX_EPOCH};
9
10/// A parsed cron expression.
11#[derive(Debug, Clone)]
12pub struct CronExpr {
13    minutes: Vec<u8>,  // 0-59
14    hours: Vec<u8>,    // 0-23
15    days: Vec<u8>,     // 1-31
16    months: Vec<u8>,   // 1-12
17    weekdays: Vec<u8>, // 0-6 (Sun=0)
18}
19
20impl CronExpr {
21    /// Parse a cron expression string.
22    /// Supports: `*`, `*/N`, `N`, `N-M`, `N,M,O`
23    pub fn parse(expr: &str) -> Result<Self, String> {
24        let parts: Vec<&str> = expr.trim().split_whitespace().collect();
25        if parts.len() != 5 {
26            return Err(format!("Expected 5 fields, got {}", parts.len()));
27        }
28
29        Ok(Self {
30            minutes: parse_field(parts[0], 0, 59)?,
31            hours: parse_field(parts[1], 0, 23)?,
32            days: parse_field(parts[2], 1, 31)?,
33            months: parse_field(parts[3], 1, 12)?,
34            weekdays: parse_field(parts[4], 0, 6)?,
35        })
36    }
37
38    /// Check if the given unix timestamp matches this cron expression.
39    pub fn matches(&self, unix_secs: u64) -> bool {
40        let (min, hour, day, month, weekday) = decompose_timestamp(unix_secs);
41        self.minutes.contains(&min)
42            && self.hours.contains(&hour)
43            && self.days.contains(&day)
44            && self.months.contains(&month)
45            && self.weekdays.contains(&weekday)
46    }
47
48    /// Check if the current moment matches.
49    pub fn matches_now(&self) -> bool {
50        let ts = SystemTime::now()
51            .duration_since(UNIX_EPOCH)
52            .unwrap_or_default()
53            .as_secs();
54        self.matches(ts)
55    }
56}
57
58/// Parse a single cron field into an expanded list of valid values.
59fn parse_field(field: &str, min: u8, max: u8) -> Result<Vec<u8>, String> {
60    if field == "*" {
61        return Ok((min..=max).collect());
62    }
63
64    // */N -- step over full range
65    if let Some(step) = field.strip_prefix("*/") {
66        let step: u8 = step.parse().map_err(|_| format!("Invalid step: {step}"))?;
67        if step == 0 {
68            return Err("Step cannot be 0".into());
69        }
70        return Ok((min..=max).step_by(step as usize).collect());
71    }
72
73    let mut values = Vec::new();
74    for part in field.split(',') {
75        if part.contains('-') {
76            // Range: N-M
77            let range: Vec<&str> = part.splitn(2, '-').collect();
78            let start: u8 = range[0]
79                .parse()
80                .map_err(|_| format!("Invalid range start: {}", range[0]))?;
81            let end: u8 = range[1]
82                .parse()
83                .map_err(|_| format!("Invalid range end: {}", range[1]))?;
84            if start > end || start < min || end > max {
85                return Err(format!("Range {start}-{end} out of bounds ({min}-{max})"));
86            }
87            values.extend(start..=end);
88        } else {
89            // Single value
90            let val: u8 = part.parse().map_err(|_| format!("Invalid value: {part}"))?;
91            if val < min || val > max {
92                return Err(format!("Value {val} out of bounds ({min}-{max})"));
93            }
94            values.push(val);
95        }
96    }
97
98    values.sort();
99    values.dedup();
100    Ok(values)
101}
102
103/// Decompose a unix timestamp into (minute, hour, day, month, weekday).
104///
105/// Uses Howard Hinnant's civil date algorithm to convert days since epoch
106/// into a calendar date.
107fn decompose_timestamp(unix_secs: u64) -> (u8, u8, u8, u8, u8) {
108    let total_secs = unix_secs as i64;
109    let day_secs = total_secs.rem_euclid(86400);
110    let hour = (day_secs / 3600) as u8;
111    let minute = ((day_secs % 3600) / 60) as u8;
112
113    // Days since epoch
114    let days = total_secs.div_euclid(86400);
115
116    // Weekday: Jan 1 1970 was Thursday (4). 0=Sun.
117    let weekday = ((days + 4).rem_euclid(7)) as u8;
118
119    // Civil date from days since epoch (Howard Hinnant's algorithm)
120    let z = days + 719468;
121    let era = if z >= 0 { z } else { z - 146096 } / 146097;
122    let doe = z - era * 146097; // day of era [0, 146096]
123    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
124    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // day of year [0, 365]
125    let mp = (5 * doy + 2) / 153; // month proxy [0, 11]
126    let day = (doy - (153 * mp + 2) / 5 + 1) as u8;
127    let month = if mp < 10 { mp + 3 } else { mp - 9 } as u8;
128
129    (minute, hour, day, month, weekday)
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn parse_every_minute() {
138        let cron = CronExpr::parse("* * * * *").unwrap();
139        assert_eq!(cron.minutes.len(), 60);
140        assert_eq!(cron.hours.len(), 24);
141        assert_eq!(cron.days.len(), 31);
142        assert_eq!(cron.months.len(), 12);
143        assert_eq!(cron.weekdays.len(), 7);
144    }
145
146    #[test]
147    fn parse_every_5_minutes() {
148        let cron = CronExpr::parse("*/5 * * * *").unwrap();
149        assert_eq!(
150            cron.minutes,
151            vec![0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55]
152        );
153    }
154
155    #[test]
156    fn parse_weekdays_at_9am() {
157        let cron = CronExpr::parse("0 9 * * 1-5").unwrap();
158        assert_eq!(cron.minutes, vec![0]);
159        assert_eq!(cron.hours, vec![9]);
160        assert_eq!(cron.weekdays, vec![1, 2, 3, 4, 5]);
161    }
162
163    #[test]
164    fn parse_comma_list() {
165        let cron = CronExpr::parse("0,15,30,45 * * * *").unwrap();
166        assert_eq!(cron.minutes, vec![0, 15, 30, 45]);
167    }
168
169    #[test]
170    fn parse_range() {
171        let cron = CronExpr::parse("* 8-17 * * *").unwrap();
172        assert_eq!(cron.hours, vec![8, 9, 10, 11, 12, 13, 14, 15, 16, 17]);
173    }
174
175    #[test]
176    fn parse_specific_values() {
177        let cron = CronExpr::parse("30 12 1 6 0").unwrap();
178        assert_eq!(cron.minutes, vec![30]);
179        assert_eq!(cron.hours, vec![12]);
180        assert_eq!(cron.days, vec![1]);
181        assert_eq!(cron.months, vec![6]);
182        assert_eq!(cron.weekdays, vec![0]);
183    }
184
185    #[test]
186    fn parse_rejects_wrong_field_count() {
187        assert!(CronExpr::parse("* * *").is_err());
188        assert!(CronExpr::parse("* * * * * *").is_err());
189        assert!(CronExpr::parse("").is_err());
190    }
191
192    #[test]
193    fn parse_rejects_out_of_range() {
194        assert!(CronExpr::parse("60 * * * *").is_err());
195        assert!(CronExpr::parse("* 24 * * *").is_err());
196        assert!(CronExpr::parse("* * 0 * *").is_err());
197        assert!(CronExpr::parse("* * 32 * *").is_err());
198        assert!(CronExpr::parse("* * * 0 *").is_err());
199        assert!(CronExpr::parse("* * * 13 *").is_err());
200        assert!(CronExpr::parse("* * * * 7").is_err());
201    }
202
203    #[test]
204    fn parse_rejects_zero_step() {
205        assert!(CronExpr::parse("*/0 * * * *").is_err());
206    }
207
208    #[test]
209    fn parse_rejects_invalid_tokens() {
210        assert!(CronExpr::parse("abc * * * *").is_err());
211        assert!(CronExpr::parse("* foo * * *").is_err());
212    }
213
214    #[test]
215    fn decompose_epoch() {
216        // 1970-01-01 00:00 is Thursday (weekday=4)
217        let (min, hour, day, month, weekday) = decompose_timestamp(0);
218        assert_eq!((min, hour, day, month, weekday), (0, 0, 1, 1, 4));
219    }
220
221    #[test]
222    fn decompose_known_date() {
223        // 2024-01-15 10:30:00 UTC = 1705314600
224        // Monday, January 15, 2024 10:30 UTC
225        let (min, hour, day, month, weekday) = decompose_timestamp(1705314600);
226        assert_eq!(min, 30);
227        assert_eq!(hour, 10);
228        assert_eq!(day, 15);
229        assert_eq!(month, 1);
230        assert_eq!(weekday, 1); // Monday
231    }
232
233    #[test]
234    fn decompose_another_known_date() {
235        // 2023-12-25 00:00:00 UTC = 1703462400
236        // Monday, December 25, 2023
237        let (min, hour, day, month, weekday) = decompose_timestamp(1703462400);
238        assert_eq!(min, 0);
239        assert_eq!(hour, 0);
240        assert_eq!(day, 25);
241        assert_eq!(month, 12);
242        assert_eq!(weekday, 1); // Monday
243    }
244
245    #[test]
246    fn matches_every_minute() {
247        let cron = CronExpr::parse("* * * * *").unwrap();
248        // Should match any timestamp
249        assert!(cron.matches(0));
250        assert!(cron.matches(1705314600));
251    }
252
253    #[test]
254    fn matches_specific_time() {
255        // 2024-01-15 10:30 UTC, Monday
256        let cron = CronExpr::parse("30 10 15 1 1").unwrap();
257        assert!(cron.matches(1705314600));
258        // One minute off should not match
259        assert!(!cron.matches(1705314600 + 60));
260    }
261
262    #[test]
263    fn matches_weekday_schedule() {
264        // 0 9 * * 1-5 : weekdays at 9:00
265        let cron = CronExpr::parse("0 9 * * 1-5").unwrap();
266        // 2024-01-15 09:00 UTC = Monday
267        let monday_9am: u64 = 1705309200; // 2024-01-15 09:00:00 UTC
268        let (min, hour, _, _, weekday) = decompose_timestamp(monday_9am);
269        assert_eq!(min, 0);
270        assert_eq!(hour, 9);
271        assert_eq!(weekday, 1);
272        assert!(cron.matches(monday_9am));
273    }
274
275    #[test]
276    fn matches_now_does_not_panic() {
277        // This is a smoke test -- matches_now should not panic regardless of
278        // what the current time is.
279        let cron = CronExpr::parse("* * * * *").unwrap();
280        // Always true for every-minute schedule.
281        assert!(cron.matches_now());
282    }
283}