Skip to main content

sz_rust_cli/
console.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2024-2026 SZ-Rust Team
3//
4//! Console 模块 — 自定义命令注册与分发
5//!
6//! 对齐 PHP ThinkPHP 6 `think\console\Console` 与 `think\console\Command` 基类。
7//!
8//! ## PHP 对齐
9//!
10//! PHP ThinkPHP 通过 `think\console\Console` 注册并分发命令:
11//!
12//! ```php
13//! $console = new \think\Console();
14//! $console->add(new \app\command\Hello());
15//! $console->run();
16//! ```
17//!
18//! Rust 端通过 `Console` 结构体 + `Command` trait 实现等价功能:
19//!
20//! ```rust,ignore
21//! use sz_rust_cli::{Console, Command, CommandSignature};
22//!
23//! struct HelloCommand;
24//!
25//! impl Command for HelloCommand {
26//!     fn signature(&self) -> CommandSignature {
27//!         CommandSignature {
28//!             name: "hello".to_string(),
29//!             description: "Print hello world".to_string(),
30//!             usage: "sz-rust hello".to_string(),
31//!         }
32//!     }
33//!     fn execute(&self, _args: &[String]) -> Result<i32, sz_rust_cli::CliError> {
34//!         println!("Hello, World!");
35//!         Ok(0)
36//!     }
37//! }
38//!
39//! let mut console = Console::new();
40//! console.register(Box::new(HelloCommand));
41//! console.run(std::env::args().collect()).await;
42//! ```
43//!
44//! ## 设计说明
45//!
46//! - 内置命令(make / migrate / route / cache / scheduler)仍由 clap derive 处理
47//! - 自定义命令通过 `Command` trait 在运行时注册
48//! - `Console::run` 先检查首个参数是否匹配已注册的自定义命令,若不匹配则回退到 clap CLI
49
50use std::collections::HashMap;
51
52use crate::CliError;
53
54/// 控制台命令签名(名称、描述、用法)
55///
56/// 对齐 PHP `think\console\command\Command::configure()` 中设置的 name / description / help。
57#[derive(Debug, Clone)]
58pub struct CommandSignature {
59    /// 命令名称(如 `"make:model"`、`"hello"`)
60    pub name: String,
61    /// 简短描述
62    pub description: String,
63    /// 用法示例
64    pub usage: String,
65}
66
67/// 自定义控制台命令 trait
68///
69/// 对齐 PHP `think\console\Command` 抽象基类。
70///
71/// PHP 端通过继承 `Command` 并实现 `configure()` 与 `execute()` 方法定义命令;
72/// Rust 端通过实现此 trait 达到相同效果。
73pub trait Command: Send + Sync {
74    /// 获取命令签名
75    ///
76    /// 对齐 PHP `think\console\Command::configure()`。
77    fn signature(&self) -> CommandSignature;
78
79    /// 执行命令
80    ///
81    /// 对齐 PHP `think\console\Command::execute(Input $input, Output $output)`。
82    ///
83    /// # 参数
84    ///
85    /// - `args`:命令参数(不含程序名与命令名,仅含命令后的额外参数)
86    ///
87    /// # 返回
88    ///
89    /// - `Ok(0)`:成功
90    /// - `Ok(code)`:命令指定的退出码(非 0 表示部分失败)
91    /// - `Err(_)`:内部错误
92    fn execute(&self, args: &[String]) -> Result<i32, CliError>;
93}
94
95/// 控制台应用 — 命令注册表与分发器
96///
97/// 对齐 PHP `think\console\Console`。允许在运行时注册自定义命令,
98/// 并列出所有可用命令。
99///
100/// # 分发逻辑
101///
102/// 1. 若 `args[1]` 匹配已注册的自定义命令名,则分发到该命令
103/// 2. 否则,回退到内置的 clap CLI(`crate::run`)
104///
105/// # 示例
106///
107/// ```rust,ignore
108/// use sz_rust_cli::{Console, Command, CommandSignature};
109///
110/// let mut console = Console::new();
111/// console.register(Box::new(MyCommand));
112/// console.run(vec!["sz-rust".to_string(), "my:command".to_string()]).await;
113/// ```
114pub struct Console {
115    /// 已注册的自定义命令映射(命令名 → 命令实现)
116    commands: HashMap<String, Box<dyn Command>>,
117}
118
119impl Console {
120    /// 创建空的 Console 实例
121    pub fn new() -> Self {
122        Self {
123            commands: HashMap::new(),
124        }
125    }
126
127    /// 注册自定义命令
128    ///
129    /// 对齐 PHP `think\console\Console::add(Command $command)`。
130    ///
131    /// # 参数
132    ///
133    /// - `command`:实现了 `Command` trait 的命令实例(装箱)
134    ///
135    /// # 返回
136    ///
137    /// 返回 `&mut Self` 以支持链式调用。
138    ///
139    /// # 示例
140    ///
141    /// ```rust,ignore
142    /// let mut console = Console::new();
143    /// console
144    ///     .register(Box::new(HelloCommand))
145    ///     .register(Box::new(WorldCommand));
146    /// ```
147    pub fn register(&mut self, command: Box<dyn Command>) -> &mut Self {
148        let name = command.signature().name;
149        self.commands.insert(name, command);
150        self
151    }
152
153    /// 运行控制台
154    ///
155    /// 若 `args[1]`(程序名后的首个参数)匹配已注册的自定义命令名,
156    /// 则分发到该命令;否则回退到内置 clap CLI。
157    ///
158    /// # 参数
159    ///
160    /// - `args`:命令行参数(含程序名,如 `["sz-rust", "hello", "arg1"]`)
161    ///
162    /// # 返回
163    ///
164    /// - `Ok(0)`:成功
165    /// - `Ok(code)`:命令指定的退出码
166    /// - `Err(_)`:内部错误
167    pub async fn run(&self, args: Vec<String>) -> Result<i32, CliError> {
168        if args.len() >= 2 {
169            if let Some(command) = self.commands.get(&args[1]) {
170                let cmd_args: &[String] = &args[2..];
171                return command.execute(cmd_args);
172            }
173        }
174        crate::run(args).await
175    }
176
177    /// 列出所有已注册的自定义命令签名
178    ///
179    /// 对齐 PHP `think\console\Console::getCommands()`。
180    ///
181    /// # 返回
182    ///
183    /// 返回命令签名向量,顺序不保证(HashMap 迭代顺序不确定)。
184    pub fn list(&self) -> Vec<CommandSignature> {
185        self.commands.values().map(|cmd| cmd.signature()).collect()
186    }
187
188    /// 生成命令列表文本(print_list 的输出内容,独立成函数以便测试断言)
189    fn format_list(&self) -> String {
190        let mut out = String::from("Available commands:");
191        let mut signatures = self.list();
192        signatures.sort_by(|a, b| a.name.cmp(&b.name));
193        for sig in signatures {
194            out.push_str(&format!("\n  {:<20} {}", sig.name, sig.description));
195        }
196        out
197    }
198
199    /// 打印命令列表到标准输出
200    ///
201    /// 对齐 PHP `think\console\Console::listCommands()`。
202    pub fn print_list(&self) {
203        println!("{}", self.format_list());
204    }
205}
206
207impl Default for Console {
208    fn default() -> Self {
209        Self::new()
210    }
211}
212
213#[cfg(test)]
214#[allow(clippy::await_holding_lock)]
215mod tests {
216    use super::*;
217
218    /// 测试用命令:打印 Hello, World!
219    struct HelloCommand;
220
221    impl Command for HelloCommand {
222        fn signature(&self) -> CommandSignature {
223            CommandSignature {
224                name: "hello".to_string(),
225                description: "Print hello world".to_string(),
226                usage: "sz-rust hello".to_string(),
227            }
228        }
229
230        fn execute(&self, _args: &[String]) -> Result<i32, CliError> {
231            println!("Hello, World!");
232            Ok(0)
233        }
234    }
235
236    /// 测试用命令:回显参数
237    struct EchoCommand;
238
239    impl Command for EchoCommand {
240        fn signature(&self) -> CommandSignature {
241            CommandSignature {
242                name: "echo".to_string(),
243                description: "Echo arguments".to_string(),
244                usage: "sz-rust echo <args...>".to_string(),
245            }
246        }
247
248        fn execute(&self, args: &[String]) -> Result<i32, CliError> {
249            println!("{}", args.join(" "));
250            Ok(0)
251        }
252    }
253
254    #[test]
255    fn test_register_and_list() {
256        let mut console = Console::new();
257        console.register(Box::new(HelloCommand));
258        let commands = console.list();
259        assert_eq!(commands.len(), 1);
260        assert_eq!(commands[0].name, "hello");
261        assert_eq!(commands[0].description, "Print hello world");
262        assert_eq!(commands[0].usage, "sz-rust hello");
263    }
264
265    #[test]
266    fn test_register_multiple_commands() {
267        let mut console = Console::new();
268        console
269            .register(Box::new(HelloCommand))
270            .register(Box::new(EchoCommand));
271        let commands = console.list();
272        assert_eq!(commands.len(), 2);
273    }
274
275    #[tokio::test]
276    async fn test_run_custom_command() {
277        let mut console = Console::new();
278        console.register(Box::new(HelloCommand));
279        let result = console
280            .run(vec!["sz-rust".to_string(), "hello".to_string()])
281            .await;
282        assert!(result.is_ok());
283        assert_eq!(result.unwrap(), 0);
284    }
285
286    #[tokio::test]
287    async fn test_run_custom_command_with_args() {
288        let mut console = Console::new();
289        console.register(Box::new(EchoCommand));
290        let result = console
291            .run(vec![
292                "sz-rust".to_string(),
293                "echo".to_string(),
294                "foo".to_string(),
295                "bar".to_string(),
296            ])
297            .await;
298        assert!(result.is_ok());
299        assert_eq!(result.unwrap(), 0);
300    }
301
302    #[tokio::test]
303    async fn test_run_unknown_command_falls_through() {
304        let _lock = crate::cmd::test_support::acquire_global_lock();
305        let temp = tempfile::tempdir().unwrap();
306        let original = std::env::current_dir().ok();
307        std::env::set_current_dir(temp.path()).unwrap();
308        let console = Console::new();
309        // "cache:clear" 是内置 clap 命令,未注册为自定义命令,应回退到 crate::run
310        let result = console
311            .run(vec!["sz-rust".to_string(), "cache:clear".to_string()])
312            .await;
313        if let Some(ref orig) = original {
314            let _ = std::env::set_current_dir(orig);
315        }
316        assert!(result.is_ok());
317    }
318
319    #[tokio::test]
320    async fn test_run_no_args_falls_through() {
321        let console = Console::new();
322        // 无参数时应回退到 crate::run
323        let result = console.run(vec!["sz-rust".to_string()]).await;
324        assert!(result.is_ok());
325        assert_eq!(result.unwrap(), 0);
326    }
327
328    #[test]
329    fn test_console_default_is_empty() {
330        let console = Console::default();
331        assert!(console.list().is_empty());
332    }
333
334    #[test]
335    fn test_register_overwrites_same_name() {
336        let mut console = Console::new();
337        console.register(Box::new(HelloCommand));
338        // 注册同名命令应覆盖
339        console.register(Box::new(EchoCommand));
340        let commands = console.list();
341        // HelloCommand 和 EchoCommand 名称不同,故应为 2
342        assert_eq!(commands.len(), 2);
343    }
344
345    #[test]
346    fn test_print_list_empty() {
347        let console = Console::new();
348        let commands = console.list();
349        assert!(commands.is_empty(), "空命令列表应返回空切片");
350    }
351
352    #[test]
353    fn test_print_list_with_commands() {
354        let mut console = Console::new();
355        console
356            .register(Box::new(HelloCommand))
357            .register(Box::new(EchoCommand));
358        let commands = console.list();
359        assert_eq!(commands.len(), 2, "应有两个注册命令");
360    }
361
362    #[test]
363    fn test_print_list_output_empty() {
364        let console = Console::new();
365        let out = console.format_list();
366        assert!(
367            out.starts_with("Available commands:"),
368            "空命令表也应输出表头"
369        );
370        assert_eq!(out.lines().count(), 1, "空命令表不应有命令行");
371    }
372
373    #[test]
374    fn test_print_list_output_with_commands() {
375        let mut console = Console::new();
376        console
377            .register(Box::new(HelloCommand))
378            .register(Box::new(EchoCommand));
379        let out = console.format_list();
380        assert!(out.contains("Available commands:"), "应包含表头");
381        assert!(out.contains("hello"), "应包含 hello 命令");
382        assert!(out.contains("echo"), "应包含 echo 命令");
383        let echo_pos = out.find("echo").expect("echo 应在列表中");
384        let hello_pos = out.find("hello").expect("hello 应在列表中");
385        assert!(
386            echo_pos < hello_pos,
387            "命令应按名称排序(echo 在 hello 之前)"
388        );
389    }
390
391    #[tokio::test]
392    async fn test_run_single_arg_falls_through() {
393        let console = Console::new();
394        let result = console.run(vec!["sz-rust".to_string()]).await;
395        assert!(result.is_ok());
396    }
397}