Skip to main content

ferro_cli/commands/
new.rs

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    // Refuse to prompt when stdin is not a TTY — dialoguer would panic otherwise.
64    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 directory structure
158    create_directories(project_path)?;
159
160    // Write backend files
161    write_backend_files(project_path, package_name, description, author)?;
162
163    // Write README at project root
164    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
172    write_frontend_files(project_path, project_name)?;
173
174    // Initialize git repository
175    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    // Root files
230    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    // Main source files
240    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    // Controllers
250    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    // Config
282    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    // Middleware
291    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    // Actions
308    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    // Models
316    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    // Migrations
325    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    // Events, Listeners, Jobs, Notifications, Tasks
347    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    // Storage gitkeep files
368    write_file(project_path, "storage/app/.gitkeep", "")?;
369    write_file(project_path, "storage/logs/.gitkeep", "")?;
370
371    // Language files
372    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    // Root frontend files
386    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    // Frontend source files
408    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    // Layouts
421    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    // Pages
438    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    // Auth pages
460    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}