Skip to main content

cargo_hammerwork/commands/
cron.rs

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    // Simplified implementation - cron functionality coming soon
196    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    // Validate cron expression (basic validation)
238    if !is_valid_cron_expression(schedule) {
239        return Err(anyhow::anyhow!("Invalid cron expression: {}", schedule));
240    }
241
242    // Parse payload JSON
243    let payload_json: Value = serde_json::from_str(payload)?;
244
245    // Calculate next run time (simplified - in production you'd use a cron library)
246    let next_run_at = chrono::Utc::now() + chrono::Duration::minutes(1); // Placeholder
247
248    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    // Simplified implementation
364    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    // Build update query dynamically
403    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    // Simplified update - just check if job exists
425    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    // Basic validation - should have 5 or 6 parts separated by spaces
490    let parts: Vec<&str> = expr.split_whitespace().collect();
491    parts.len() >= 5 && parts.len() <= 6
492}