Skip to main content

apollo/
scheduler.rs

1//! Cron scheduler — recurring task automation
2//! Phase 4 feature: Time-based task scheduling
3
4use serde::{Deserialize, Serialize};
5use std::sync::Arc;
6use tokio::sync::RwLock;
7
8/// Scheduled task
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Schedule {
11    pub id: String,
12    pub cron_expression: String,
13    pub task_goal: String,
14    pub priority: u8,
15    pub enabled: bool,
16    pub last_run: Option<chrono::DateTime<chrono::Utc>>,
17    pub next_run: Option<chrono::DateTime<chrono::Utc>>,
18    pub created_at: chrono::DateTime<chrono::Utc>,
19}
20
21/// Scheduler instance
22pub struct Scheduler {
23    schedules: Arc<RwLock<Vec<Schedule>>>,
24}
25
26impl Scheduler {
27    pub fn new() -> Self {
28        Self {
29            schedules: Arc::new(RwLock::new(Vec::new())),
30        }
31    }
32
33    /// Add a new schedule
34    pub async fn schedule(&self, cron: &str, goal: &str, priority: u8) -> anyhow::Result<String> {
35        // Validate cron expression
36        let _parsed = cron::Schedule::from_str(cron)
37            .map_err(|e| anyhow::anyhow!("Invalid cron expression: {}", e))?;
38
39        let schedule = Schedule {
40            id: uuid::Uuid::new_v4().to_string(),
41            cron_expression: cron.to_string(),
42            task_goal: goal.to_string(),
43            priority,
44            enabled: true,
45            last_run: None,
46            next_run: None,
47            created_at: chrono::Utc::now(),
48        };
49
50        let schedule_id = schedule.id.clone();
51        self.schedules.write().await.push(schedule);
52        Ok(schedule_id)
53    }
54
55    /// List all schedules
56    pub async fn list(&self) -> Vec<Schedule> {
57        self.schedules.read().await.clone()
58    }
59
60    /// Enable a schedule
61    pub async fn enable(&self, schedule_id: &str) -> anyhow::Result<()> {
62        let mut schedules = self.schedules.write().await;
63        if let Some(sched) = schedules.iter_mut().find(|s| s.id == schedule_id) {
64            sched.enabled = true;
65            Ok(())
66        } else {
67            Err(anyhow::anyhow!("Schedule not found"))
68        }
69    }
70
71    /// Disable a schedule
72    pub async fn disable(&self, schedule_id: &str) -> anyhow::Result<()> {
73        let mut schedules = self.schedules.write().await;
74        if let Some(sched) = schedules.iter_mut().find(|s| s.id == schedule_id) {
75            sched.enabled = false;
76            Ok(())
77        } else {
78            Err(anyhow::anyhow!("Schedule not found"))
79        }
80    }
81
82    /// Delete a schedule
83    pub async fn delete(&self, schedule_id: &str) -> anyhow::Result<()> {
84        let mut schedules = self.schedules.write().await;
85        if let Some(pos) = schedules.iter().position(|s| s.id == schedule_id) {
86            schedules.remove(pos);
87            Ok(())
88        } else {
89            Err(anyhow::anyhow!("Schedule not found"))
90        }
91    }
92
93    /// Get next tasks to run
94    pub async fn next_tasks(&self) -> Vec<Schedule> {
95        let now = chrono::Utc::now();
96        let schedules = self.schedules.read().await;
97
98        schedules
99            .iter()
100            .filter_map(|sched| {
101                if !sched.enabled {
102                    return None;
103                }
104
105                if let Ok(schedule) = cron::Schedule::from_str(&sched.cron_expression) {
106                    // Use the public iterator interface instead of next_after
107                    let mut iter = schedule.after(&now);
108
109                    if let Some(next_time) = iter.next() {
110                        // Task is due if next time is now or in the past
111                        if next_time <= now {
112                            return Some(sched.clone());
113                        }
114                    }
115                }
116
117                None
118            })
119            .collect()
120    }
121}
122
123impl Default for Scheduler {
124    fn default() -> Self {
125        Self::new()
126    }
127}
128
129use std::str::FromStr;
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[tokio::test]
136    async fn test_schedule_creation() {
137        let scheduler = Scheduler::new();
138
139        let id = scheduler
140            .schedule("0 0 9 * * MON *", "Monday digest", 7)
141            .await
142            .unwrap();
143        assert!(!id.is_empty());
144
145        let schedules = scheduler.list().await;
146        assert_eq!(schedules.len(), 1);
147    }
148
149    #[tokio::test]
150    async fn test_invalid_cron() {
151        let scheduler = Scheduler::new();
152
153        let result = scheduler.schedule("invalid", "test", 5).await;
154        assert!(result.is_err());
155    }
156
157    #[tokio::test]
158    async fn test_enable_disable() {
159        let scheduler = Scheduler::new();
160
161        let id = scheduler
162            .schedule("0 0 9 * * * *", "daily", 5)
163            .await
164            .unwrap();
165
166        scheduler.disable(&id).await.unwrap();
167        let schedules = scheduler.list().await;
168        assert!(!schedules[0].enabled);
169
170        scheduler.enable(&id).await.unwrap();
171        let schedules = scheduler.list().await;
172        assert!(schedules[0].enabled);
173    }
174}