1use std::path::{Path, PathBuf};
15
16use clap::Subcommand;
17
18use crate::error::CliError;
19use crate::stubs::{self, render_template};
20
21#[derive(Subcommand, Debug)]
25pub enum MakeCommand {
26 #[command(name = "model")]
28 Model {
29 name: String,
31 },
32
33 #[command(name = "controller")]
35 Controller {
36 name: String,
38 #[arg(long)]
40 api: bool,
41 #[arg(long)]
43 plain: bool,
44 },
45
46 #[command(name = "migration")]
48 Migration {
49 name: String,
51 #[arg(short = 'p', long, default_value = "migrations")]
53 path: String,
54 },
55
56 #[command(name = "guard")]
58 Guard {
59 name: String,
61 },
62
63 #[command(name = "scaffold")]
65 Scaffold {
66 name: String,
68 },
69}
70
71pub 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
82fn 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
105fn 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
137fn 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%}", ×tamp),
158 ("{%table_name%}", &table_name),
159 ],
160 );
161 let down_content = render_template(
162 stubs::MIGRATION_DOWN_STUB,
163 &[
164 ("{%name%}", name),
165 ("{%timestamp%}", ×tamp),
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
180fn 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
194fn 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
204fn resolve_target(name: &str, layer: &str) -> (String, String, PathBuf) {
216 let (app, class_part) = if let Some(idx) = name.find('@') {
218 (&name[..idx], &name[idx + 1..])
219 } else {
220 ("", name)
221 };
222
223 let segments: Vec<&str> = class_part.split('/').collect();
225 let class_name = segments.last().unwrap_or(&"").to_string();
226
227 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 let mut path = PathBuf::from("app");
248 if !app.is_empty() {
249 path.push(app);
250 }
251 path.push(layer);
253 for seg in &parent_segments {
255 path.push(seg);
256 }
257 path.push(format!("{}.rs", class_name));
259
260 (class_name, module_path, path)
261}
262
263fn 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
273fn 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
284fn 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
298fn name_to_table(name: &str) -> String {
302 if let Some(rest) = name.strip_prefix("create_") {
304 return rest.to_string();
305 }
306 if let Some(rest) = name.strip_prefix("add_") {
307 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 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); 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 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}