Skip to main content

a3s_cron/
parser.rs

1//! Cron expression parser
2//!
3//! Supports standard 5-field cron syntax:
4//! ```text
5//! ┌───────────── minute (0-59)
6//! │ ┌───────────── hour (0-23)
7//! │ │ ┌───────────── day of month (1-31)
8//! │ │ │ ┌───────────── month (1-12)
9//! │ │ │ │ ┌───────────── day of week (0-6, 0=Sunday)
10//! │ │ │ │ │
11//! * * * * *
12//! ```
13//!
14//! Special characters:
15//! - `*` - any value
16//! - `,` - value list separator (e.g., `1,3,5`)
17//! - `-` - range (e.g., `1-5`)
18//! - `/` - step (e.g., `*/5` or `0-30/5`)
19
20use crate::types::{CronError, Result};
21use chrono::{DateTime, Datelike, Duration, TimeZone, Timelike, Utc};
22use serde::{Deserialize, Serialize};
23use std::collections::BTreeSet;
24
25/// A parsed cron expression
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct CronExpression {
28    /// Original expression string
29    pub expression: String,
30    /// Allowed minutes (0-59)
31    minutes: BTreeSet<u32>,
32    /// Allowed hours (0-23)
33    hours: BTreeSet<u32>,
34    /// Allowed days of month (1-31)
35    days: BTreeSet<u32>,
36    /// Allowed months (1-12)
37    months: BTreeSet<u32>,
38    /// Allowed days of week (0-6, 0=Sunday)
39    weekdays: BTreeSet<u32>,
40}
41
42impl CronExpression {
43    /// Parse a cron expression string
44    ///
45    /// # Examples
46    ///
47    /// ```
48    /// use a3s_cron::CronExpression;
49    ///
50    /// // Every 5 minutes
51    /// let expr = CronExpression::parse("*/5 * * * *").unwrap();
52    ///
53    /// // Every day at 2:30 AM
54    /// let expr = CronExpression::parse("30 2 * * *").unwrap();
55    ///
56    /// // Every Monday at 9 AM
57    /// let expr = CronExpression::parse("0 9 * * 1").unwrap();
58    /// ```
59    pub fn parse(expression: &str) -> Result<Self> {
60        let parts: Vec<&str> = expression.split_whitespace().collect();
61
62        if parts.len() != 5 {
63            return Err(CronError::InvalidExpression(format!(
64                "Expected 5 fields, got {}",
65                parts.len()
66            )));
67        }
68
69        let minutes = parse_field(parts[0], 0, 59, "minute")?;
70        let hours = parse_field(parts[1], 0, 23, "hour")?;
71        let days = parse_field(parts[2], 1, 31, "day")?;
72        let months = parse_field(parts[3], 1, 12, "month")?;
73        let weekdays = parse_field(parts[4], 0, 6, "weekday")?;
74
75        Ok(Self {
76            expression: expression.to_string(),
77            minutes,
78            hours,
79            days,
80            months,
81            weekdays,
82        })
83    }
84
85    /// Calculate the next run time after the given datetime
86    pub fn next_after(&self, after: DateTime<Utc>) -> Option<DateTime<Utc>> {
87        // Start from the next minute
88        let mut current = after + Duration::minutes(1);
89        current = Utc
90            .with_ymd_and_hms(
91                current.year(),
92                current.month(),
93                current.day(),
94                current.hour(),
95                current.minute(),
96                0,
97            )
98            .single()?;
99
100        // Search for up to 4 years (to handle leap years and edge cases)
101        let max_iterations = 4 * 366 * 24 * 60;
102
103        for _ in 0..max_iterations {
104            if self.matches(&current) {
105                return Some(current);
106            }
107            current += Duration::minutes(1);
108        }
109
110        None
111    }
112
113    /// Check if a datetime matches this cron expression
114    pub fn matches(&self, dt: &DateTime<Utc>) -> bool {
115        let minute = dt.minute();
116        let hour = dt.hour();
117        let day = dt.day();
118        let month = dt.month();
119        let weekday = dt.weekday().num_days_from_sunday();
120
121        self.minutes.contains(&minute)
122            && self.hours.contains(&hour)
123            && self.days.contains(&day)
124            && self.months.contains(&month)
125            && self.weekdays.contains(&weekday)
126    }
127
128    /// Get a human-readable description of the schedule
129    pub fn describe(&self) -> String {
130        let mut parts = Vec::new();
131
132        // Minutes
133        if self.minutes.len() == 60 {
134            parts.push("every minute".to_string());
135        } else if self.minutes.len() == 1 {
136            let min = *self.minutes.iter().next().unwrap();
137            if min == 0 {
138                parts.push("at the start of the hour".to_string());
139            } else {
140                parts.push(format!("at minute {}", min));
141            }
142        } else {
143            parts.push(format!("at minutes {:?}", self.minutes));
144        }
145
146        // Hours
147        if self.hours.len() < 24 {
148            if self.hours.len() == 1 {
149                let hour = *self.hours.iter().next().unwrap();
150                parts.push(format!("at {}:00", hour));
151            } else {
152                parts.push(format!("during hours {:?}", self.hours));
153            }
154        }
155
156        // Days
157        if self.days.len() < 31 {
158            parts.push(format!("on days {:?}", self.days));
159        }
160
161        // Months
162        if self.months.len() < 12 {
163            parts.push(format!("in months {:?}", self.months));
164        }
165
166        // Weekdays
167        if self.weekdays.len() < 7 {
168            let weekday_names: Vec<&str> = self
169                .weekdays
170                .iter()
171                .map(|&d| match d {
172                    0 => "Sun",
173                    1 => "Mon",
174                    2 => "Tue",
175                    3 => "Wed",
176                    4 => "Thu",
177                    5 => "Fri",
178                    6 => "Sat",
179                    _ => "?",
180                })
181                .collect();
182            parts.push(format!("on {}", weekday_names.join(", ")));
183        }
184
185        parts.join(", ")
186    }
187}
188
189/// Parse a single cron field
190fn parse_field(field: &str, min: u32, max: u32, name: &str) -> Result<BTreeSet<u32>> {
191    let mut values = BTreeSet::new();
192
193    for part in field.split(',') {
194        let part = part.trim();
195        if part.is_empty() {
196            continue;
197        }
198
199        // Handle step values (e.g., */5 or 0-30/5)
200        let (range_part, step) = if let Some(idx) = part.find('/') {
201            let step_str = &part[idx + 1..];
202            let step: u32 = step_str.parse().map_err(|_| {
203                CronError::InvalidExpression(format!(
204                    "Invalid step value '{}' in {}",
205                    step_str, name
206                ))
207            })?;
208            if step == 0 {
209                return Err(CronError::InvalidExpression(format!(
210                    "Step value cannot be 0 in {}",
211                    name
212                )));
213            }
214            (&part[..idx], Some(step))
215        } else {
216            (part, None)
217        };
218
219        // Parse the range part
220        let (start, end) = if range_part == "*" {
221            (min, max)
222        } else if let Some(idx) = range_part.find('-') {
223            let start: u32 = range_part[..idx].parse().map_err(|_| {
224                CronError::InvalidExpression(format!(
225                    "Invalid range start '{}' in {}",
226                    &range_part[..idx],
227                    name
228                ))
229            })?;
230            let end: u32 = range_part[idx + 1..].parse().map_err(|_| {
231                CronError::InvalidExpression(format!(
232                    "Invalid range end '{}' in {}",
233                    &range_part[idx + 1..],
234                    name
235                ))
236            })?;
237            (start, end)
238        } else {
239            let value: u32 = range_part.parse().map_err(|_| {
240                CronError::InvalidExpression(format!("Invalid value '{}' in {}", range_part, name))
241            })?;
242            (value, value)
243        };
244
245        // Validate range
246        if start < min || start > max {
247            return Err(CronError::InvalidExpression(format!(
248                "Value {} out of range ({}-{}) in {}",
249                start, min, max, name
250            )));
251        }
252        if end < min || end > max {
253            return Err(CronError::InvalidExpression(format!(
254                "Value {} out of range ({}-{}) in {}",
255                end, min, max, name
256            )));
257        }
258        if start > end {
259            return Err(CronError::InvalidExpression(format!(
260                "Invalid range {}-{} in {}",
261                start, end, name
262            )));
263        }
264
265        // Add values with step
266        let step = step.unwrap_or(1);
267        let mut current = start;
268        while current <= end {
269            values.insert(current);
270            current += step;
271        }
272    }
273
274    if values.is_empty() {
275        return Err(CronError::InvalidExpression(format!(
276            "No valid values in {}",
277            name
278        )));
279    }
280
281    Ok(values)
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn test_parse_every_minute() {
290        let expr = CronExpression::parse("* * * * *").unwrap();
291        assert_eq!(expr.minutes.len(), 60);
292        assert_eq!(expr.hours.len(), 24);
293        assert_eq!(expr.days.len(), 31);
294        assert_eq!(expr.months.len(), 12);
295        assert_eq!(expr.weekdays.len(), 7);
296    }
297
298    #[test]
299    fn test_parse_specific_time() {
300        let expr = CronExpression::parse("30 2 * * *").unwrap();
301        assert_eq!(expr.minutes, BTreeSet::from([30]));
302        assert_eq!(expr.hours, BTreeSet::from([2]));
303    }
304
305    #[test]
306    fn test_parse_step() {
307        let expr = CronExpression::parse("*/5 * * * *").unwrap();
308        assert_eq!(
309            expr.minutes,
310            BTreeSet::from([0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55])
311        );
312    }
313
314    #[test]
315    fn test_parse_range() {
316        let expr = CronExpression::parse("0 9-17 * * *").unwrap();
317        assert_eq!(
318            expr.hours,
319            BTreeSet::from([9, 10, 11, 12, 13, 14, 15, 16, 17])
320        );
321    }
322
323    #[test]
324    fn test_parse_list() {
325        let expr = CronExpression::parse("0 0 * * 1,3,5").unwrap();
326        assert_eq!(expr.weekdays, BTreeSet::from([1, 3, 5]));
327    }
328
329    #[test]
330    fn test_parse_range_with_step() {
331        let expr = CronExpression::parse("0-30/10 * * * *").unwrap();
332        assert_eq!(expr.minutes, BTreeSet::from([0, 10, 20, 30]));
333    }
334
335    #[test]
336    fn test_parse_invalid_field_count() {
337        let result = CronExpression::parse("* * *");
338        assert!(result.is_err());
339    }
340
341    #[test]
342    fn test_parse_invalid_value() {
343        let result = CronExpression::parse("60 * * * *");
344        assert!(result.is_err());
345    }
346
347    #[test]
348    fn test_parse_invalid_range() {
349        let result = CronExpression::parse("30-10 * * * *");
350        assert!(result.is_err());
351    }
352
353    #[test]
354    fn test_parse_zero_step() {
355        let result = CronExpression::parse("*/0 * * * *");
356        assert!(result.is_err());
357    }
358
359    #[test]
360    fn test_next_after() {
361        let expr = CronExpression::parse("0 * * * *").unwrap();
362        let now = Utc.with_ymd_and_hms(2026, 2, 5, 10, 30, 0).unwrap();
363        let next = expr.next_after(now).unwrap();
364        assert_eq!(next.hour(), 11);
365        assert_eq!(next.minute(), 0);
366    }
367
368    #[test]
369    fn test_next_after_specific_time() {
370        let expr = CronExpression::parse("30 14 * * *").unwrap();
371        let now = Utc.with_ymd_and_hms(2026, 2, 5, 10, 0, 0).unwrap();
372        let next = expr.next_after(now).unwrap();
373        assert_eq!(next.day(), 5);
374        assert_eq!(next.hour(), 14);
375        assert_eq!(next.minute(), 30);
376    }
377
378    #[test]
379    fn test_next_after_next_day() {
380        let expr = CronExpression::parse("0 2 * * *").unwrap();
381        let now = Utc.with_ymd_and_hms(2026, 2, 5, 10, 0, 0).unwrap();
382        let next = expr.next_after(now).unwrap();
383        assert_eq!(next.day(), 6);
384        assert_eq!(next.hour(), 2);
385        assert_eq!(next.minute(), 0);
386    }
387
388    #[test]
389    fn test_matches() {
390        let expr = CronExpression::parse("30 14 * * 1").unwrap();
391        // Monday, Feb 3, 2026 at 14:30
392        let dt = Utc.with_ymd_and_hms(2026, 2, 2, 14, 30, 0).unwrap();
393        assert!(expr.matches(&dt));
394
395        // Same time but Tuesday
396        let dt = Utc.with_ymd_and_hms(2026, 2, 3, 14, 30, 0).unwrap();
397        assert!(!expr.matches(&dt));
398    }
399
400    #[test]
401    fn test_describe() {
402        let expr = CronExpression::parse("0 9 * * 1-5").unwrap();
403        let desc = expr.describe();
404        assert!(desc.contains("Mon"));
405        assert!(desc.contains("Fri"));
406    }
407}