1use std::path::PathBuf;
37use std::sync::Arc;
38
39use clap::Subcommand;
40use serde::{Deserialize, Serialize};
41
42use sz_rust_core::orm::scheduler::Scheduler;
43
44use crate::error::CliError;
45
46const DEFAULT_CONFIG_PATH: &str = "scheduler.toml";
48
49#[derive(Subcommand, Debug)]
51pub enum SchedulerCommand {
52 #[command(name = "list")]
54 List {
55 #[arg(short = 'c', long, default_value = DEFAULT_CONFIG_PATH)]
57 config: PathBuf,
58 },
59
60 #[command(name = "run")]
62 Run {
63 #[arg(short = 'c', long, default_value = DEFAULT_CONFIG_PATH)]
65 config: PathBuf,
66 },
67
68 #[command(name = "start")]
70 Start {
71 #[arg(short = 't', long, default_value = "1000")]
73 tick_ms: u64,
74
75 #[arg(short = 'c', long, default_value = DEFAULT_CONFIG_PATH)]
77 config: PathBuf,
78 },
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
83pub struct TaskConfig {
84 pub id: String,
86 pub name: String,
88 pub cron: String,
90 #[serde(default)]
92 pub callback: String,
93 #[serde(default = "default_enabled")]
95 pub enabled: bool,
96}
97
98fn default_enabled() -> bool {
99 true
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
104pub struct SchedulerConfig {
105 #[serde(default)]
107 pub tasks: Vec<TaskConfig>,
108}
109
110impl SchedulerConfig {
111 pub fn from_toml_str(s: &str) -> Result<Self, CliError> {
118 let config: SchedulerConfig = toml::from_str(s)
119 .map_err(|e| CliError::Scheduler(format!("config parse error: {e}")))?;
120 config.validate()?;
121 Ok(config)
122 }
123
124 pub fn from_file(path: &std::path::Path) -> Result<Self, CliError> {
132 let content = std::fs::read_to_string(path)?;
133 Self::from_toml_str(&content)
134 }
135
136 fn validate(&self) -> Result<(), CliError> {
138 let mut seen = std::collections::HashSet::new();
139 for task in &self.tasks {
140 if !seen.insert(&task.id) {
141 return Err(CliError::Scheduler(format!(
142 "duplicate task id: {}",
143 task.id
144 )));
145 }
146 }
147 Ok(())
148 }
149
150 pub fn register_to(
154 &self,
155 scheduler: &sz_rust_core::orm::scheduler::CronScheduler,
156 ) -> Result<usize, CliError> {
157 let handler: Arc<dyn sz_rust_core::orm::scheduler::JobHandler> = Arc::new(PrintJobHandler);
158 let mut count = 0;
159 for task in &self.tasks {
160 let scheduled =
161 sz_rust_core::orm::scheduler::ScheduledTask::new(&task.id, &task.name, &task.cron)
162 .with_callback(&task.callback);
163 let scheduled = if task.enabled {
164 scheduled
165 } else {
166 scheduled.disable()
167 };
168 scheduler.schedule(scheduled).map_err(|e| {
169 CliError::Scheduler(format!("schedule task {} failed: {e}", task.id))
170 })?;
171 scheduler.register_handler(&task.id, handler.clone());
172 count += 1;
173 }
174 Ok(count)
175 }
176}
177
178pub fn execute(cmd: &SchedulerCommand) -> Result<(), CliError> {
180 match cmd {
181 SchedulerCommand::List { config } => execute_list(config),
182 SchedulerCommand::Run { config } => execute_run(config),
183 SchedulerCommand::Start { tick_ms, config } => execute_start(*tick_ms, config),
184 }
185}
186
187fn load_config_or_empty(path: &std::path::Path) -> Result<SchedulerConfig, CliError> {
189 if !path.exists() {
190 return Ok(SchedulerConfig::default());
191 }
192 SchedulerConfig::from_file(path)
193}
194
195fn build_scheduler(
197 config: &SchedulerConfig,
198) -> Result<sz_rust_core::orm::scheduler::CronScheduler, CliError> {
199 let scheduler = sz_rust_core::orm::scheduler::CronScheduler::new();
200 config.register_to(&scheduler)?;
201 Ok(scheduler)
202}
203
204fn execute_list(config_path: &std::path::Path) -> Result<(), CliError> {
206 let config = load_config_or_empty(config_path)?;
207
208 if config.tasks.is_empty() {
209 println!("No scheduled tasks registered.");
210 println!();
211 println!("To register tasks, create a scheduler configuration file at:");
212 println!(" {}", config_path.display());
213 println!();
214 println!("Example scheduler.toml:");
215 println!();
216 println!(" [[tasks]]");
217 println!(" id = \"cleanup-logs\"");
218 println!(" name = \"清理日志\"");
219 println!(" cron = \"0 3 * * *\"");
220 println!(" callback = \"demo::cleanup_logs\"");
221 println!(" enabled = true");
222 return Ok(());
223 }
224
225 let scheduler = build_scheduler(&config)?;
226 let tasks = scheduler.list_tasks();
227
228 println!(
229 "{:<20} {:<25} {:<25} {:<8}",
230 "ID", "Name", "Cron", "Enabled"
231 );
232 println!("{}", "-".repeat(80));
233
234 for task in &tasks {
235 println!(
236 "{:<20} {:<25} {:<25} {:<8}",
237 task.id, task.name, task.cron_expr, task.enabled
238 );
239 }
240
241 println!();
242 println!("Total: {} task(s)", tasks.len());
243 println!("Config: {}", config_path.display());
244 Ok(())
245}
246
247fn execute_run(config_path: &std::path::Path) -> Result<(), CliError> {
251 let config = load_config_or_empty(config_path)?;
252
253 if config.tasks.is_empty() {
254 println!("No scheduled tasks registered. Nothing to run.");
255 println!("Config: {}", config_path.display());
256 return Ok(());
257 }
258
259 let scheduler = build_scheduler(&config)?;
260 let now = chrono::Utc::now();
261
262 println!("Scheduler run at {} (UTC)", now);
264 println!();
265 for task in &config.tasks {
266 match scheduler.next_run_time(&task.cron, now) {
267 Ok(next) => {
268 println!(
269 " {:<20} {:<25} cron={:<20} next={}",
270 task.id, task.name, task.cron, next
271 );
272 }
273 Err(e) => {
274 println!(
275 " {:<20} {:<25} cron={:<20} next=N/A ({})",
276 task.id, task.name, task.cron, e
277 );
278 }
279 }
280 }
281 println!();
282
283 let fired = scheduler.try_fire_due(now);
284 println!("Fired {} task(s) matching current time.", fired);
285 println!("Config: {}", config_path.display());
286 Ok(())
287}
288
289fn execute_start(tick_ms: u64, config_path: &std::path::Path) -> Result<(), CliError> {
293 let config = load_config_or_empty(config_path)?;
294
295 if config.tasks.is_empty() {
296 return Err(CliError::Scheduler(format!(
297 "no tasks to schedule. Please create config file at {}",
298 config_path.display()
299 )));
300 }
301
302 let scheduler = build_scheduler(&config)?;
303 let task_count = config.tasks.len();
304
305 println!("Starting scheduler with tick interval: {}ms", tick_ms);
306 println!(
307 "Loaded {} task(s) from {}",
308 task_count,
309 config_path.display()
310 );
311 println!("Press Ctrl+C to stop.");
312 println!();
313
314 scheduler
316 .start(tick_ms)
317 .map_err(|e| CliError::Scheduler(e.to_string()))?;
318
319 println!("Scheduler started. Waiting for tasks to fire...");
320
321 loop {
324 std::thread::sleep(std::time::Duration::from_secs(1));
325 }
326}
327
328struct PrintJobHandler;
332
333impl sz_rust_core::orm::scheduler::JobHandler for PrintJobHandler {
334 fn handle(&self, task: &sz_rust_core::orm::scheduler::ScheduledTask) -> Result<(), String> {
335 println!(
336 "[{}] Task fired: {} ({}) callback={}",
337 chrono::Utc::now(),
338 task.name,
339 task.id,
340 if task.callback.is_empty() {
341 "(none)"
342 } else {
343 &task.callback
344 }
345 );
346 Ok(())
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use sz_rust_core::orm::scheduler::JobHandler;
354 use tempfile::NamedTempFile;
355
356 const SAMPLE_TOML: &str = r#"
357[[tasks]]
358id = "cleanup-logs"
359name = "清理日志"
360cron = "0 3 * * *"
361callback = "demo::cleanup_logs"
362enabled = true
363
364[[tasks]]
365id = "sync-data"
366name = "数据同步"
367cron = "*/30 * * * *"
368callback = "demo::sync_data"
369
370[[tasks]]
371id = "disabled-task"
372name = "已禁用任务"
373cron = "0 0 * * *"
374callback = "demo::disabled"
375enabled = false
376"#;
377
378 #[test]
379 fn test_config_from_toml_str() {
380 let config = SchedulerConfig::from_toml_str(SAMPLE_TOML).unwrap();
381 assert_eq!(config.tasks.len(), 3);
382
383 assert_eq!(config.tasks[0].id, "cleanup-logs");
384 assert_eq!(config.tasks[0].name, "清理日志");
385 assert_eq!(config.tasks[0].cron, "0 3 * * *");
386 assert_eq!(config.tasks[0].callback, "demo::cleanup_logs");
387 assert!(config.tasks[0].enabled);
388
389 assert!(config.tasks[1].enabled);
391
392 assert!(!config.tasks[2].enabled);
394 }
395
396 #[test]
397 fn test_config_from_toml_str_empty() {
398 let config = SchedulerConfig::from_toml_str("").unwrap();
399 assert!(config.tasks.is_empty());
400 }
401
402 #[test]
403 fn test_config_duplicate_id_rejected() {
404 let toml_str = r#"
405[[tasks]]
406id = "dup"
407name = "任务1"
408cron = "0 * * * *"
409
410[[tasks]]
411id = "dup"
412name = "任务2"
413cron = "0 * * * *"
414"#;
415 let result = SchedulerConfig::from_toml_str(toml_str);
416 assert!(result.is_err());
417 let err = result.unwrap_err().to_string();
418 assert!(err.contains("duplicate task id"), "got: {err}");
419 }
420
421 #[test]
422 fn test_config_invalid_toml_rejected() {
423 let result = SchedulerConfig::from_toml_str("not valid toml [[[[");
424 assert!(result.is_err());
425 }
426
427 #[test]
428 fn test_config_from_file() {
429 let tmp = NamedTempFile::new().unwrap();
430 std::fs::write(tmp.path(), SAMPLE_TOML).unwrap();
431
432 let config = SchedulerConfig::from_file(tmp.path()).unwrap();
433 assert_eq!(config.tasks.len(), 3);
434 }
435
436 #[test]
437 fn test_config_from_file_not_found() {
438 let result = SchedulerConfig::from_file(std::path::Path::new("/nonexistent/path.toml"));
439 assert!(result.is_err());
440 }
441
442 #[test]
443 fn test_register_to_scheduler() {
444 let config = SchedulerConfig::from_toml_str(SAMPLE_TOML).unwrap();
445 let scheduler = sz_rust_core::orm::scheduler::CronScheduler::new();
446
447 let count = config.register_to(&scheduler).unwrap();
448 assert_eq!(count, 3);
449
450 let tasks = scheduler.list_tasks();
451 assert_eq!(tasks.len(), 3);
452
453 let disabled = tasks.iter().find(|t| t.id == "disabled-task").unwrap();
455 assert!(!disabled.enabled);
456 }
457
458 #[test]
459 fn test_register_to_invalid_cron_rejected() {
460 let toml_str = r#"
461[[tasks]]
462id = "bad"
463name = "坏任务"
464cron = "not a cron"
465"#;
466 let config = SchedulerConfig::from_toml_str(toml_str).unwrap();
467 let scheduler = sz_rust_core::orm::scheduler::CronScheduler::new();
468
469 let result = config.register_to(&scheduler);
470 assert!(result.is_err());
471 let err = result.unwrap_err().to_string();
472 assert!(err.contains("schedule task"), "got: {err}");
473 }
474
475 #[test]
476 fn test_load_config_or_empty_missing_file() {
477 let config =
478 load_config_or_empty(std::path::Path::new("/nonexistent/scheduler.toml")).unwrap();
479 assert!(config.tasks.is_empty());
480 }
481
482 #[test]
483 fn test_load_config_or_empty_existing_file() {
484 let tmp = NamedTempFile::new().unwrap();
485 std::fs::write(tmp.path(), SAMPLE_TOML).unwrap();
486
487 let config = load_config_or_empty(tmp.path()).unwrap();
488 assert_eq!(config.tasks.len(), 3);
489 }
490
491 #[test]
492 fn test_build_scheduler() {
493 let config = SchedulerConfig::from_toml_str(SAMPLE_TOML).unwrap();
494 let scheduler = build_scheduler(&config).unwrap();
495 let tasks = scheduler.list_tasks();
496 assert_eq!(tasks.len(), 3);
497 }
498
499 #[test]
500 fn test_execute_list_empty_config() {
501 let result = execute_list(std::path::Path::new("/nonexistent/scheduler.toml"));
503 assert!(result.is_ok());
504 }
505
506 #[test]
507 fn test_execute_list_with_config() {
508 let tmp = NamedTempFile::new().unwrap();
509 std::fs::write(tmp.path(), SAMPLE_TOML).unwrap();
510
511 let result = execute_list(tmp.path());
512 assert!(result.is_ok());
513 }
514
515 #[test]
516 fn test_execute_run_empty_config() {
517 let result = execute_run(std::path::Path::new("/nonexistent/scheduler.toml"));
518 assert!(result.is_ok());
519 }
520
521 #[test]
522 fn test_execute_run_with_config() {
523 let tmp = NamedTempFile::new().unwrap();
524 std::fs::write(tmp.path(), SAMPLE_TOML).unwrap();
525
526 let result = execute_run(tmp.path());
527 assert!(result.is_ok());
528 }
529
530 #[test]
531 fn test_execute_start_empty_config_errors() {
532 let result = execute_start(1000, std::path::Path::new("/nonexistent/scheduler.toml"));
533 assert!(result.is_err());
534 let err = result.unwrap_err().to_string();
535 assert!(err.contains("no tasks to schedule"), "got: {err}");
536 }
537
538 #[test]
539 fn test_print_job_handler() {
540 let handler = PrintJobHandler;
541 let task = sz_rust_core::orm::scheduler::ScheduledTask::new("test", "测试", "0 * * * *")
542 .with_callback("demo::test");
543 let result = handler.handle(&task);
544 assert!(result.is_ok());
545 }
546
547 #[test]
548 fn test_print_job_handler_empty_callback() {
549 let handler = PrintJobHandler;
550 let task = sz_rust_core::orm::scheduler::ScheduledTask::new("test", "测试", "0 * * * *");
551 let result = handler.handle(&task);
552 assert!(result.is_ok());
553 }
554
555 #[test]
556 fn test_task_config_default_enabled() {
557 let toml_str = r#"
558[[tasks]]
559id = "t1"
560name = "任务"
561cron = "0 * * * *"
562"#;
563 let config = SchedulerConfig::from_toml_str(toml_str).unwrap();
564 assert!(config.tasks[0].enabled, "enabled should default to true");
565 }
566
567 #[test]
568 fn test_config_serialize_roundtrip() {
569 let config = SchedulerConfig::from_toml_str(SAMPLE_TOML).unwrap();
570 let toml_str = toml::to_string(&config).unwrap();
571 let config2 = SchedulerConfig::from_toml_str(&toml_str).unwrap();
572 assert_eq!(config, config2);
573 }
574
575 #[test]
576 fn test_execute_dispatch_list() {
577 let cmd = SchedulerCommand::List {
578 config: PathBuf::from("/nonexistent/scheduler.toml"),
579 };
580 let result = execute(&cmd);
581 assert!(result.is_ok());
582 }
583
584 #[test]
585 fn test_execute_dispatch_run() {
586 let cmd = SchedulerCommand::Run {
587 config: PathBuf::from("/nonexistent/scheduler.toml"),
588 };
589 let result = execute(&cmd);
590 assert!(result.is_ok());
591 }
592
593 #[test]
594 fn test_execute_dispatch_start_empty_config() {
595 let cmd = SchedulerCommand::Start {
596 tick_ms: 1000,
597 config: PathBuf::from("/nonexistent/scheduler.toml"),
598 };
599 let result = execute(&cmd);
600 assert!(result.is_err());
601 assert!(result
602 .unwrap_err()
603 .to_string()
604 .contains("no tasks to schedule"));
605 }
606
607 #[test]
608 fn test_execute_run_with_invalid_cron() {
609 let tmp = NamedTempFile::new().unwrap();
611 let toml_str = r#"
612[[tasks]]
613id = "bad-cron"
614name = "坏 cron"
615cron = "not a valid cron"
616callback = "demo::bad"
617"#;
618 std::fs::write(tmp.path(), toml_str).unwrap();
619
620 let result = execute_run(tmp.path());
621 assert!(result.is_err());
623 }
624}