Skip to main content

oxigdal_workflow/scheduler/
cron.rs

1//! Cron-based workflow scheduling.
2
3use crate::error::{Result, WorkflowError};
4use crate::scheduler::SchedulerConfig;
5use chrono::{DateTime, Utc};
6use cron::Schedule as CronScheduleParser;
7use serde::{Deserialize, Serialize};
8use std::str::FromStr;
9
10/// Cron schedule definition.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct CronSchedule {
13    /// Cron expression (standard 5-field or 6-field with seconds).
14    pub expression: String,
15    /// Time zone for evaluation.
16    pub timezone: String,
17    /// Description of the schedule.
18    pub description: Option<String>,
19}
20
21impl CronSchedule {
22    /// Create a new cron schedule.
23    pub fn new<S: Into<String>>(expression: S) -> Result<Self> {
24        let expr = expression.into();
25
26        // Validate the cron expression
27        CronScheduleParser::from_str(&expr).map_err(|e| {
28            WorkflowError::cron_expression(format!("Invalid cron expression '{}': {}", expr, e))
29        })?;
30
31        Ok(Self {
32            expression: expr,
33            timezone: "UTC".to_string(),
34            description: None,
35        })
36    }
37
38    /// Set the timezone for this schedule.
39    pub fn with_timezone<S: Into<String>>(mut self, timezone: S) -> Self {
40        self.timezone = timezone.into();
41        self
42    }
43
44    /// Set the description for this schedule.
45    pub fn with_description<S: Into<String>>(mut self, description: S) -> Self {
46        self.description = Some(description.into());
47        self
48    }
49
50    /// Calculate the next execution time from the given datetime.
51    pub fn next_execution_from(&self, from: DateTime<Utc>) -> Result<Option<DateTime<Utc>>> {
52        let schedule = CronScheduleParser::from_str(&self.expression).map_err(|e| {
53            WorkflowError::cron_expression(format!("Invalid cron expression: {}", e))
54        })?;
55
56        Ok(schedule.after(&from).next())
57    }
58
59    /// Calculate all execution times within a given range.
60    pub fn executions_in_range(
61        &self,
62        start: DateTime<Utc>,
63        end: DateTime<Utc>,
64        max_count: usize,
65    ) -> Result<Vec<DateTime<Utc>>> {
66        let schedule = CronScheduleParser::from_str(&self.expression).map_err(|e| {
67            WorkflowError::cron_expression(format!("Invalid cron expression: {}", e))
68        })?;
69
70        let mut executions = Vec::new();
71        for datetime in schedule.after(&start).take(max_count) {
72            if datetime > end {
73                break;
74            }
75            executions.push(datetime);
76        }
77
78        Ok(executions)
79    }
80
81    /// Check if this schedule should execute at the given time (within 1 second tolerance).
82    pub fn should_execute_at(&self, time: DateTime<Utc>) -> Result<bool> {
83        let next = self.next_execution_from(
84            time - chrono::Duration::try_seconds(2)
85                .ok_or_else(|| WorkflowError::internal("Duration overflow"))?,
86        )?;
87
88        if let Some(next_time) = next {
89            let diff = (next_time - time).num_seconds().abs();
90            Ok(diff <= 1)
91        } else {
92            Ok(false)
93        }
94    }
95}
96
97/// Cron scheduler for managing cron-based workflow executions.
98pub struct CronScheduler {
99    config: SchedulerConfig,
100}
101
102impl CronScheduler {
103    /// Create a new cron scheduler.
104    pub fn new(config: SchedulerConfig) -> Self {
105        Self { config }
106    }
107
108    /// Calculate the next execution time for a cron expression.
109    pub fn calculate_next_execution(
110        &self,
111        expression: &str,
112        from: DateTime<Utc>,
113    ) -> Result<Option<DateTime<Utc>>> {
114        let schedule = CronScheduleParser::from_str(expression).map_err(|e| {
115            WorkflowError::cron_expression(format!(
116                "Invalid cron expression '{}': {}",
117                expression, e
118            ))
119        })?;
120
121        Ok(schedule.after(&from).next())
122    }
123
124    /// Calculate missed executions between two times.
125    pub fn calculate_missed_executions(
126        &self,
127        expression: &str,
128        last_execution: DateTime<Utc>,
129        now: DateTime<Utc>,
130    ) -> Result<Vec<DateTime<Utc>>> {
131        if !self.config.handle_missed_executions {
132            return Ok(Vec::new());
133        }
134
135        let schedule = CronScheduleParser::from_str(expression).map_err(|e| {
136            WorkflowError::cron_expression(format!("Invalid cron expression: {}", e))
137        })?;
138
139        let mut missed = Vec::new();
140        for datetime in schedule
141            .after(&last_execution)
142            .take(self.config.max_missed_executions)
143        {
144            if datetime >= now {
145                break;
146            }
147            missed.push(datetime);
148        }
149
150        Ok(missed)
151    }
152
153    /// Validate a cron expression.
154    pub fn validate_expression(expression: &str) -> Result<()> {
155        CronScheduleParser::from_str(expression).map_err(|e| {
156            WorkflowError::cron_expression(format!(
157                "Invalid cron expression '{}': {}",
158                expression, e
159            ))
160        })?;
161        Ok(())
162    }
163
164    /// Get human-readable description of a cron expression.
165    pub fn describe_expression(expression: &str) -> Result<String> {
166        // Validate first
167        Self::validate_expression(expression)?;
168
169        // Simple description (could be enhanced with a cron descriptor library)
170        Ok(format!("Cron schedule: {}", expression))
171    }
172}
173
174/// Common cron schedule patterns.
175pub struct CronPatterns;
176
177impl CronPatterns {
178    /// Every minute.
179    pub fn every_minute() -> &'static str {
180        "0 * * * * *"
181    }
182
183    /// Every 5 minutes.
184    pub fn every_5_minutes() -> &'static str {
185        "0 */5 * * * *"
186    }
187
188    /// Every 15 minutes.
189    pub fn every_15_minutes() -> &'static str {
190        "0 */15 * * * *"
191    }
192
193    /// Every 30 minutes.
194    pub fn every_30_minutes() -> &'static str {
195        "0 */30 * * * *"
196    }
197
198    /// Every hour.
199    pub fn every_hour() -> &'static str {
200        "0 0 * * * *"
201    }
202
203    /// Every day at midnight.
204    pub fn daily() -> &'static str {
205        "0 0 0 * * *"
206    }
207
208    /// Every day at noon.
209    pub fn daily_at_noon() -> &'static str {
210        "0 0 12 * * *"
211    }
212
213    /// Every week on Sunday at midnight.
214    pub fn weekly() -> &'static str {
215        "0 0 0 * * 0"
216    }
217
218    /// Every month on the 1st at midnight.
219    pub fn monthly() -> &'static str {
220        "0 0 0 1 * *"
221    }
222
223    /// Every year on January 1st at midnight.
224    pub fn yearly() -> &'static str {
225        "0 0 0 1 1 *"
226    }
227
228    /// Weekdays only at 9 AM.
229    pub fn weekdays_at_9am() -> &'static str {
230        "0 0 9 * * 1-5"
231    }
232
233    /// Weekends only at 10 AM.
234    pub fn weekends_at_10am() -> &'static str {
235        "0 0 10 * * 0,6"
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn test_cron_schedule_creation() {
245        let schedule = CronSchedule::new("0 0 0 * * *").expect("Failed to create schedule");
246        assert_eq!(schedule.expression, "0 0 0 * * *");
247        assert_eq!(schedule.timezone, "UTC");
248    }
249
250    #[test]
251    fn test_invalid_cron_expression() {
252        let result = CronSchedule::new("invalid");
253        assert!(result.is_err());
254    }
255
256    #[test]
257    fn test_next_execution() {
258        let schedule = CronSchedule::new("0 0 0 * * *").expect("Failed to create schedule");
259        let now = Utc::now();
260        let next = schedule
261            .next_execution_from(now)
262            .expect("Failed to calculate next execution");
263        assert!(next.is_some());
264    }
265
266    #[test]
267    fn test_cron_patterns() {
268        assert_eq!(CronPatterns::every_minute(), "0 * * * * *");
269        assert_eq!(CronPatterns::daily(), "0 0 0 * * *");
270        assert_eq!(CronPatterns::weekly(), "0 0 0 * * 0");
271    }
272
273    #[test]
274    fn test_validate_expression() {
275        assert!(CronScheduler::validate_expression("0 0 0 * * *").is_ok());
276        assert!(CronScheduler::validate_expression("invalid").is_err());
277    }
278
279    #[test]
280    fn test_executions_in_range() {
281        let schedule = CronSchedule::new("0 0 * * * *").expect("Failed to create schedule");
282        let start = Utc::now();
283        let end = start + chrono::Duration::try_hours(5).expect("Duration overflow");
284
285        let executions = schedule
286            .executions_in_range(start, end, 10)
287            .expect("Failed to get executions");
288
289        assert!(!executions.is_empty());
290        assert!(executions.len() <= 10);
291    }
292}