1use console::style;
2use dialoguer::{theme::ColorfulTheme, Input};
3use std::fs;
4use std::path::Path;
5use std::process::Command;
6
7use crate::templates;
8
9pub fn run(name: Option<String>, no_interaction: bool, no_git: bool) {
10 println!();
11 println!("{}", style("Welcome to Ferro!").cyan().bold());
12 println!();
13
14 let project_name = get_project_name(name, no_interaction);
15 let description = get_description(no_interaction);
16 let author = get_author(no_interaction);
17
18 let package_name = to_snake_case(&project_name);
19
20 println!();
21 println!(
22 "{}",
23 style(format!("Creating project '{project_name}'...")).dim()
24 );
25
26 if let Err(e) = create_project(&project_name, &package_name, &description, &author, no_git) {
27 eprintln!("{} {}", style("Error:").red().bold(), e);
28 std::process::exit(1);
29 }
30
31 println!("{} Generated project structure", style("✓").green());
32
33 if !no_git {
34 println!("{} Initialized git repository", style("✓").green());
35 }
36
37 println!("{} Ready to go!", style("✓").green());
38 println!();
39 println!("Next steps:");
40 println!(" {} {}", style("cd").cyan(), project_name);
41 println!(" {}", style("ferro serve").cyan());
42 println!();
43 println!(
44 "Backend will be at {}",
45 style("http://localhost:8080").underlined()
46 );
47 println!(
48 "Frontend dev server at {}",
49 style("http://localhost:5173").underlined()
50 );
51 println!();
52}
53
54fn get_project_name(name: Option<String>, no_interaction: bool) -> String {
55 if let Some(n) = name {
56 return n;
57 }
58
59 if no_interaction {
60 return "my-ferro-app".to_string();
61 }
62
63 if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
65 eprintln!(
66 "{} project name required when not running in an interactive terminal.\n Usage: ferro new <name>",
67 style("Error:").red().bold()
68 );
69 std::process::exit(1);
70 }
71
72 Input::with_theme(&ColorfulTheme::default())
73 .with_prompt("Project name")
74 .default("my-ferro-app".to_string())
75 .interact_text()
76 .unwrap()
77}
78
79fn get_description(no_interaction: bool) -> String {
80 if no_interaction {
81 return "A web application built with Ferro".to_string();
82 }
83
84 Input::with_theme(&ColorfulTheme::default())
85 .with_prompt("Description")
86 .default("A web application built with Ferro".to_string())
87 .interact_text()
88 .unwrap()
89}
90
91fn get_author(no_interaction: bool) -> String {
92 if no_interaction {
93 return String::new();
94 }
95
96 let default_author = get_git_author().unwrap_or_default();
97
98 Input::with_theme(&ColorfulTheme::default())
99 .with_prompt("Author")
100 .default(default_author)
101 .allow_empty(true)
102 .interact_text()
103 .unwrap()
104}
105
106fn get_git_author() -> Option<String> {
107 let name = Command::new("git")
108 .args(["config", "user.name"])
109 .output()
110 .ok()
111 .and_then(|o| String::from_utf8(o.stdout).ok())
112 .map(|s| s.trim().to_string())
113 .filter(|s| !s.is_empty())?;
114
115 let email = Command::new("git")
116 .args(["config", "user.email"])
117 .output()
118 .ok()
119 .and_then(|o| String::from_utf8(o.stdout).ok())
120 .map(|s| s.trim().to_string())
121 .filter(|s| !s.is_empty())?;
122
123 Some(format!("{name} <{email}>"))
124}
125
126fn to_snake_case(s: &str) -> String {
127 s.replace('-', "_").to_lowercase()
128}
129
130fn to_title_case(s: &str) -> String {
131 s.replace(['-', '_'], " ")
132 .split_whitespace()
133 .map(|word| {
134 let mut chars = word.chars();
135 match chars.next() {
136 None => String::new(),
137 Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
138 }
139 })
140 .collect::<Vec<_>>()
141 .join(" ")
142}
143
144fn create_project(
145 project_name: &str,
146 package_name: &str,
147 description: &str,
148 author: &str,
149 no_git: bool,
150) -> Result<(), String> {
151 let project_path = Path::new(project_name);
152
153 if project_path.exists() {
154 return Err(format!("Directory '{project_name}' already exists"));
155 }
156
157 create_directories(project_path)?;
159
160 write_backend_files(project_path, package_name, description, author)?;
162
163 let project_title = to_title_case(project_name);
165 write_file(
166 project_path,
167 "README.md",
168 &templates::readme(project_name, &project_title, description),
169 )?;
170
171 write_frontend_files(project_path, project_name)?;
173
174 if !no_git {
176 Command::new("git")
177 .args(["init"])
178 .current_dir(project_path)
179 .output()
180 .map_err(|e| format!("Failed to initialize git repository: {e}"))?;
181 }
182
183 Ok(())
184}
185
186fn create_directories(project_path: &Path) -> Result<(), String> {
187 let backend_dirs = [
188 "src/controllers",
189 "src/config",
190 "src/middleware",
191 "src/actions",
192 "src/models",
193 "src/migrations",
194 "src/events",
195 "src/listeners",
196 "src/jobs",
197 "src/notifications",
198 "src/tasks",
199 "src/seeders",
200 "src/factories",
201 "storage/app/public",
202 "storage/logs",
203 "lang/en",
204 ];
205
206 let frontend_dirs = [
207 "frontend/src/pages",
208 "frontend/src/pages/auth",
209 "frontend/src/types",
210 "frontend/src/layouts",
211 "frontend/src/styles",
212 "public/assets",
213 ];
214
215 for dir in backend_dirs.iter().chain(frontend_dirs.iter()) {
216 fs::create_dir_all(project_path.join(dir))
217 .map_err(|e| format!("Failed to create directory {dir}: {e}"))?;
218 }
219
220 Ok(())
221}
222
223fn write_backend_files(
224 project_path: &Path,
225 package_name: &str,
226 description: &str,
227 author: &str,
228) -> Result<(), String> {
229 write_file(
231 project_path,
232 "Cargo.toml",
233 &templates::cargo_toml(package_name, description, author),
234 )?;
235 write_file(project_path, ".gitignore", templates::gitignore())?;
236 write_file(project_path, ".env", &templates::env(package_name))?;
237 write_file(project_path, ".env.example", templates::env_example())?;
238
239 write_file(
241 project_path,
242 "src/main.rs",
243 &templates::main_rs(package_name),
244 )?;
245 write_file(project_path, "src/routes.rs", templates::routes_rs())?;
246 write_file(project_path, "src/bootstrap.rs", templates::bootstrap())?;
247 write_file(project_path, "src/schedule.rs", templates::schedule_rs())?;
248
249 write_file(
251 project_path,
252 "src/controllers/mod.rs",
253 templates::controllers_mod(),
254 )?;
255 write_file(
256 project_path,
257 "src/controllers/home.rs",
258 templates::home_controller(),
259 )?;
260 write_file(
261 project_path,
262 "src/controllers/auth.rs",
263 templates::auth_controller(),
264 )?;
265 write_file(
266 project_path,
267 "src/controllers/dashboard.rs",
268 templates::dashboard_controller(),
269 )?;
270 write_file(
271 project_path,
272 "src/controllers/profile.rs",
273 templates::profile_controller(),
274 )?;
275 write_file(
276 project_path,
277 "src/controllers/settings.rs",
278 templates::settings_controller(),
279 )?;
280
281 write_file(project_path, "src/config/mod.rs", templates::config_mod())?;
283 write_file(
284 project_path,
285 "src/config/database.rs",
286 templates::config_database(),
287 )?;
288 write_file(project_path, "src/config/mail.rs", templates::config_mail())?;
289
290 write_file(
292 project_path,
293 "src/middleware/mod.rs",
294 templates::middleware_mod(),
295 )?;
296 write_file(
297 project_path,
298 "src/middleware/logging.rs",
299 templates::middleware_logging(),
300 )?;
301 write_file(
302 project_path,
303 "src/middleware/authenticate.rs",
304 templates::authenticate_middleware(),
305 )?;
306
307 write_file(project_path, "src/actions/mod.rs", templates::actions_mod())?;
309 write_file(
310 project_path,
311 "src/actions/example_action.rs",
312 templates::example_action(),
313 )?;
314
315 write_file(project_path, "src/models/mod.rs", templates::models_mod())?;
317 write_file(project_path, "src/models/user.rs", templates::user_model())?;
318 write_file(
319 project_path,
320 "src/models/password_reset_tokens.rs",
321 templates::password_reset_tokens_model(),
322 )?;
323
324 write_file(
326 project_path,
327 "src/migrations/mod.rs",
328 templates::migrations_mod(),
329 )?;
330 write_file(
331 project_path,
332 "src/migrations/m20240101_000001_create_users_table.rs",
333 templates::create_users_migration(),
334 )?;
335 write_file(
336 project_path,
337 "src/migrations/m20240101_000002_create_sessions_table.rs",
338 templates::create_sessions_migration(),
339 )?;
340 write_file(
341 project_path,
342 "src/migrations/m20240101_000003_create_password_reset_tokens_table.rs",
343 templates::create_password_reset_tokens_migration(),
344 )?;
345
346 write_file(project_path, "src/events/mod.rs", templates::events_mod())?;
348 write_file(
349 project_path,
350 "src/listeners/mod.rs",
351 templates::listeners_mod(),
352 )?;
353 write_file(project_path, "src/jobs/mod.rs", templates::jobs_mod())?;
354 write_file(
355 project_path,
356 "src/notifications/mod.rs",
357 templates::notifications_mod(),
358 )?;
359 write_file(project_path, "src/tasks/mod.rs", templates::tasks_mod())?;
360 write_file(project_path, "src/seeders/mod.rs", templates::seeders_mod())?;
361 write_file(
362 project_path,
363 "src/factories/mod.rs",
364 templates::factories_mod(),
365 )?;
366
367 write_file(project_path, "storage/app/.gitkeep", "")?;
369 write_file(project_path, "storage/logs/.gitkeep", "")?;
370
371 write_file(
373 project_path,
374 "lang/en/validation.json",
375 templates::lang_validation_json(),
376 )?;
377 write_file(project_path, "lang/en/app.json", templates::lang_app_json())?;
378
379 Ok(())
380}
381
382fn write_frontend_files(project_path: &Path, project_name: &str) -> Result<(), String> {
383 let title = to_title_case(project_name);
384
385 write_file(
387 project_path,
388 "frontend/package.json",
389 &templates::package_json(project_name),
390 )?;
391 write_file(
392 project_path,
393 "frontend/vite.config.ts",
394 templates::vite_config(),
395 )?;
396 write_file(
397 project_path,
398 "frontend/tsconfig.json",
399 templates::tsconfig(),
400 )?;
401 write_file(
402 project_path,
403 "frontend/index.html",
404 &templates::index_html(&title),
405 )?;
406
407 write_file(project_path, "frontend/src/main.tsx", templates::main_tsx())?;
409 write_file(
410 project_path,
411 "frontend/src/types/inertia-props.ts",
412 templates::inertia_props_types(),
413 )?;
414 write_file(
415 project_path,
416 "frontend/src/styles/globals.css",
417 templates::globals_css(),
418 )?;
419
420 write_file(
422 project_path,
423 "frontend/src/layouts/AppLayout.tsx",
424 templates::app_layout(),
425 )?;
426 write_file(
427 project_path,
428 "frontend/src/layouts/AuthLayout.tsx",
429 templates::auth_layout(),
430 )?;
431 write_file(
432 project_path,
433 "frontend/src/layouts/index.ts",
434 templates::layouts_index(),
435 )?;
436
437 write_file(
439 project_path,
440 "frontend/src/pages/Home.tsx",
441 templates::home_page(),
442 )?;
443 write_file(
444 project_path,
445 "frontend/src/pages/Dashboard.tsx",
446 templates::dashboard_page(),
447 )?;
448 write_file(
449 project_path,
450 "frontend/src/pages/Profile.tsx",
451 templates::profile_page(),
452 )?;
453 write_file(
454 project_path,
455 "frontend/src/pages/Settings.tsx",
456 templates::settings_page(),
457 )?;
458
459 write_file(
461 project_path,
462 "frontend/src/pages/auth/Login.tsx",
463 templates::login_page(),
464 )?;
465 write_file(
466 project_path,
467 "frontend/src/pages/auth/Register.tsx",
468 templates::register_page(),
469 )?;
470 write_file(
471 project_path,
472 "frontend/src/pages/auth/ForgotPassword.tsx",
473 templates::forgot_password_page(),
474 )?;
475 write_file(
476 project_path,
477 "frontend/src/pages/auth/ResetPassword.tsx",
478 templates::reset_password_page(),
479 )?;
480
481 Ok(())
482}
483
484fn write_file(project_path: &Path, relative_path: &str, content: &str) -> Result<(), String> {
485 let full_path = project_path.join(relative_path);
486 fs::write(&full_path, content).map_err(|e| format!("Failed to write {relative_path}: {e}"))
487}