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