kit_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 kit::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 % step == 0,
86            CronField::StepFrom(start, step) => value >= *start && (value - start) % step == 0,
87            CronField::List(values) => values.contains(&value),
88        }
89    }
90
91    fn parse(s: &str) -> Result<Self, String> {
92        if s == "*" {
93            return Ok(CronField::Any);
94        }
95
96        // Handle */N (every N)
97        if s.starts_with("*/") {
98            let step: u32 = s[2..]
99                .parse()
100                .map_err(|_| format!("Invalid step value in '{}'", s))?;
101            return Ok(CronField::Step(step));
102        }
103
104        // Handle N/M (starting at N, every M)
105        if s.contains('/') && !s.starts_with('*') {
106            let parts: Vec<&str> = s.split('/').collect();
107            if parts.len() == 2 {
108                let start: u32 = parts[0]
109                    .parse()
110                    .map_err(|_| format!("Invalid start value in '{}'", s))?;
111                let step: u32 = parts[1]
112                    .parse()
113                    .map_err(|_| format!("Invalid step value in '{}'", s))?;
114                return Ok(CronField::StepFrom(start, step));
115            }
116        }
117
118        // Handle comma-separated list (1,3,5)
119        if s.contains(',') {
120            let values: Result<Vec<u32>, _> = s.split(',').map(|v| v.trim().parse()).collect();
121            return Ok(CronField::List(
122                values.map_err(|_| format!("Invalid list value in '{}'", s))?,
123            ));
124        }
125
126        // Handle range (1-5)
127        if s.contains('-') {
128            let parts: Vec<&str> = s.split('-').collect();
129            if parts.len() == 2 {
130                let start: u32 = parts[0]
131                    .parse()
132                    .map_err(|_| format!("Invalid range start in '{}'", s))?;
133                let end: u32 = parts[1]
134                    .parse()
135                    .map_err(|_| format!("Invalid range end in '{}'", s))?;
136                return Ok(CronField::Range(start, end));
137            }
138        }
139
140        // Handle single value
141        let value: u32 = s
142            .parse()
143            .map_err(|_| format!("Invalid value in '{}'", s))?;
144        Ok(CronField::Value(value))
145    }
146
147    fn to_string(&self) -> String {
148        match self {
149            CronField::Any => "*".to_string(),
150            CronField::Value(v) => v.to_string(),
151            CronField::Range(s, e) => format!("{}-{}", s, e),
152            CronField::Step(s) => format!("*/{}", s),
153            CronField::StepFrom(start, step) => format!("{}/{}", start, step),
154            CronField::List(l) => l
155                .iter()
156                .map(|v| v.to_string())
157                .collect::<Vec<_>>()
158                .join(","),
159        }
160    }
161}
162
163impl CronExpression {
164    /// Parse a cron expression string
165    ///
166    /// Format: `minute hour day-of-month month day-of-week`
167    ///
168    /// # Examples
169    ///
170    /// - `* * * * *` - Every minute
171    /// - `0 * * * *` - Every hour
172    /// - `0 3 * * *` - Daily at 3:00 AM
173    /// - `0 0 * * 0` - Weekly on Sunday
174    /// - `*/5 * * * *` - Every 5 minutes
175    pub fn parse(expression: &str) -> Result<Self, String> {
176        let parts: Vec<&str> = expression.split_whitespace().collect();
177
178        if parts.len() != 5 {
179            return Err(format!(
180                "Cron expression must have 5 fields, got {}",
181                parts.len()
182            ));
183        }
184
185        Ok(Self {
186            raw: expression.to_string(),
187            minute: CronField::parse(parts[0])?,
188            hour: CronField::parse(parts[1])?,
189            day_of_month: CronField::parse(parts[2])?,
190            month: CronField::parse(parts[3])?,
191            day_of_week: CronField::parse(parts[4])?,
192        })
193    }
194
195    /// Check if this expression is due now
196    pub fn is_due(&self) -> bool {
197        let now = Local::now();
198
199        self.minute.matches(now.minute())
200            && self.hour.matches(now.hour())
201            && self.day_of_month.matches(now.day())
202            && self.month.matches(now.month())
203            && self.day_of_week.matches(now.weekday().num_days_from_sunday())
204    }
205
206    /// Get the raw cron expression string
207    pub fn expression(&self) -> &str {
208        &self.raw
209    }
210
211    /// Set the time component (modifies hour and minute)
212    pub fn at(mut self, time: &str) -> Self {
213        let parts: Vec<&str> = time.split(':').collect();
214        if parts.len() == 2 {
215            if let (Ok(hour), Ok(minute)) = (parts[0].parse::<u32>(), parts[1].parse::<u32>()) {
216                self.hour = CronField::Value(hour);
217                self.minute = CronField::Value(minute);
218                self.raw = format!(
219                    "{} {} {} {} {}",
220                    minute,
221                    hour,
222                    self.day_of_month.to_string(),
223                    self.month.to_string(),
224                    self.day_of_week.to_string(),
225                );
226            }
227        }
228        self
229    }
230
231    // =========================================================================
232    // Factory Methods
233    // =========================================================================
234
235    /// Every minute: `* * * * *`
236    pub fn every_minute() -> Self {
237        Self::parse("* * * * *").unwrap()
238    }
239
240    /// Every N minutes: `*/N * * * *`
241    pub fn every_n_minutes(n: u32) -> Self {
242        Self::parse(&format!("*/{} * * * *", n)).unwrap()
243    }
244
245    /// Every hour at minute 0: `0 * * * *`
246    pub fn hourly() -> Self {
247        Self::parse("0 * * * *").unwrap()
248    }
249
250    /// Every hour at specific minute: `M * * * *`
251    pub fn hourly_at(minute: u32) -> Self {
252        Self::parse(&format!("{} * * * *", minute)).unwrap()
253    }
254
255    /// Daily at midnight: `0 0 * * *`
256    pub fn daily() -> Self {
257        Self::parse("0 0 * * *").unwrap()
258    }
259
260    /// Daily at specific time: `M H * * *`
261    pub fn daily_at(time: &str) -> Self {
262        let parts: Vec<&str> = time.split(':').collect();
263        if parts.len() == 2 {
264            let hour = parts[0].parse().unwrap_or(0);
265            let minute = parts[1].parse().unwrap_or(0);
266            Self::parse(&format!("{} {} * * *", minute, hour)).unwrap()
267        } else {
268            Self::daily()
269        }
270    }
271
272    /// Weekly on Sunday at midnight: `0 0 * * 0`
273    pub fn weekly() -> Self {
274        Self::parse("0 0 * * 0").unwrap()
275    }
276
277    /// Weekly on specific day at midnight: `0 0 * * D`
278    pub fn weekly_on(day: DayOfWeek) -> Self {
279        Self::parse(&format!("0 0 * * {}", day as u32)).unwrap()
280    }
281
282    /// On specific days of the week at midnight
283    pub fn on_days(days: &[DayOfWeek]) -> Self {
284        let days_str: Vec<String> = days.iter().map(|d| (*d as u32).to_string()).collect();
285        Self::parse(&format!("0 0 * * {}", days_str.join(","))).unwrap()
286    }
287
288    /// Monthly on the first day at midnight: `0 0 1 * *`
289    pub fn monthly() -> Self {
290        Self::parse("0 0 1 * *").unwrap()
291    }
292
293    /// Monthly on specific day at midnight: `0 0 D * *`
294    pub fn monthly_on(day: u32) -> Self {
295        Self::parse(&format!("0 0 {} * *", day)).unwrap()
296    }
297
298    /// Quarterly on the first day of each quarter at midnight
299    pub fn quarterly() -> Self {
300        Self::parse("0 0 1 1,4,7,10 *").unwrap()
301    }
302
303    /// Yearly on January 1st at midnight: `0 0 1 1 *`
304    pub fn yearly() -> Self {
305        Self::parse("0 0 1 1 *").unwrap()
306    }
307
308    /// On weekdays (Monday-Friday) at midnight
309    pub fn weekdays() -> Self {
310        Self::parse("0 0 * * 1-5").unwrap()
311    }
312
313    /// On weekends (Saturday-Sunday) at midnight
314    pub fn weekends() -> Self {
315        Self::parse("0 0 * * 0,6").unwrap()
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn test_parse_every_minute() {
325        let expr = CronExpression::parse("* * * * *").unwrap();
326        assert_eq!(expr.expression(), "* * * * *");
327    }
328
329    #[test]
330    fn test_parse_specific_time() {
331        let expr = CronExpression::parse("30 14 * * *").unwrap();
332        assert_eq!(expr.expression(), "30 14 * * *");
333    }
334
335    #[test]
336    fn test_parse_invalid_expression() {
337        let result = CronExpression::parse("* * *");
338        assert!(result.is_err());
339    }
340
341    #[test]
342    fn test_factory_methods() {
343        assert_eq!(CronExpression::every_minute().expression(), "* * * * *");
344        assert_eq!(CronExpression::hourly().expression(), "0 * * * *");
345        assert_eq!(CronExpression::daily().expression(), "0 0 * * *");
346        assert_eq!(CronExpression::weekly().expression(), "0 0 * * 0");
347        assert_eq!(CronExpression::monthly().expression(), "0 0 1 * *");
348    }
349
350    #[test]
351    fn test_daily_at() {
352        let expr = CronExpression::daily_at("03:30");
353        assert_eq!(expr.expression(), "30 3 * * *");
354    }
355
356    #[test]
357    fn test_at_modifier() {
358        let expr = CronExpression::daily().at("14:30");
359        assert_eq!(expr.expression(), "30 14 * * *");
360    }
361}