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