Skip to main content

oximedia_workflow/
scheduler.rs

1//! Workflow scheduling and triggers.
2
3use crate::error::{Result, WorkflowError};
4use crate::workflow::{Workflow, WorkflowId};
5use chrono::{DateTime, Utc};
6use cron::Schedule;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::PathBuf;
10use std::str::FromStr;
11use std::sync::Arc;
12use tokio::sync::RwLock;
13use tracing::{debug, info};
14
15/// Trigger type for workflow execution.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(tag = "type", rename_all = "snake_case")]
18pub enum Trigger {
19    /// Cron-style schedule.
20    Cron {
21        /// Cron expression.
22        expression: String,
23        /// Timezone (defaults to UTC).
24        #[serde(default)]
25        timezone: String,
26    },
27
28    /// Watch folder for file changes.
29    WatchFolder {
30        /// Folder path to watch.
31        path: PathBuf,
32        /// File pattern to match (glob).
33        #[serde(default)]
34        pattern: String,
35        /// Whether to watch recursively.
36        #[serde(default)]
37        recursive: bool,
38    },
39
40    /// Manual/API trigger.
41    Manual,
42
43    /// Time-based trigger (one-time).
44    Time {
45        /// Scheduled time.
46        at: DateTime<Utc>,
47    },
48
49    /// Event-based trigger.
50    Event {
51        /// Event name/type.
52        event_type: String,
53        /// Event filter conditions.
54        #[serde(default)]
55        conditions: HashMap<String, String>,
56    },
57
58    /// Interval trigger.
59    Interval {
60        /// Interval duration in seconds.
61        seconds: u64,
62    },
63}
64
65/// Scheduled workflow.
66#[derive(Debug, Clone)]
67pub struct ScheduledWorkflow {
68    /// Workflow to execute.
69    pub workflow: Workflow,
70    /// Trigger configuration.
71    pub trigger: Trigger,
72    /// Whether the schedule is enabled.
73    pub enabled: bool,
74    /// Last execution time.
75    pub last_execution: Option<DateTime<Utc>>,
76    /// Next scheduled execution time.
77    pub next_execution: Option<DateTime<Utc>>,
78}
79
80impl ScheduledWorkflow {
81    /// Create a new scheduled workflow.
82    #[must_use]
83    pub fn new(workflow: Workflow, trigger: Trigger) -> Self {
84        let next_execution = Self::calculate_next_execution(&trigger, None);
85        Self {
86            workflow,
87            trigger,
88            enabled: true,
89            last_execution: None,
90            next_execution,
91        }
92    }
93
94    /// Calculate next execution time.
95    fn calculate_next_execution(
96        trigger: &Trigger,
97        after: Option<DateTime<Utc>>,
98    ) -> Option<DateTime<Utc>> {
99        let now = after.unwrap_or_else(Utc::now);
100
101        match trigger {
102            Trigger::Cron { expression, .. } => {
103                if let Ok(schedule) = Schedule::from_str(expression) {
104                    schedule.after(&now).next()
105                } else {
106                    None
107                }
108            }
109            Trigger::Time { at } => {
110                if *at > now {
111                    Some(*at)
112                } else {
113                    None
114                }
115            }
116            Trigger::Interval { seconds } => {
117                Some(now + chrono::Duration::seconds(i64::try_from(*seconds).unwrap_or(60)))
118            }
119            Trigger::Manual | Trigger::WatchFolder { .. } | Trigger::Event { .. } => None,
120        }
121    }
122
123    /// Update next execution time after execution.
124    pub fn update_next_execution(&mut self) {
125        self.last_execution = Some(Utc::now());
126        self.next_execution = Self::calculate_next_execution(&self.trigger, self.last_execution);
127    }
128
129    /// Check if workflow should execute now.
130    #[must_use]
131    pub fn should_execute(&self) -> bool {
132        if !self.enabled {
133            return false;
134        }
135
136        match self.next_execution {
137            Some(next) => Utc::now() >= next,
138            None => false,
139        }
140    }
141}
142
143/// Workflow scheduler.
144pub struct WorkflowScheduler {
145    /// Scheduled workflows.
146    schedules: Arc<RwLock<HashMap<WorkflowId, ScheduledWorkflow>>>,
147    /// Whether scheduler is running.
148    running: Arc<RwLock<bool>>,
149}
150
151impl WorkflowScheduler {
152    /// Create a new scheduler.
153    #[must_use]
154    pub fn new() -> Self {
155        Self {
156            schedules: Arc::new(RwLock::new(HashMap::new())),
157            running: Arc::new(RwLock::new(false)),
158        }
159    }
160
161    /// Add a scheduled workflow.
162    pub async fn add_schedule(&self, workflow: Workflow, trigger: Trigger) -> Result<WorkflowId> {
163        let workflow_id = workflow.id;
164        let scheduled = ScheduledWorkflow::new(workflow, trigger);
165
166        let mut schedules = self.schedules.write().await;
167        schedules.insert(workflow_id, scheduled);
168
169        info!("Added scheduled workflow: {}", workflow_id);
170        Ok(workflow_id)
171    }
172
173    /// Remove a scheduled workflow.
174    pub async fn remove_schedule(&self, workflow_id: WorkflowId) -> Result<()> {
175        let mut schedules = self.schedules.write().await;
176        schedules.remove(&workflow_id);
177        info!("Removed scheduled workflow: {}", workflow_id);
178        Ok(())
179    }
180
181    /// Enable/disable a schedule.
182    pub async fn set_schedule_enabled(&self, workflow_id: WorkflowId, enabled: bool) -> Result<()> {
183        let mut schedules = self.schedules.write().await;
184        if let Some(schedule) = schedules.get_mut(&workflow_id) {
185            schedule.enabled = enabled;
186            debug!("Schedule {} enabled: {}", workflow_id, enabled);
187            Ok(())
188        } else {
189            Err(WorkflowError::WorkflowNotFound(workflow_id.to_string()))
190        }
191    }
192
193    /// Get all scheduled workflows.
194    pub async fn list_schedules(&self) -> Vec<(WorkflowId, ScheduledWorkflow)> {
195        let schedules = self.schedules.read().await;
196        schedules
197            .iter()
198            .map(|(id, sched)| (*id, sched.clone()))
199            .collect()
200    }
201
202    /// Start the scheduler.
203    pub async fn start(&self) -> Result<()> {
204        let mut running = self.running.write().await;
205        if *running {
206            return Err(WorkflowError::AlreadyRunning("Scheduler".to_string()));
207        }
208        *running = true;
209        drop(running);
210
211        info!("Scheduler started");
212        Ok(())
213    }
214
215    /// Stop the scheduler.
216    pub async fn stop(&self) -> Result<()> {
217        let mut running = self.running.write().await;
218        if !*running {
219            return Err(WorkflowError::NotRunning("Scheduler".to_string()));
220        }
221        *running = false;
222
223        info!("Scheduler stopped");
224        Ok(())
225    }
226
227    /// Check for workflows ready to execute.
228    pub async fn check_schedules(&self) -> Vec<Workflow> {
229        let running = self.running.read().await;
230        if !*running {
231            return Vec::new();
232        }
233        drop(running);
234
235        let mut schedules = self.schedules.write().await;
236        let mut ready_workflows = Vec::new();
237
238        for (_, schedule) in schedules.iter_mut() {
239            if schedule.should_execute() {
240                ready_workflows.push(schedule.workflow.clone());
241                schedule.update_next_execution();
242                debug!(
243                    "Workflow {} ready for execution. Next: {:?}",
244                    schedule.workflow.id, schedule.next_execution
245                );
246            }
247        }
248
249        ready_workflows
250    }
251
252    /// Get next execution time for a workflow.
253    pub async fn get_next_execution(&self, workflow_id: WorkflowId) -> Option<DateTime<Utc>> {
254        let schedules = self.schedules.read().await;
255        schedules.get(&workflow_id).and_then(|s| s.next_execution)
256    }
257}
258
259impl Default for WorkflowScheduler {
260    fn default() -> Self {
261        Self::new()
262    }
263}
264
265/// File watcher for watch folder triggers.
266pub struct FileWatcher {
267    /// Watched paths and their workflow IDs.
268    watches: Arc<RwLock<HashMap<PathBuf, Vec<WorkflowId>>>>,
269}
270
271impl FileWatcher {
272    /// Create a new file watcher.
273    #[must_use]
274    pub fn new() -> Self {
275        Self {
276            watches: Arc::new(RwLock::new(HashMap::new())),
277        }
278    }
279
280    /// Add a watch for a path.
281    pub async fn add_watch(&self, path: PathBuf, workflow_id: WorkflowId) -> Result<()> {
282        let mut watches = self.watches.write().await;
283        watches.entry(path.clone()).or_default().push(workflow_id);
284        info!("Added file watch: {:?} -> {}", path, workflow_id);
285        Ok(())
286    }
287
288    /// Remove a watch.
289    pub async fn remove_watch(&self, path: &PathBuf, workflow_id: WorkflowId) -> Result<()> {
290        let mut watches = self.watches.write().await;
291        if let Some(workflows) = watches.get_mut(path) {
292            workflows.retain(|id| *id != workflow_id);
293            if workflows.is_empty() {
294                watches.remove(path);
295            }
296        }
297        info!("Removed file watch: {:?} -> {}", path, workflow_id);
298        Ok(())
299    }
300
301    /// Get workflows for a path.
302    pub async fn get_workflows_for_path(&self, path: &PathBuf) -> Vec<WorkflowId> {
303        let watches = self.watches.read().await;
304        watches.get(path).cloned().unwrap_or_default()
305    }
306}
307
308impl Default for FileWatcher {
309    fn default() -> Self {
310        Self::new()
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn test_cron_trigger_parsing() {
320        let trigger = Trigger::Cron {
321            expression: "0 0 * * * *".to_string(), // Every hour
322            timezone: "UTC".to_string(),
323        };
324
325        let next = ScheduledWorkflow::calculate_next_execution(&trigger, None);
326        assert!(next.is_some());
327    }
328
329    #[test]
330    fn test_interval_trigger() {
331        let trigger = Trigger::Interval { seconds: 60 };
332        let next = ScheduledWorkflow::calculate_next_execution(&trigger, None);
333        assert!(next.is_some());
334    }
335
336    #[test]
337    fn test_time_trigger_future() {
338        let future_time = Utc::now() + chrono::Duration::hours(1);
339        let trigger = Trigger::Time { at: future_time };
340        let next = ScheduledWorkflow::calculate_next_execution(&trigger, None);
341        assert_eq!(next, Some(future_time));
342    }
343
344    #[test]
345    fn test_time_trigger_past() {
346        let past_time = Utc::now() - chrono::Duration::hours(1);
347        let trigger = Trigger::Time { at: past_time };
348        let next = ScheduledWorkflow::calculate_next_execution(&trigger, None);
349        assert!(next.is_none());
350    }
351
352    #[test]
353    fn test_scheduled_workflow_creation() {
354        let workflow = Workflow::new("test");
355        let trigger = Trigger::Manual;
356        let scheduled = ScheduledWorkflow::new(workflow, trigger);
357
358        assert!(scheduled.enabled);
359        assert!(scheduled.last_execution.is_none());
360    }
361
362    #[tokio::test]
363    async fn test_scheduler_creation() {
364        let scheduler = WorkflowScheduler::new();
365        assert!(!*scheduler.running.read().await);
366    }
367
368    #[tokio::test]
369    async fn test_add_schedule() {
370        let scheduler = WorkflowScheduler::new();
371        let workflow = Workflow::new("test-workflow");
372        let trigger = Trigger::Manual;
373
374        let workflow_id = scheduler
375            .add_schedule(workflow, trigger)
376            .await
377            .expect("should succeed in test");
378        let schedules = scheduler.list_schedules().await;
379
380        assert_eq!(schedules.len(), 1);
381        assert_eq!(schedules[0].0, workflow_id);
382    }
383
384    #[tokio::test]
385    async fn test_remove_schedule() {
386        let scheduler = WorkflowScheduler::new();
387        let workflow = Workflow::new("test-workflow");
388        let trigger = Trigger::Manual;
389
390        let workflow_id = scheduler
391            .add_schedule(workflow, trigger)
392            .await
393            .expect("should succeed in test");
394        scheduler
395            .remove_schedule(workflow_id)
396            .await
397            .expect("should succeed in test");
398
399        let schedules = scheduler.list_schedules().await;
400        assert_eq!(schedules.len(), 0);
401    }
402
403    #[tokio::test]
404    async fn test_enable_disable_schedule() {
405        let scheduler = WorkflowScheduler::new();
406        let workflow = Workflow::new("test-workflow");
407        let trigger = Trigger::Manual;
408
409        let workflow_id = scheduler
410            .add_schedule(workflow, trigger)
411            .await
412            .expect("should succeed in test");
413
414        scheduler
415            .set_schedule_enabled(workflow_id, false)
416            .await
417            .expect("should succeed in test");
418        let schedules = scheduler.list_schedules().await;
419        assert!(!schedules[0].1.enabled);
420
421        scheduler
422            .set_schedule_enabled(workflow_id, true)
423            .await
424            .expect("should succeed in test");
425        let schedules = scheduler.list_schedules().await;
426        assert!(schedules[0].1.enabled);
427    }
428
429    #[tokio::test]
430    async fn test_scheduler_start_stop() {
431        let scheduler = WorkflowScheduler::new();
432
433        scheduler.start().await.expect("should succeed in test");
434        assert!(*scheduler.running.read().await);
435
436        scheduler.stop().await.expect("should succeed in test");
437        assert!(!*scheduler.running.read().await);
438    }
439
440    #[tokio::test]
441    async fn test_file_watcher() {
442        let watcher = FileWatcher::new();
443        let path = PathBuf::from("/tmp/test");
444        let workflow_id = WorkflowId::new();
445
446        watcher
447            .add_watch(path.clone(), workflow_id)
448            .await
449            .expect("should succeed in test");
450        let workflows = watcher.get_workflows_for_path(&path).await;
451        assert_eq!(workflows.len(), 1);
452        assert_eq!(workflows[0], workflow_id);
453
454        watcher
455            .remove_watch(&path, workflow_id)
456            .await
457            .expect("should succeed in test");
458        let workflows = watcher.get_workflows_for_path(&path).await;
459        assert_eq!(workflows.len(), 0);
460    }
461}