Skip to main content

sz_rust_cli/cmd/
make.rs

1//! `make:*` 代码生成命令 — 对齐 PHP `think\console\command\make\*`
2//!
3//! ## PHP 对齐
4//!
5//! PHP `Make::execute()` 流程:
6//! 1. `getArgument('name')` 获取类名
7//! 2. `getClassName(name)` 处理 `@` 分隔应用名 + `/` 转 `\`
8//! 3. `getPathName(className)` 剥离 `app\` 前缀 + `/` 替换 + `.php` 后缀
9//! 4. 检查文件存在 → `mkdir` → `file_put_contents(buildClass())`
10//! 5. `buildClass(name)` 读取 stub,替换占位符
11//!
12//! Rust 端对齐上述流程,但生成 `.rs` 文件而非 `.php` 文件。
13
14use std::path::{Path, PathBuf};
15
16use clap::Subcommand;
17
18use crate::error::CliError;
19use crate::stubs::{self, render_template};
20
21/// `make` 子命令枚举
22///
23/// 对齐 PHP `think make:*` 命令组。
24#[derive(Subcommand, Debug)]
25pub enum MakeCommand {
26    /// 生成 Model(对齐 `php think make:model User`)
27    #[command(name = "model")]
28    Model {
29        /// 类名(如 `User` 或 `admin/User`)
30        name: String,
31    },
32
33    /// 生成 Controller(对齐 `php think make:controller User`)
34    #[command(name = "controller")]
35    Controller {
36        /// 类名
37        name: String,
38        /// 生成 API 风格控制器(5 方法,无 create/edit)
39        #[arg(long)]
40        api: bool,
41        /// 生成空控制器
42        #[arg(long)]
43        plain: bool,
44    },
45
46    /// 生成迁移文件(对齐 Phinx `make:migration`)
47    #[command(name = "migration")]
48    Migration {
49        /// 迁移名称(如 `create_users`)
50        name: String,
51        /// 迁移目录(默认 `migrations`)
52        #[arg(short = 'p', long, default_value = "migrations")]
53        path: String,
54    },
55
56    /// 生成 Guard(sz-rust 自研,无 PHP 对应)
57    #[command(name = "guard")]
58    Guard {
59        /// Guard 名称(如 `Admin`)
60        name: String,
61    },
62
63    /// 生成脚手架(Model + Controller + Migration)
64    #[command(name = "scaffold")]
65    Scaffold {
66        /// 资源名称(如 `User`)
67        name: String,
68    },
69}
70
71/// 执行 make 子命令
72pub fn execute(cmd: &MakeCommand) -> Result<(), CliError> {
73    match cmd {
74        MakeCommand::Model { name } => execute_make_model(name),
75        MakeCommand::Controller { name, api, plain } => execute_make_controller(name, *api, *plain),
76        MakeCommand::Migration { name, path } => execute_make_migration(name, path),
77        MakeCommand::Guard { name } => execute_make_guard(name),
78        MakeCommand::Scaffold { name } => execute_make_scaffold(name),
79    }
80}
81
82/// 生成 Model 文件
83///
84/// 对齐 PHP `make:model`:读取 `model.stub`,替换占位符,写入 `app/model/{name}.rs`。
85fn execute_make_model(name: &str) -> Result<(), CliError> {
86    let (class_name, module_path, file_path) = resolve_target(name, "model");
87    check_file_exists(&file_path)?;
88
89    let namespace = format!("app::{}", module_path);
90    let table_name = class_to_snake(&class_name);
91    let content = render_template(
92        stubs::MODEL_STUB,
93        &[
94            ("{%className%}", &class_name),
95            ("{%namespace%}", &namespace),
96            ("{%table_name%}", &table_name),
97        ],
98    );
99
100    write_file(&file_path, &content)?;
101    println!("Model created: {}", file_path.display());
102    Ok(())
103}
104
105/// 生成 Controller 文件
106///
107/// 对齐 PHP `make:controller`:根据 `--api` / `--plain` 选择不同 stub。
108fn execute_make_controller(name: &str, api: bool, plain: bool) -> Result<(), CliError> {
109    let (class_name, module_path, file_path) = resolve_target(name, "controller");
110    check_file_exists(&file_path)?;
111
112    let namespace = format!("app::{}", module_path);
113    let route = class_to_snake(&class_name);
114
115    let template = if plain {
116        stubs::CONTROLLER_PLAIN_STUB
117    } else if api {
118        stubs::CONTROLLER_API_STUB
119    } else {
120        stubs::CONTROLLER_STUB
121    };
122
123    let content = render_template(
124        template,
125        &[
126            ("{%className%}", &class_name),
127            ("{%namespace%}", &namespace),
128            ("{%route%}", &route),
129        ],
130    );
131
132    write_file(&file_path, &content)?;
133    println!("Controller created: {}", file_path.display());
134    Ok(())
135}
136
137/// 生成迁移文件
138///
139/// 对齐 Phinx 风格:生成 `{timestamp}_{name}_up.sql` 和 `{timestamp}_{name}_down.sql`。
140fn execute_make_migration(name: &str, path: &str) -> Result<(), CliError> {
141    let dir = Path::new(path);
142    std::fs::create_dir_all(dir)?;
143
144    let timestamp = chrono::Utc::now().format("%Y%m%d%H%M%S").to_string();
145    let table_name = name_to_table(name);
146
147    let up_file = dir.join(format!("{}_{}_up.sql", timestamp, name));
148    let down_file = dir.join(format!("{}_{}_down.sql", timestamp, name));
149
150    check_file_exists(&up_file)?;
151    check_file_exists(&down_file)?;
152
153    let up_content = render_template(
154        stubs::MIGRATION_UP_STUB,
155        &[
156            ("{%name%}", name),
157            ("{%timestamp%}", &timestamp),
158            ("{%table_name%}", &table_name),
159        ],
160    );
161    let down_content = render_template(
162        stubs::MIGRATION_DOWN_STUB,
163        &[
164            ("{%name%}", name),
165            ("{%timestamp%}", &timestamp),
166            ("{%table_name%}", &table_name),
167        ],
168    );
169
170    write_file(&up_file, &up_content)?;
171    write_file(&down_file, &down_content)?;
172    println!(
173        "Migration created: {} & {}",
174        up_file.display(),
175        down_file.display()
176    );
177    Ok(())
178}
179
180/// 生成 Guard 文件(sz-rust 自研)
181fn execute_make_guard(name: &str) -> Result<(), CliError> {
182    let (class_name, _module_path, file_path) = resolve_target(name, "guard");
183    check_file_exists(&file_path)?;
184
185    let content = format!(
186        "//! Guard: {class_name}\n//!\n//! 由 `sz-rust make:guard` 生成。\n//!\n//! 对齐 NestJS Guard + Spring Security 模式。\n\nuse sz_rust_core::guard::Guard;\nuse sz_rust_core::request::Request;\n\n/// {class_name} Guard\npub struct {class_name};\n\nimpl Guard for {class_name} {{\n    async fn can_activate(&self, _req: &Request) -> bool {{\n        // 在此实现鉴权逻辑\n        true\n    }}\n}}\n"
187    );
188
189    write_file(&file_path, &content)?;
190    println!("Guard created: {}", file_path.display());
191    Ok(())
192}
193
194/// 生成脚手架(Model + Controller + Migration)
195fn execute_make_scaffold(name: &str) -> Result<(), CliError> {
196    println!("Scaffolding for: {}", name);
197    execute_make_model(name)?;
198    execute_make_controller(name, false, false)?;
199    execute_make_migration(&class_to_snake(name), "migrations")?;
200    println!("Scaffold complete.");
201    Ok(())
202}
203
204// ============================================================================
205// 辅助函数(对齐 PHP Make 基类方法)
206// ============================================================================
207
208/// 解析目标(类名 + 模块路径 + 文件路径)
209///
210/// 对齐 PHP `Make::getClassName()` + `getPathName()`:
211///
212/// - `User` → (`User`, `model`, `app/model/User.rs`)
213/// - `admin/User` → (`User`, `controller::admin`, `app/controller/admin/User.rs`)
214/// - `admin@User` → (`User`, `admin::model`, `app/admin/model/User.rs`)
215fn resolve_target(name: &str, layer: &str) -> (String, String, PathBuf) {
216    // 处理 @ 分隔应用名(对齐 PHP getClassName)
217    let (app, class_part) = if let Some(idx) = name.find('@') {
218        (&name[..idx], &name[idx + 1..])
219    } else {
220        ("", name)
221    };
222
223    // 处理 / 分隔子目录(对齐 PHP / → \)
224    let segments: Vec<&str> = class_part.split('/').collect();
225    let class_name = segments.last().unwrap_or(&"").to_string();
226
227    // 构建模块路径
228    let parent_segments: Vec<&str> = if segments.len() > 1 {
229        segments[..segments.len() - 1].to_vec()
230    } else {
231        Vec::new()
232    };
233
234    let module_path = if app.is_empty() {
235        if parent_segments.is_empty() {
236            layer.to_string()
237        } else {
238            format!("{}::{}", layer, parent_segments.join("::"))
239        }
240    } else if parent_segments.is_empty() {
241        format!("{}::{}", app, layer)
242    } else {
243        format!("{}::{}::{}", app, layer, parent_segments.join("::"))
244    };
245
246    // 构建文件路径(对齐 PHP getPathName:app\ → app/ + / 替换)
247    let mut path = PathBuf::from("app");
248    if !app.is_empty() {
249        path.push(app);
250    }
251    // 添加层目录
252    path.push(layer);
253    // 添加子目录
254    for seg in &parent_segments {
255        path.push(seg);
256    }
257    // 添加文件名
258    path.push(format!("{}.rs", class_name));
259
260    (class_name, module_path, path)
261}
262
263/// 检查文件是否已存在
264///
265/// 对齐 PHP `Make::execute()` 中 `already exists!` 提示。
266fn check_file_exists(path: &Path) -> Result<(), CliError> {
267    if path.exists() {
268        return Err(CliError::FileExists(path.display().to_string()));
269    }
270    Ok(())
271}
272
273/// 写入文件(自动创建父目录)
274///
275/// 对齐 PHP `mkdir` + `file_put_contents`。
276fn write_file(path: &Path, content: &str) -> Result<(), CliError> {
277    if let Some(parent) = path.parent() {
278        std::fs::create_dir_all(parent)?;
279    }
280    std::fs::write(path, content)?;
281    Ok(())
282}
283
284/// 类名转 snake_case 表名
285///
286/// `User` → `user`,`OrderItem` → `order_item`
287fn class_to_snake(s: &str) -> String {
288    let mut result = String::new();
289    for (i, ch) in s.chars().enumerate() {
290        if ch.is_uppercase() && i > 0 {
291            result.push('_');
292        }
293        result.push(ch.to_lowercase().next().unwrap_or(ch));
294    }
295    result
296}
297
298/// 迁移名转表名
299///
300/// `create_users` → `users`,`add_index_to_orders` → `orders`
301fn name_to_table(name: &str) -> String {
302    // 简单提取:create_xxx → xxx
303    if let Some(rest) = name.strip_prefix("create_") {
304        return rest.to_string();
305    }
306    if let Some(rest) = name.strip_prefix("add_") {
307        // add_index_to_orders → orders
308        if let Some(to_pos) = rest.find("_to_") {
309            return rest[to_pos + 4..].to_string();
310        }
311        return rest.to_string();
312    }
313    name.to_string()
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn test_resolve_target_simple_model() {
322        let (class, module, path) = resolve_target("User", "model");
323        assert_eq!(class, "User");
324        assert_eq!(module, "model");
325        assert_eq!(path, PathBuf::from("app/model/User.rs"));
326    }
327
328    #[test]
329    fn test_resolve_target_nested_controller() {
330        let (class, module, path) = resolve_target("admin/User", "controller");
331        assert_eq!(class, "User");
332        assert_eq!(module, "controller::admin");
333        assert_eq!(path, PathBuf::from("app/controller/admin/User.rs"));
334    }
335
336    #[test]
337    fn test_resolve_target_with_app() {
338        let (class, module, _path) = resolve_target("admin@User", "model");
339        assert_eq!(class, "User");
340        assert_eq!(module, "admin::model");
341    }
342
343    #[test]
344    fn test_class_to_snake() {
345        assert_eq!(class_to_snake("User"), "user");
346        assert_eq!(class_to_snake("OrderItem"), "order_item");
347        assert_eq!(class_to_snake("API"), "a_p_i");
348    }
349
350    #[test]
351    fn test_name_to_table_create() {
352        assert_eq!(name_to_table("create_users"), "users");
353        assert_eq!(name_to_table("create_orders"), "orders");
354    }
355
356    #[test]
357    fn test_name_to_table_add() {
358        assert_eq!(name_to_table("add_index_to_orders"), "orders");
359        assert_eq!(name_to_table("add_status"), "status");
360    }
361
362    #[test]
363    fn test_name_to_table_other() {
364        assert_eq!(name_to_table("custom_migration"), "custom_migration");
365    }
366
367    #[test]
368    fn test_check_file_exists_nonexistent() {
369        let result = check_file_exists(Path::new("/nonexistent/path/file.txt"));
370        assert!(result.is_ok());
371    }
372
373    #[test]
374    fn test_check_file_exists_existing() {
375        // 使用临时文件确保文件确实存在
376        let temp = tempfile::NamedTempFile::new().unwrap();
377        let result = check_file_exists(temp.path());
378        assert!(matches!(result, Err(CliError::FileExists(_))));
379    }
380
381    #[test]
382    fn test_write_and_read_file() {
383        let temp_dir = tempfile::tempdir().unwrap();
384        let file_path = temp_dir.path().join("test_file.txt");
385
386        write_file(&file_path, "test content").unwrap();
387        assert_eq!(std::fs::read_to_string(&file_path).unwrap(), "test content");
388    }
389
390    #[test]
391    fn test_execute_make_migration_creates_files() {
392        let temp_dir = tempfile::tempdir().unwrap();
393        let path = temp_dir.path().to_str().unwrap();
394
395        execute_make_migration("create_test_table", path).unwrap();
396
397        let entries: Vec<_> = std::fs::read_dir(path).unwrap().collect();
398        assert_eq!(entries.len(), 2); // up + down
399
400        let mut has_up = false;
401        let mut has_down = false;
402        for entry in entries {
403            let name = entry.unwrap().file_name();
404            let name = name.to_string_lossy();
405            if name.ends_with("_up.sql") {
406                has_up = true;
407            }
408            if name.ends_with("_down.sql") {
409                has_down = true;
410            }
411        }
412        assert!(has_up);
413        assert!(has_down);
414    }
415
416    #[test]
417    fn test_execute_make_model_in_temp() {
418        // 模拟生成(在临时目录验证逻辑)
419        let temp_dir = tempfile::tempdir().unwrap();
420        let old = std::env::current_dir().unwrap();
421        std::env::set_current_dir(temp_dir.path()).unwrap();
422
423        execute_make_model("TestUser").unwrap();
424
425        let model_path = Path::new("app/model/TestUser.rs");
426        assert!(model_path.exists());
427
428        let content = std::fs::read_to_string(model_path).unwrap();
429        assert!(content.contains("TestUser"));
430        assert!(content.contains("test_user"));
431
432        std::env::set_current_dir(old).unwrap();
433    }
434}