1use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7use crate::WorkflowId;
8
9pub type ScheduleId = Uuid;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
15pub struct Schedule {
16 #[cfg_attr(feature = "openapi", schema(value_type = String))]
18 pub id: ScheduleId,
19
20 #[cfg_attr(feature = "openapi", schema(value_type = String))]
22 pub workflow_id: WorkflowId,
23
24 pub name: String,
26
27 pub description: Option<String>,
29
30 pub cron: String,
32
33 pub timezone: String,
35
36 pub enabled: bool,
38
39 pub input_variables: std::collections::HashMap<String, serde_json::Value>,
41
42 pub created_at: DateTime<Utc>,
44
45 pub updated_at: DateTime<Utc>,
47
48 pub last_run: Option<DateTime<Utc>>,
50
51 pub next_run: Option<DateTime<Utc>>,
53
54 pub run_count: u64,
56
57 pub max_runs: Option<u64>,
59
60 pub expires_at: Option<DateTime<Utc>>,
62}
63
64impl Schedule {
65 pub fn new(workflow_id: WorkflowId, name: String, cron: String) -> Self {
67 let now = Utc::now();
68 Self {
69 id: Uuid::new_v4(),
70 workflow_id,
71 name,
72 description: None,
73 cron,
74 timezone: "UTC".to_string(),
75 enabled: true,
76 input_variables: std::collections::HashMap::new(),
77 created_at: now,
78 updated_at: now,
79 last_run: None,
80 next_run: None,
81 run_count: 0,
82 max_runs: None,
83 expires_at: None,
84 }
85 }
86
87 pub fn should_run(&self) -> bool {
89 if !self.enabled {
90 return false;
91 }
92
93 if let Some(expires_at) = self.expires_at {
95 if Utc::now() > expires_at {
96 return false;
97 }
98 }
99
100 if let Some(max_runs) = self.max_runs {
102 if self.run_count >= max_runs {
103 return false;
104 }
105 }
106
107 if let Some(next_run) = self.next_run {
109 return Utc::now() >= next_run;
110 }
111
112 false
113 }
114
115 pub fn mark_executed(&mut self) {
117 self.last_run = Some(Utc::now());
118 self.run_count += 1;
119 }
120
121 pub fn validate(&self) -> Result<(), String> {
123 let parts: Vec<&str> = self.cron.split_whitespace().collect();
125 if parts.len() != 5 && parts.len() != 6 {
126 return Err(format!(
127 "Invalid cron expression '{}': must have 5 or 6 parts",
128 self.cron
129 ));
130 }
131
132 if self.timezone.is_empty() {
134 return Err("Timezone cannot be empty".to_string());
135 }
136
137 Ok(())
138 }
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
143#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
144pub struct ScheduleExecution {
145 #[cfg_attr(feature = "openapi", schema(value_type = String))]
147 pub id: Uuid,
148
149 #[cfg_attr(feature = "openapi", schema(value_type = String))]
151 pub schedule_id: ScheduleId,
152
153 #[cfg_attr(feature = "openapi", schema(value_type = String))]
155 pub execution_id: Uuid,
156
157 pub triggered_at: DateTime<Utc>,
159
160 pub success: bool,
162
163 pub error: Option<String>,
165
166 pub duration_ms: Option<u64>,
168}
169
170impl ScheduleExecution {
171 pub fn new(schedule_id: ScheduleId, execution_id: Uuid) -> Self {
173 Self {
174 id: Uuid::new_v4(),
175 schedule_id,
176 execution_id,
177 triggered_at: Utc::now(),
178 success: false,
179 error: None,
180 duration_ms: None,
181 }
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 #[test]
190 fn test_schedule_creation() {
191 let workflow_id = Uuid::new_v4();
192 let schedule = Schedule::new(
193 workflow_id,
194 "Daily Report".to_string(),
195 "0 0 * * *".to_string(),
196 );
197
198 assert_eq!(schedule.workflow_id, workflow_id);
199 assert_eq!(schedule.name, "Daily Report");
200 assert_eq!(schedule.cron, "0 0 * * *");
201 assert!(schedule.enabled);
202 assert_eq!(schedule.run_count, 0);
203 }
204
205 #[test]
206 fn test_schedule_validation() {
207 let mut schedule =
208 Schedule::new(Uuid::new_v4(), "Test".to_string(), "0 0 * * *".to_string());
209
210 assert!(schedule.validate().is_ok());
211
212 schedule.cron = "0 0 *".to_string();
214 assert!(schedule.validate().is_err());
215
216 schedule.cron = "0 0 0 * * *".to_string();
218 assert!(schedule.validate().is_ok());
219 }
220
221 #[test]
222 fn test_should_run() {
223 let mut schedule =
224 Schedule::new(Uuid::new_v4(), "Test".to_string(), "0 0 * * *".to_string());
225
226 schedule.enabled = false;
228 assert!(!schedule.should_run());
229
230 schedule.enabled = true;
232 assert!(!schedule.should_run());
233
234 schedule.max_runs = Some(5);
236 schedule.run_count = 5;
237 schedule.next_run = Some(Utc::now());
238 assert!(!schedule.should_run());
239
240 schedule.max_runs = None;
242 schedule.run_count = 0;
243 schedule.expires_at = Some(Utc::now() - chrono::Duration::hours(1));
244 assert!(!schedule.should_run());
245 }
246
247 #[test]
248 fn test_mark_executed() {
249 let mut schedule =
250 Schedule::new(Uuid::new_v4(), "Test".to_string(), "0 0 * * *".to_string());
251
252 assert_eq!(schedule.run_count, 0);
253 assert!(schedule.last_run.is_none());
254
255 schedule.mark_executed();
256
257 assert_eq!(schedule.run_count, 1);
258 assert!(schedule.last_run.is_some());
259 }
260}