1use anyhow::Result;
2use clap::Subcommand;
3use serde_json::Value;
4use tracing::info;
5
6use crate::config::Config;
7use crate::utils::database::DatabasePool;
8use crate::utils::db_helpers::*;
9
10#[derive(Subcommand)]
11pub enum CronCommand {
12 #[command(about = "List scheduled/recurring jobs")]
13 List {
14 #[arg(short = 'u', long, help = "Database connection URL")]
15 database_url: Option<String>,
16 #[arg(short = 'n', long, help = "Queue name filter")]
17 queue: Option<String>,
18 #[arg(long, help = "Show only active cron jobs")]
19 active_only: bool,
20 #[arg(long, help = "Show detailed schedule information")]
21 detailed: bool,
22 },
23 #[command(about = "Create a new cron-scheduled job")]
24 Create {
25 #[arg(short = 'u', long, help = "Database connection URL")]
26 database_url: Option<String>,
27 #[arg(short = 'n', long, help = "Queue name")]
28 queue: String,
29 #[arg(short = 'j', long, help = "Job payload as JSON")]
30 payload: String,
31 #[arg(short = 's', long, help = "Cron schedule expression")]
32 schedule: String,
33 #[arg(short = 'z', long, help = "Timezone (e.g., UTC, America/New_York)")]
34 timezone: Option<String>,
35 #[arg(short = 'r', long, help = "Job priority")]
36 priority: Option<String>,
37 #[arg(long, help = "Job description")]
38 description: Option<String>,
39 },
40 #[command(about = "Enable cron job execution")]
41 Enable {
42 #[arg(short = 'u', long, help = "Database connection URL")]
43 database_url: Option<String>,
44 #[arg(help = "Job ID")]
45 job_id: String,
46 },
47 #[command(about = "Disable cron job execution")]
48 Disable {
49 #[arg(short = 'u', long, help = "Database connection URL")]
50 database_url: Option<String>,
51 #[arg(help = "Job ID")]
52 job_id: String,
53 },
54 #[command(about = "Show next execution times for cron jobs")]
55 Next {
56 #[arg(short = 'u', long, help = "Database connection URL")]
57 database_url: Option<String>,
58 #[arg(short = 'n', long, help = "Queue name filter")]
59 queue: Option<String>,
60 #[arg(
61 short = 'c',
62 long,
63 default_value = "10",
64 help = "Number of upcoming executions to show"
65 )]
66 count: u32,
67 #[arg(long, help = "Show next N hours of executions")]
68 hours: Option<u32>,
69 },
70 #[command(about = "Update cron job schedule")]
71 Update {
72 #[arg(short = 'u', long, help = "Database connection URL")]
73 database_url: Option<String>,
74 #[arg(help = "Job ID")]
75 job_id: String,
76 #[arg(short = 's', long, help = "New cron schedule expression")]
77 schedule: Option<String>,
78 #[arg(short = 'z', long, help = "New timezone")]
79 timezone: Option<String>,
80 #[arg(short = 'r', long, help = "New priority")]
81 priority: Option<String>,
82 },
83 #[command(about = "Delete a cron job")]
84 Delete {
85 #[arg(short = 'u', long, help = "Database connection URL")]
86 database_url: Option<String>,
87 #[arg(help = "Job ID")]
88 job_id: String,
89 #[arg(long, help = "Confirm the deletion")]
90 confirm: bool,
91 },
92}
93
94impl CronCommand {
95 pub async fn execute(&self, config: &Config) -> Result<()> {
96 let db_url = self.get_database_url(config)?;
97 let pool = DatabasePool::connect(&db_url, config.get_connection_pool_size()).await?;
98
99 match self {
100 CronCommand::List {
101 queue,
102 active_only,
103 detailed,
104 ..
105 } => {
106 list_cron_jobs(pool, queue.clone(), *active_only, *detailed).await?;
107 }
108 CronCommand::Create {
109 queue,
110 payload,
111 schedule,
112 timezone,
113 priority,
114 description,
115 ..
116 } => {
117 create_cron_job(
118 &pool,
119 queue,
120 payload,
121 schedule,
122 timezone.clone(),
123 priority.clone(),
124 description.clone(),
125 )
126 .await?;
127 }
128 CronCommand::Enable { job_id, .. } => {
129 toggle_cron_job(&pool, job_id, true).await?;
130 }
131 CronCommand::Disable { job_id, .. } => {
132 toggle_cron_job(&pool, job_id, false).await?;
133 }
134 CronCommand::Next {
135 queue,
136 count,
137 hours,
138 ..
139 } => {
140 show_next_executions(pool, queue.clone(), *count, *hours).await?;
141 }
142 CronCommand::Update {
143 job_id,
144 schedule,
145 timezone,
146 priority,
147 ..
148 } => {
149 update_cron_job(
150 pool,
151 job_id,
152 schedule.clone(),
153 timezone.clone(),
154 priority.clone(),
155 )
156 .await?;
157 }
158 CronCommand::Delete {
159 job_id, confirm, ..
160 } => {
161 delete_cron_job(&pool, job_id, *confirm).await?;
162 }
163 }
164 Ok(())
165 }
166
167 fn get_database_url(&self, config: &Config) -> Result<String> {
168 let url = match self {
169 CronCommand::List { database_url, .. } => database_url,
170 CronCommand::Create { database_url, .. } => database_url,
171 CronCommand::Enable { database_url, .. } => database_url,
172 CronCommand::Disable { database_url, .. } => database_url,
173 CronCommand::Next { database_url, .. } => database_url,
174 CronCommand::Update { database_url, .. } => database_url,
175 CronCommand::Delete { database_url, .. } => database_url,
176 };
177
178 url.as_ref()
179 .map(|s| s.as_str())
180 .or(config.get_database_url())
181 .ok_or_else(|| anyhow::anyhow!("Database URL is required"))
182 .map(|s| s.to_string())
183 }
184}
185
186async fn list_cron_jobs(
187 pool: DatabasePool,
188 queue: Option<String>,
189 active_only: bool,
190 detailed: bool,
191) -> Result<()> {
192 println!("📅 Cron Jobs");
193 println!("═══════════");
194
195 let mut query =
197 "SELECT COUNT(*) as count FROM hammerwork_jobs WHERE cron_schedule IS NOT NULL".to_string();
198
199 if let Some(queue_name) = &queue {
200 query = format!("{} AND queue_name = '{}'", query, queue_name);
201 }
202
203 if active_only {
204 query = format!("{} AND recurring = true", query);
205 }
206
207 let count = execute_count_query(&pool, &query).await?;
208
209 if count == 0 {
210 println!("📅 No cron jobs found");
211 if let Some(q) = queue {
212 println!(" Queue filter: {}", q);
213 }
214 if active_only {
215 println!(" Filter: Active only");
216 }
217 } else {
218 println!("Found {} cron jobs", count);
219 println!("💡 Detailed cron job listing coming soon!");
220 if detailed {
221 println!(" Note: Detailed view requested");
222 }
223 }
224
225 Ok(())
226}
227
228async fn create_cron_job(
229 pool: &DatabasePool,
230 queue: &str,
231 payload: &str,
232 schedule: &str,
233 timezone: Option<String>,
234 priority: Option<String>,
235 description: Option<String>,
236) -> Result<()> {
237 if !is_valid_cron_expression(schedule) {
239 return Err(anyhow::anyhow!("Invalid cron expression: {}", schedule));
240 }
241
242 let payload_json: Value = serde_json::from_str(payload)?;
244
245 let next_run_at = chrono::Utc::now() + chrono::Duration::minutes(1); let job_id = uuid::Uuid::new_v4().to_string();
249 let priority_val = priority.unwrap_or_else(|| "normal".to_string());
250 let timezone_val = timezone.unwrap_or_else(|| "UTC".to_string());
251
252 info!("Creating cron job with schedule: {}", schedule);
253
254 match &pool {
255 DatabasePool::Postgres(pg_pool) => {
256 sqlx::query(
257 r#"
258 INSERT INTO hammerwork_jobs (
259 id, queue_name, payload, status, priority, attempts, max_attempts,
260 created_at, scheduled_at, cron_schedule, next_run_at, recurring, timezone
261 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
262 "#,
263 )
264 .bind(&job_id)
265 .bind(queue)
266 .bind(&payload_json)
267 .bind("Pending")
268 .bind(&priority_val)
269 .bind(0)
270 .bind(3)
271 .bind(chrono::Utc::now())
272 .bind(next_run_at)
273 .bind(schedule)
274 .bind(next_run_at)
275 .bind(true)
276 .bind(&timezone_val)
277 .execute(pg_pool)
278 .await?;
279 }
280 DatabasePool::MySQL(mysql_pool) => {
281 sqlx::query(
282 r#"
283 INSERT INTO hammerwork_jobs (
284 id, queue_name, payload, status, priority, attempts, max_attempts,
285 created_at, scheduled_at, cron_schedule, next_run_at, recurring, timezone
286 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
287 "#,
288 )
289 .bind(&job_id)
290 .bind(queue)
291 .bind(&payload_json)
292 .bind("Pending")
293 .bind(&priority_val)
294 .bind(0)
295 .bind(3)
296 .bind(chrono::Utc::now())
297 .bind(next_run_at)
298 .bind(schedule)
299 .bind(next_run_at)
300 .bind(true)
301 .bind(&timezone_val)
302 .execute(mysql_pool)
303 .await?;
304 }
305 }
306
307 println!("✅ Cron job created successfully");
308 println!(" Job ID: {}", job_id);
309 println!(" Queue: {}", queue);
310 println!(" Schedule: {}", schedule);
311 println!(" Priority: {}", priority_val);
312 println!(" Timezone: {}", timezone_val);
313 if let Some(desc) = description {
314 println!(" Description: {}", desc);
315 }
316
317 info!("Created cron job: {}", job_id);
318 Ok(())
319}
320
321async fn toggle_cron_job(pool: &DatabasePool, job_id: &str, enable: bool) -> Result<()> {
322 let status = if enable { "enabled" } else { "disabled" };
323
324 let updated = match pool {
325 DatabasePool::Postgres(pg_pool) => {
326 let result = sqlx::query("UPDATE hammerwork_jobs SET recurring = $1 WHERE id = $2 AND cron_schedule IS NOT NULL")
327 .bind(enable)
328 .bind(job_id)
329 .execute(pg_pool)
330 .await?;
331 result.rows_affected()
332 }
333 DatabasePool::MySQL(mysql_pool) => {
334 let result = sqlx::query("UPDATE hammerwork_jobs SET recurring = ? WHERE id = ? AND cron_schedule IS NOT NULL")
335 .bind(enable)
336 .bind(job_id)
337 .execute(mysql_pool)
338 .await?;
339 result.rows_affected()
340 }
341 };
342
343 if updated == 0 {
344 return Err(anyhow::anyhow!("Cron job not found: {}", job_id));
345 }
346
347 println!("✅ Cron job {} successfully", status);
348 println!(" Job ID: {}", job_id);
349
350 info!("Cron job {} {}", job_id, status);
351 Ok(())
352}
353
354async fn show_next_executions(
355 pool: DatabasePool,
356 queue: Option<String>,
357 count: u32,
358 hours: Option<u32>,
359) -> Result<()> {
360 println!("⏰ Upcoming Cron Job Executions");
361 println!("═══════════════════════════════");
362
363 let mut query = "SELECT COUNT(*) as count FROM hammerwork_jobs WHERE cron_schedule IS NOT NULL AND recurring = true".to_string();
365
366 if let Some(queue_name) = &queue {
367 query = format!("{} AND queue_name = '{}'", query, queue_name);
368 }
369
370 if let Some(hour_limit) = hours {
371 let cutoff = chrono::Utc::now() + chrono::Duration::hours(hour_limit as i64);
372 query = format!(
373 "{} AND next_run_at <= '{}'",
374 query,
375 cutoff.format("%Y-%m-%d %H:%M:%S")
376 );
377 }
378
379 let total_jobs = execute_count_query(&pool, &query).await?;
380
381 if total_jobs == 0 {
382 println!("📅 No upcoming cron job executions found");
383 } else {
384 println!("Found {} active cron jobs", total_jobs);
385 println!("💡 Detailed execution schedule display coming soon!");
386 println!(" Requested count: {}", count);
387 if let Some(h) = hours {
388 println!(" Time window: {} hours", h);
389 }
390 }
391
392 Ok(())
393}
394
395async fn update_cron_job(
396 pool: DatabasePool,
397 job_id: &str,
398 schedule: Option<String>,
399 timezone: Option<String>,
400 priority: Option<String>,
401) -> Result<()> {
402 let mut updates = Vec::new();
404
405 if let Some(new_schedule) = &schedule {
406 if !is_valid_cron_expression(new_schedule) {
407 return Err(anyhow::anyhow!("Invalid cron expression: {}", new_schedule));
408 }
409 updates.push("cron_schedule".to_string());
410 }
411
412 if timezone.is_some() {
413 updates.push("timezone".to_string());
414 }
415
416 if priority.is_some() {
417 updates.push("priority".to_string());
418 }
419
420 if updates.is_empty() {
421 return Err(anyhow::anyhow!("No updates specified"));
422 }
423
424 let exists_query =
426 "SELECT COUNT(*) as count FROM hammerwork_jobs WHERE id = ? AND cron_schedule IS NOT NULL";
427 let exists = execute_count_query_with_param(&pool, exists_query, job_id).await? > 0;
428
429 if !exists {
430 return Err(anyhow::anyhow!("Cron job not found: {}", job_id));
431 }
432
433 println!("✅ Cron job update would be applied successfully");
434 println!(" Job ID: {}", job_id);
435 if let Some(s) = schedule {
436 println!(" New Schedule: {}", s);
437 }
438 if let Some(tz) = timezone {
439 println!(" New Timezone: {}", tz);
440 }
441 if let Some(p) = priority {
442 println!(" New Priority: {}", p);
443 }
444 println!("💡 Actual cron job update implementation coming soon!");
445
446 info!("Cron job update requested: {}", job_id);
447 Ok(())
448}
449
450async fn delete_cron_job(pool: &DatabasePool, job_id: &str, confirm: bool) -> Result<()> {
451 if !confirm {
452 println!("⚠️ This will permanently delete the cron job. Use --confirm to proceed.");
453 return Ok(());
454 }
455
456 let deleted = match &pool {
457 DatabasePool::Postgres(pg_pool) => {
458 let result = sqlx::query(
459 "DELETE FROM hammerwork_jobs WHERE id = $1 AND cron_schedule IS NOT NULL",
460 )
461 .bind(job_id)
462 .execute(pg_pool)
463 .await?;
464 result.rows_affected()
465 }
466 DatabasePool::MySQL(mysql_pool) => {
467 let result = sqlx::query(
468 "DELETE FROM hammerwork_jobs WHERE id = ? AND cron_schedule IS NOT NULL",
469 )
470 .bind(job_id)
471 .execute(mysql_pool)
472 .await?;
473 result.rows_affected()
474 }
475 };
476
477 if deleted == 0 {
478 return Err(anyhow::anyhow!("Cron job not found: {}", job_id));
479 }
480
481 println!("✅ Cron job deleted successfully");
482 println!(" Job ID: {}", job_id);
483
484 info!("Deleted cron job: {}", job_id);
485 Ok(())
486}
487
488fn is_valid_cron_expression(expr: &str) -> bool {
489 let parts: Vec<&str> = expr.split_whitespace().collect();
491 parts.len() >= 5 && parts.len() <= 6
492}