1use clap::{Parser, Subcommand};
38
39use crate::cmd;
40use crate::error::CliError;
41
42#[derive(Parser, Debug)]
46#[command(
47 name = "sz-rust",
48 bin_name = "sz-rust",
49 version,
50 about = "SZ-Rust 命令行工具 — 替代 PHP think 命令",
51 long_about = "SZ-Rust CLI 对齐 PHP ThinkPHP 6 think 命令体系,提供 make:migration / make:model / make:controller / migrate / route:list / cache:clear 等命令。"
52)]
53pub struct Cli {
54 #[command(subcommand)]
56 pub command: Option<Command>,
57}
58
59#[derive(Subcommand, Debug)]
63pub enum Command {
64 #[command(name = "make")]
66 Make {
67 #[command(subcommand)]
69 make_command: cmd::make::MakeCommand,
70 },
71
72 #[command(name = "migrate")]
74 Migrate {
75 #[command(flatten)]
77 args: cmd::migrate::MigrateArgs,
78 },
79
80 #[command(name = "migrate:status")]
82 MigrateStatus {
83 #[arg(short = 'p', long, default_value = "migrations")]
85 path: String,
86
87 #[arg(long, default_value = "postgres")]
89 db_type: String,
90
91 #[arg(long)]
93 show_sql: bool,
94
95 #[arg(long)]
99 url: Option<String>,
100 },
101
102 #[command(name = "route:list")]
104 RouteList {
105 #[arg(short = 'f', long, default_value = "table")]
107 format: String,
108 },
109
110 #[command(name = "cache:clear")]
112 CacheClear {
113 #[arg(short = 's', long)]
115 store: Option<String>,
116 },
117
118 #[command(name = "db:seed")]
123 Seed {
124 #[arg(short = 'p', long, default_value = "seeds")]
126 path: String,
127
128 #[arg(long, default_value = "postgres")]
130 db_type: String,
131
132 #[arg(long)]
134 show_sql: bool,
135
136 #[arg(long)]
140 url: Option<String>,
141
142 #[arg(short = 'c', long)]
146 class: Option<String>,
147 },
148
149 #[command(name = "scheduler")]
151 Scheduler {
152 #[command(subcommand)]
154 scheduler_command: cmd::scheduler::SchedulerCommand,
155 },
156
157 #[command(name = "optimize:route")]
161 OptimizeRoute,
162
163 #[command(name = "optimize:config")]
167 OptimizeConfig,
168
169 #[command(name = "optimize:schema")]
175 OptimizeSchema,
176
177 #[command(name = "route:clear")]
181 RouteClear,
182
183 #[command(name = "plugin")]
185 Plugin {
186 #[command(subcommand)]
188 plugin_command: cmd::plugin::PluginCommand,
189 },
190
191 #[command(name = "admin")]
193 Admin {
194 #[command(subcommand)]
196 admin_command: cmd::admin::AdminCommand,
197 },
198
199 #[command(name = "serve")]
201 Serve {
202 #[arg(long)]
204 with_admin: bool,
205
206 #[arg(long)]
208 with_tenant: bool,
209
210 #[arg(long)]
212 with_data_scope: bool,
213
214 #[arg(long, default_value = "0.0.0.0:8080")]
216 addr: String,
217
218 #[arg(long)]
220 watch_config: bool,
221
222 #[arg(long)]
224 workers: Option<u16>,
225
226 #[arg(long)]
228 grace_timeout: Option<u16>,
229
230 #[arg(long)]
232 tls_cert: Option<std::path::PathBuf>,
233
234 #[arg(long)]
236 tls_key: Option<std::path::PathBuf>,
237
238 #[arg(long)]
240 access_log: bool,
241
242 #[arg(long, default_value_t = true)]
244 health: bool,
245
246 #[arg(long, conflicts_with = "health")]
248 no_health: bool,
249 },
250
251 #[command(name = "serve:reload")]
256 ServeReload,
257
258 #[command(name = "serve:log-level")]
263 ServeLogLevel,
264}
265
266impl Cli {
267 pub async fn execute(&self) -> Result<i32, CliError> {
277 match &self.command {
278 None => {
279 println!("SZ-Rust CLI — 使用 --help 查看可用命令");
281 Ok(0)
282 }
283 Some(Command::Make { make_command }) => {
284 cmd::make::execute(make_command).await.map(|_| 0)
285 }
286 Some(Command::Migrate { args }) => cmd::migrate::execute_migrate(args).await.map(|_| 0),
287 Some(Command::MigrateStatus {
288 path,
289 db_type,
290 show_sql,
291 url,
292 }) => cmd::migrate::execute_status_full(path, db_type, *show_sql, url.as_deref())
293 .await
294 .map(|_| 0),
295 Some(Command::RouteList { format }) => {
296 cmd::route::execute_route_list(format).map(|_| 0)
297 }
298 Some(Command::CacheClear { store }) => {
299 cmd::cache::execute_cache_clear(store.as_deref()).map(|_| 0)
300 }
301 Some(Command::Seed {
302 path,
303 db_type,
304 show_sql,
305 url,
306 class,
307 }) => {
308 let args = cmd::seed::SeedArgs {
309 path: path.clone(),
310 db_type: db_type.clone(),
311 show_sql: *show_sql,
312 url: url.clone(),
313 class: class.clone(),
314 };
315 cmd::seed::execute_seed(&args).map(|_| 0)
316 }
317 Some(Command::Scheduler { scheduler_command }) => {
318 cmd::scheduler::execute(scheduler_command).map(|_| 0)
319 }
320 Some(Command::OptimizeRoute) => {
321 cmd::optimize::execute_optimize_route().await.map(|_| 0)
322 }
323 Some(Command::OptimizeConfig) => {
324 cmd::optimize::execute_optimize_config().await.map(|_| 0)
325 }
326 Some(Command::OptimizeSchema) => {
327 cmd::optimize::execute_optimize_schema().await.map(|_| 0)
328 }
329 Some(Command::RouteClear) => cmd::optimize::execute_route_clear().await.map(|_| 0),
330 Some(Command::Plugin { plugin_command }) => cmd::plugin::execute(plugin_command).await,
331 Some(Command::Admin { admin_command }) => cmd::admin::execute(admin_command).await,
332 Some(Command::Serve {
333 with_admin,
334 with_tenant,
335 with_data_scope,
336 addr,
337 watch_config,
338 workers,
339 grace_timeout,
340 tls_cert,
341 tls_key,
342 access_log,
343 health,
344 no_health,
345 }) => {
346 let args = cmd::serve::ServeArgs {
347 with_admin: *with_admin,
348 with_tenant: *with_tenant,
349 with_data_scope: *with_data_scope,
350 addr: addr.clone(),
351 watch_config: *watch_config,
352 workers: *workers,
353 grace_timeout: *grace_timeout,
354 tls_cert: tls_cert.clone(),
355 tls_key: tls_key.clone(),
356 access_log: *access_log,
357 health: *health && !*no_health,
358 };
359 tokio::task::spawn_blocking(move || cmd::serve::execute(args))
360 .await
361 .map_err(|e| CliError::Generic(format!("serve 任务执行失败: {e}")))?
362 }
363 Some(Command::ServeReload) => {
364 println!("Windows 信号替代方案暂未实现,请使用 --watch-config 或重启服务");
365 Ok(0)
366 }
367 Some(Command::ServeLogLevel) => {
368 println!("Windows 信号替代方案暂未实现,请使用 --watch-config 或重启服务");
369 Ok(0)
370 }
371 }
372 }
373}
374
375#[cfg(test)]
376#[allow(clippy::await_holding_lock)]
377mod tests {
378 use super::*;
379 use clap::Parser;
380
381 #[test]
382 fn test_parse_make_model() {
383 let cli = Cli::parse_from(["sz-rust", "make", "model", "User"]);
384 match cli.command {
385 Some(Command::Make { make_command }) => {
386 assert!(matches!(make_command, cmd::make::MakeCommand::Model { .. }));
387 }
388 _ => panic!("expected Make command"),
389 }
390 }
391
392 #[test]
393 fn test_parse_make_controller() {
394 let cli = Cli::parse_from(["sz-rust", "make", "controller", "User"]);
395 match cli.command {
396 Some(Command::Make { make_command }) => {
397 assert!(matches!(
398 make_command,
399 cmd::make::MakeCommand::Controller { .. }
400 ));
401 }
402 _ => panic!("expected Make command"),
403 }
404 }
405
406 #[test]
407 fn test_parse_make_migration() {
408 let cli = Cli::parse_from(["sz-rust", "make", "migration", "create_users"]);
409 match cli.command {
410 Some(Command::Make { make_command }) => {
411 assert!(matches!(
412 make_command,
413 cmd::make::MakeCommand::Migration { .. }
414 ));
415 }
416 _ => panic!("expected Make command"),
417 }
418 }
419
420 #[test]
421 fn test_parse_optimize_schema() {
422 let cli = Cli::parse_from(["sz-rust", "optimize:schema"]);
423 assert!(matches!(cli.command, Some(Command::OptimizeSchema)));
424 }
425
426 #[test]
427 fn test_parse_make_validate() {
428 let cli = Cli::parse_from(["sz-rust", "make", "validate", "User"]);
429 match cli.command {
430 Some(Command::Make { make_command }) => {
431 assert!(matches!(
432 make_command,
433 cmd::make::MakeCommand::Validate { .. }
434 ));
435 }
436 _ => panic!("expected Make command"),
437 }
438 }
439
440 #[test]
441 fn test_parse_make_seeder() {
442 let cli = Cli::parse_from(["sz-rust", "make", "seeder", "001_users"]);
443 match cli.command {
444 Some(Command::Make { make_command }) => {
445 assert!(matches!(
446 make_command,
447 cmd::make::MakeCommand::Seeder { .. }
448 ));
449 }
450 _ => panic!("expected Make command"),
451 }
452 }
453
454 #[test]
455 fn test_parse_migrate() {
456 let cli = Cli::parse_from(["sz-rust", "migrate"]);
457 assert!(matches!(cli.command, Some(Command::Migrate { .. })));
458 }
459
460 #[test]
461 fn test_parse_migrate_status() {
462 let cli = Cli::parse_from(["sz-rust", "migrate:status"]);
463 assert!(matches!(cli.command, Some(Command::MigrateStatus { .. })));
464 }
465
466 #[test]
467 fn test_parse_route_list() {
468 let cli = Cli::parse_from(["sz-rust", "route:list"]);
469 assert!(matches!(cli.command, Some(Command::RouteList { .. })));
470 }
471
472 #[test]
473 fn test_parse_cache_clear() {
474 let cli = Cli::parse_from(["sz-rust", "cache:clear"]);
475 assert!(matches!(cli.command, Some(Command::CacheClear { .. })));
476 }
477
478 #[test]
479 fn test_parse_cache_clear_with_store() {
480 let cli = Cli::parse_from(["sz-rust", "cache:clear", "--store", "redis"]);
481 match cli.command {
482 Some(Command::CacheClear { store }) => {
483 assert_eq!(store.as_deref(), Some("redis"));
484 }
485 _ => panic!("expected CacheClear command"),
486 }
487 }
488
489 #[test]
490 fn test_parse_scheduler() {
491 let cli = Cli::parse_from(["sz-rust", "scheduler", "list"]);
492 assert!(matches!(cli.command, Some(Command::Scheduler { .. })));
493 }
494
495 #[test]
496 fn test_parse_db_seed() {
497 let cli = Cli::parse_from(["sz-rust", "db:seed"]);
498 assert!(matches!(cli.command, Some(Command::Seed { .. })));
499 }
500
501 #[test]
502 fn test_parse_db_seed_with_options() {
503 let cli = Cli::parse_from([
504 "sz-rust",
505 "db:seed",
506 "--path",
507 "custom_seeds",
508 "--db-type",
509 "mysql",
510 "--show-sql",
511 "--url",
512 "mysql://user:pass@host:3306/db",
513 "--class",
514 "001_users",
515 ]);
516 match cli.command {
517 Some(Command::Seed {
518 path,
519 db_type,
520 show_sql,
521 url,
522 class,
523 }) => {
524 assert_eq!(path, "custom_seeds");
525 assert_eq!(db_type, "mysql");
526 assert!(show_sql);
527 assert_eq!(url.as_deref(), Some("mysql://user:pass@host:3306/db"));
528 assert_eq!(class.as_deref(), Some("001_users"));
529 }
530 _ => panic!("expected Seed command"),
531 }
532 }
533
534 #[tokio::test]
535 async fn test_execute_no_command_returns_ok() {
536 let cli = Cli { command: None };
537 let result = cli.execute().await;
538 assert!(result.is_ok());
539 assert_eq!(result.unwrap(), 0);
540 }
541
542 #[tokio::test]
543 async fn test_execute_make_model() {
544 let _lock = crate::cmd::test_support::acquire_global_lock();
545 let temp = tempfile::tempdir().unwrap();
546 let original = std::env::current_dir().unwrap();
547 std::env::set_current_dir(temp.path()).unwrap();
548
549 let cli = Cli {
550 command: Some(Command::Make {
551 make_command: cmd::make::MakeCommand::Model {
552 name: "User".to_string(),
553 },
554 }),
555 };
556 let result = cli.execute().await;
557 std::env::set_current_dir(&original).unwrap();
558 assert!(result.is_ok());
559 assert!(temp.path().join("app/model/User.rs").exists());
560 }
561
562 #[tokio::test]
563 async fn test_execute_migrate_offline() {
564 let _lock = crate::cmd::test_support::acquire_global_lock();
565 let temp = tempfile::tempdir().unwrap();
566 let original = std::env::current_dir().unwrap();
567 std::env::set_current_dir(temp.path()).unwrap();
568
569 let cli = Cli {
570 command: Some(Command::Migrate {
571 args: cmd::migrate::MigrateArgs {
572 rollback: false,
573 path: temp.path().to_string_lossy().to_string(),
574 db_type: "postgres".to_string(),
575 show_sql: false,
576 url: None,
577 },
578 }),
579 };
580 let result = cli.execute().await;
581 std::env::set_current_dir(&original).unwrap();
582 assert!(result.is_ok());
583 }
584
585 #[tokio::test]
586 async fn test_execute_migrate_status_offline() {
587 let _lock = crate::cmd::test_support::acquire_global_lock();
588 let temp = tempfile::tempdir().unwrap();
589 let original = std::env::current_dir().unwrap();
590 std::env::set_current_dir(temp.path()).unwrap();
591
592 let cli = Cli {
593 command: Some(Command::MigrateStatus {
594 path: temp.path().to_string_lossy().to_string(),
595 db_type: "postgres".to_string(),
596 show_sql: false,
597 url: None,
598 }),
599 };
600 let result = cli.execute().await;
601 std::env::set_current_dir(&original).unwrap();
602 assert!(result.is_ok());
603 }
604
605 #[tokio::test]
606 async fn test_execute_route_list() {
607 let cli = Cli {
608 command: Some(Command::RouteList {
609 format: "table".to_string(),
610 }),
611 };
612 let result = cli.execute().await;
613 assert!(result.is_ok());
614 }
615
616 #[tokio::test]
617 async fn test_execute_cache_clear() {
618 let _lock = crate::cmd::test_support::acquire_global_lock();
619 let temp = tempfile::tempdir().unwrap();
620 let original = std::env::current_dir().unwrap();
621 std::env::set_current_dir(temp.path()).unwrap();
622
623 let cli = Cli {
624 command: Some(Command::CacheClear { store: None }),
625 };
626 let result = cli.execute().await;
627 std::env::set_current_dir(&original).unwrap();
628 assert!(result.is_ok());
629 }
630
631 #[tokio::test]
632 async fn test_execute_seed_offline() {
633 let _lock = crate::cmd::test_support::acquire_global_lock();
634 let temp = tempfile::tempdir().unwrap();
635 let original = std::env::current_dir().unwrap();
636 std::env::set_current_dir(temp.path()).unwrap();
637
638 let cli = Cli {
639 command: Some(Command::Seed {
640 path: temp.path().to_string_lossy().to_string(),
641 db_type: "postgres".to_string(),
642 show_sql: false,
643 url: None,
644 class: None,
645 }),
646 };
647 let result = cli.execute().await;
648 std::env::set_current_dir(&original).unwrap();
649 assert!(result.is_ok());
650 }
651
652 #[tokio::test]
653 async fn test_execute_scheduler_list() {
654 let cli = Cli {
655 command: Some(Command::Scheduler {
656 scheduler_command: cmd::scheduler::SchedulerCommand::List {
657 config: std::path::PathBuf::from("/nonexistent/scheduler.toml"),
658 },
659 }),
660 };
661 let result = cli.execute().await;
662 assert!(result.is_ok());
663 }
664
665 #[tokio::test]
666 async fn test_execute_optimize_route() {
667 let _lock = crate::cmd::test_support::acquire_global_lock();
668 let temp = tempfile::tempdir().unwrap();
669 let original = std::env::current_dir().unwrap();
670 std::env::set_current_dir(temp.path()).unwrap();
671
672 let cli = Cli {
673 command: Some(Command::OptimizeRoute),
674 };
675 let result = cli.execute().await;
676 std::env::set_current_dir(&original).unwrap();
677 assert!(result.is_ok());
678 }
679
680 #[tokio::test]
681 async fn test_execute_optimize_config() {
682 let _lock = crate::cmd::test_support::acquire_global_lock();
683 let temp = tempfile::tempdir().unwrap();
684 let original = std::env::current_dir().unwrap();
685 std::env::set_current_dir(temp.path()).unwrap();
686
687 std::fs::create_dir_all(temp.path().join("config")).unwrap();
689
690 let cli = Cli {
691 command: Some(Command::OptimizeConfig),
692 };
693 let result = cli.execute().await;
694 std::env::set_current_dir(&original).unwrap();
695 assert!(result.is_ok());
696 }
697
698 #[tokio::test]
699 async fn test_execute_optimize_schema() {
700 let _lock = crate::cmd::test_support::acquire_global_lock();
701 let temp = tempfile::tempdir().unwrap();
702 let original = std::env::current_dir().unwrap();
703 std::env::set_current_dir(temp.path()).unwrap();
704
705 let cli = Cli {
706 command: Some(Command::OptimizeSchema),
707 };
708 let result = cli.execute().await;
709 std::env::set_current_dir(&original).unwrap();
710 assert!(result.is_ok());
711 }
712
713 #[tokio::test]
714 async fn test_execute_route_clear() {
715 let _lock = crate::cmd::test_support::acquire_global_lock();
716 let temp = tempfile::tempdir().unwrap();
717 let original = std::env::current_dir().unwrap();
718 std::env::set_current_dir(temp.path()).unwrap();
719
720 let cli = Cli {
721 command: Some(Command::RouteClear),
722 };
723 let result = cli.execute().await;
724 std::env::set_current_dir(&original).unwrap();
725 assert!(result.is_ok());
726 }
727
728 #[tokio::test]
729 async fn test_execute_plugin_login_without_token() {
730 let cli = Cli {
731 command: Some(Command::Plugin {
732 plugin_command: cmd::plugin::PluginCommand::Login(cmd::plugin::LoginArgs {
733 token: None,
734 url: None,
735 }),
736 }),
737 };
738 let result = cli.execute().await;
739 assert!(result.is_ok());
740 assert_eq!(result.unwrap(), 1);
741 }
742
743 #[tokio::test]
744
745 async fn test_execute_serve_reload() {
746 let cli = Cli {
747 command: Some(Command::ServeReload),
748 };
749 let result = cli.execute().await;
750 assert!(result.is_ok());
751 assert_eq!(result.unwrap(), 0);
752 }
753
754 #[tokio::test]
755 async fn test_execute_serve_log_level() {
756 let cli = Cli {
757 command: Some(Command::ServeLogLevel),
758 };
759 let result = cli.execute().await;
760 assert!(result.is_ok());
761 assert_eq!(result.unwrap(), 0);
762 }
763
764 #[tokio::test]
765 async fn test_execute_admin_via_parse_list_routes() {
766 let cli = Cli::parse_from(["sz-rust", "admin", "list-routes"]);
767 let result = cli.execute().await;
768 assert!(result.is_ok());
769 }
770
771 #[tokio::test]
772 async fn test_execute_admin_via_parse_list_capabilities() {
773 let cli = Cli::parse_from(["sz-rust", "admin", "list-capabilities"]);
774 let result = cli.execute().await;
775 assert!(result.is_ok());
776 }
777
778 #[tokio::test]
779 async fn test_execute_admin_via_parse_migrate_offline() {
780 let cli = Cli::parse_from(["sz-rust", "admin", "migrate", "--show-sql"]);
781 let result = cli.execute().await;
782 assert!(result.is_ok());
783 }
784
785 #[tokio::test]
786 async fn test_execute_admin_via_parse_init_offline() {
787 let cli = Cli::parse_from(["sz-rust", "admin", "init"]);
788 let result = cli.execute().await;
789 assert!(result.is_ok());
790 }
791}