Skip to main content

ferro_rs/schedule/
expression.rs

1//! Cron expression parsing and due-checking
2//!
3//! Supports standard cron syntax with 5 fields:
4//! `minute hour day-of-month month day-of-week`
5
6use chrono::{Datelike, Local, Timelike};
7
8/// Day of week enum for scheduling
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum DayOfWeek {
11    Sunday = 0,
12    Monday = 1,
13    Tuesday = 2,
14    Wednesday = 3,
15    Thursday = 4,
16    Friday = 5,
17    Saturday = 6,
18}
19
20impl DayOfWeek {
21    /// Convert from chrono Weekday
22    pub fn from_chrono(weekday: chrono::Weekday) -> Self {
23        match weekday {
24            chrono::Weekday::Sun => DayOfWeek::Sunday,
25            chrono::Weekday::Mon => DayOfWeek::Monday,
26            chrono::Weekday::Tue => DayOfWeek::Tuesday,
27            chrono::Weekday::Wed => DayOfWeek::Wednesday,
28            chrono::Weekday::Thu => DayOfWeek::Thursday,
29            chrono::Weekday::Fri => DayOfWeek::Friday,
30            chrono::Weekday::Sat => DayOfWeek::Saturday,
31        }
32    }
33}
34
35/// Cron expression for scheduling tasks
36///
37/// Supports standard cron syntax with 5 fields:
38/// `minute hour day-of-month month day-of-week`
39///
40/// # Examples
41///
42/// ```rust,ignore
43/// use ferro_rs::CronExpression;
44///
45/// // Every minute
46/// let expr = CronExpression::every_minute();
47///
48/// // Daily at 3:00 AM
49/// let expr = CronExpression::daily_at("03:00");
50///
51/// // Custom cron expression
52/// let expr = CronExpression::parse("0 */2 * * *").unwrap(); // Every 2 hours
53/// ```
54#[derive(Debug, Clone)]
55pub struct CronExpression {
56    raw: String,
57    /// Minutes (0-59)
58    minute: CronField,
59    /// Hours (0-23)
60    hour: CronField,
61    /// Day of month (1-31)
62    day_of_month: CronField,
63    /// Month (1-12)
64    month: CronField,
65    /// Day of week (0-6, Sunday=0)
66    day_of_week: CronField,
67}
68
69#[derive(Debug, Clone)]
70enum CronField {
71    Any,                // *
72    Value(u32),         // 5
73    Range(u32, u32),    // 1-5
74    Step(u32),          // */5
75    List(Vec<u32>),     // 1,3,5
76    StepFrom(u32, u32), // 5/10 (start at 5, every 10)
77}
78
79impl CronField {
80    fn matches(&self, value: u32) -> bool {
81        match self {
82            CronField::Any => true,
83            CronField::Value(v) => *v == value,
84            CronField::Range(start, end) => value >= *start && value <= *end,
85            CronField::Step(step) => value.is_multiple_of(*step),
86            CronField::StepFrom(start, step) => {
87                value >= *start && (value - start).is_multiple_of(*step)
88            }
89            CronField::List(values) => values.contains(&value),
90        }
91    }
92
93    fn parse(s: &str) -> Result<Self, String> {
94        if s == "*" {
95            return Ok(CronField::Any);
96        }
97
98        // Handle */N (every N)
99        if let Some(step_str) = s.strip_prefix("*/") {
100            let step: u32 = step_str
101                .parse()
102                .map_err(|_| format!("Invalid step value in '{s}'"))?;
103            return Ok(CronField::Step(step));
104        }
105
106        // Handle N/M (starting at N, every M)
107        if s.contains('/') && !s.starts_with('*') {
108            let parts: Vec<&str> = s.split('/').collect();
109            if parts.len() == 2 {
110                let start: u32 = parts[0]
111                    .parse()
112                    .map_err(|_| format!("Invalid start value in '{s}'"))?;
113                let step: u32 = parts[1]
114                    .parse()
115                    .map_err(|_| format!("Invalid step value in '{s}'"))?;
116                return Ok(CronField::StepFrom(start, step));
117            }
118        }
119
120        // Handle comma-separated list (1,3,5)
121        if s.contains(',') {
122            let values: Result<Vec<u32>, _> = s.split(',').map(|v| v.trim().parse()).collect();
123            return Ok(CronField::List(
124                values.map_err(|_| format!("Invalid list value in '{s}'"))?,
125            ));
126        }
127
128        // Handle range (1-5)
129        if s.contains('-') {
130            let parts: Vec<&str> = s.split('-').collect();
131            if parts.len() == 2 {
132                let start: u32 = parts[0]
133                    .parse()
134                    .map_err(|_| format!("Invalid range start in '{s}'"))?;
135                let end: u32 = parts[1]
136                    .parse()
137                    .map_err(|_| format!("Invalid range end in '{s}'"))?;
138                return Ok(CronField::Range(start, end));
139            }
140        }
141
142        // Handle single value
143        let value: u32 = s.parse().map_err(|_| format!("Invalid value in '{s}'"))?;
144        Ok(CronField::Value(value))
145    }
146}
147
148impl std::fmt::Display for CronField {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        match self {
151            CronField::Any => write!(f, "*"),
152            CronField::Value(v) => write!(f, "{v}"),
153            CronField::Range(s, e) => write!(f, "{s}-{e}"),
154            CronField::Step(s) => write!(f, "*/{s}"),
155            CronField::StepFrom(start, step) => write!(f, "{start}/{step}"),
156            CronField::List(l) => {
157                let s: String = l
158                    .iter()
159                    .map(|v| v.to_string())
160                    .collect::<Vec<_>>()
161                    .join(",");
162                write!(f, "{s}")
163            }
164        }
165    }
166}
167
168impl CronExpression {
169    /// Parse a cron expression string
170    ///
171    /// Format: `minute hour day-of-month month day-of-week`
172    ///
173    /// # Examples
174    ///
175    /// - `* * * * *` - Every minute
176    /// - `0 * * * *` - Every hour
177    /// - `0 3 * * *` - Daily at 3:00 AM
178    /// - `0 0 * * 0` - Weekly on Sunday
179    /// - `*/5 * * * *` - Every 5 minutes
180    pub fn parse(expression: &str) -> Result<Self, String> {
181        let parts: Vec<&str> = expression.split_whitespace().collect();
182
183        if parts.len() != 5 {
184            return Err(format!(
185                "Cron expression must have 5 fields, got {}",
186                parts.len()
187            ));
188        }
189
190        Ok(Self {
191            raw: expression.to_string(),
192            minute: CronField::parse(parts[0])?,
193            hour: CronField::parse(parts[1])?,
194            day_of_month: CronField::parse(parts[2])?,
195            month: CronField::parse(parts[3])?,
196            day_of_week: CronField::parse(parts[4])?,
197        })
198    }
199
200    /// Check if this expression is due now
201    pub fn is_due(&self) -> bool {
202        let now = Local::now();
203
204        self.minute.matches(now.minute())
205            && self.hour.matches(now.hour())
206            && self.day_of_month.matches(now.day())
207            && self.month.matches(now.month())
208            && self
209                .day_of_week
210                .matches(now.weekday().num_days_from_sunday())
211    }
212
213    /// Get the raw cron expression string
214    pub fn expression(&self) -> &str {
215        &self.raw
216    }
217
218    /// Set the time component (modifies hour and minute)
219    pub fn at(mut self, time: &str) -> Self {
220        let parts: Vec<&str> = time.split(':').collect();
221        if parts.len() == 2 {
222            if let (Ok(hour), Ok(minute)) = (parts[0].parse::<u32>(), parts[1].parse::<u32>()) {
223                self.hour = CronField::Value(hour);
224                self.minute = CronField::Value(minute);
225                self.raw = format!(
226                    "{} {} {} {} {}",
227                    minute, hour, self.day_of_month, self.month, self.day_of_week,
228                );
229            }
230        }
231        self
232    }
233
234    // =========================================================================
235    // Factory Methods
236    // =========================================================================
237
238    /// Every minute: `* * * * *`
239    pub fn every_minute() -> Self {
240        Self::parse("* * * * *").expect("valid cron: every minute")
241    }
242
243    /// Every N minutes: `*/N * * * *`
244    ///
245    /// # Panics
246    /// Panics if `n` is 0.
247    pub fn every_n_minutes(n: u32) -> Self {
248        assert!(n > 0, "interval must be > 0");
249        Self::parse(&format!("*/{n} * * * *")).expect("valid cron: every N minutes")
250    }
251
252    /// Every hour at minute 0: `0 * * * *`
253    pub fn hourly() -> Self {
254        Self::parse("0 * * * *").expect("valid cron: hourly")
255    }
256
257    /// Every hour at specific minute: `M * * * *`
258    ///
259    /// # Panics
260    /// Panics if `minute` >= 60.
261    pub fn hourly_at(minute: u32) -> Self {
262        assert!(minute < 60, "minute must be 0-59, got {minute}");
263        Self::parse(&format!("{minute} * * * *")).expect("valid cron: hourly at")
264    }
265
266    /// Daily at midnight: `0 0 * * *`
267    pub fn daily() -> Self {
268        Self::parse("0 0 * * *").expect("valid cron: daily")
269    }
270
271    /// Daily at specific time: `M H * * *`
272    ///
273    /// Accepts `"HH:MM"` format. Falls back to midnight on invalid input.
274    pub fn daily_at(time: &str) -> Self {
275        let parts: Vec<&str> = time.split(':').collect();
276        if parts.len() == 2 {
277            let hour: u32 = parts[0].parse().unwrap_or(0);
278            let minute: u32 = parts[1].parse().unwrap_or(0);
279            Self::parse(&format!("{} {} * * *", minute.min(59), hour.min(23)))
280                .expect("valid cron: daily at")
281        } else {
282            Self::daily()
283        }
284    }
285
286    /// Weekly on Sunday at midnight: `0 0 * * 0`
287    pub fn weekly() -> Self {
288        Self::parse("0 0 * * 0").expect("valid cron: weekly")
289    }
290
291    /// Weekly on specific day at midnight: `0 0 * * D`
292    pub fn weekly_on(day: DayOfWeek) -> Self {
293        Self::parse(&format!("0 0 * * {}", day as u32)).expect("valid cron: weekly on")
294    }
295
296    /// On specific days of the week at midnight.
297    ///
298    /// # Panics
299    /// Panics if `days` is empty.
300    pub fn on_days(days: &[DayOfWeek]) -> Self {
301        assert!(!days.is_empty(), "days must not be empty");
302        let days_str: Vec<String> = days.iter().map(|d| (*d as u32).to_string()).collect();
303        Self::parse(&format!("0 0 * * {}", days_str.join(","))).expect("valid cron: on days")
304    }
305
306    /// Monthly on the first day at midnight: `0 0 1 * *`
307    pub fn monthly() -> Self {
308        Self::parse("0 0 1 * *").expect("valid cron: monthly")
309    }
310
311    /// Monthly on specific day at midnight: `0 0 D * *`
312    ///
313    /// # Panics
314    /// Panics if `day` is 0 or > 31.
315    pub fn monthly_on(day: u32) -> Self {
316        assert!((1..=31).contains(&day), "day must be 1-31, got {day}");
317        Self::parse(&format!("0 0 {day} * *")).expect("valid cron: monthly on")
318    }
319
320    /// Quarterly on the first day of each quarter at midnight
321    pub fn quarterly() -> Self {
322        Self::parse("0 0 1 1,4,7,10 *").expect("valid cron: quarterly")
323    }
324
325    /// Yearly on January 1st at midnight: `0 0 1 1 *`
326    pub fn yearly() -> Self {
327        Self::parse("0 0 1 1 *").expect("valid cron: yearly")
328    }
329
330    /// On weekdays (Monday-Friday) at midnight
331    pub fn weekdays() -> Self {
332        Self::parse("0 0 * * 1-5").expect("valid cron: weekdays")
333    }
334
335    /// On weekends (Saturday-Sunday) at midnight
336    pub fn weekends() -> Self {
337        Self::parse("0 0 * * 0,6").expect("valid cron: weekends")
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn test_parse_every_minute() {
347        let expr = CronExpression::parse("* * * * *").unwrap();
348        assert_eq!(expr.expression(), "* * * * *");
349    }
350
351    #[test]
352    fn test_parse_specific_time() {
353        let expr = CronExpression::parse("30 14 * * *").unwrap();
354        assert_eq!(expr.expression(), "30 14 * * *");
355    }
356
357    #[test]
358    fn test_parse_invalid_expression() {
359        let result = CronExpression::parse("* * *");
360        assert!(result.is_err());
361    }
362
363    #[test]
364    fn test_factory_methods() {
365        assert_eq!(CronExpression::every_minute().expression(), "* * * * *");
366        assert_eq!(CronExpression::hourly().expression(), "0 * * * *");
367        assert_eq!(CronExpression::daily().expression(), "0 0 * * *");
368        assert_eq!(CronExpression::weekly().expression(), "0 0 * * 0");
369        assert_eq!(CronExpression::monthly().expression(), "0 0 1 * *");
370    }
371
372    #[test]
373    fn test_daily_at() {
374        let expr = CronExpression::daily_at("03:30");
375        assert_eq!(expr.expression(), "30 3 * * *");
376    }
377
378    #[test]
379    fn test_at_modifier() {
380        let expr = CronExpression::daily().at("14:30");
381        assert_eq!(expr.expression(), "30 14 * * *");
382    }
383}