fob_cli/commands/
init.rs

1//! Init command implementation.
2//!
3//! Creates new Fob projects from templates.
4
5use crate::cli::InitArgs;
6use crate::commands::{templates, utils};
7use crate::error::{CliError, Result};
8use crate::ui;
9use std::fs;
10use std::path::Path;
11
12/// Execute the init command.
13///
14/// # Process
15///
16/// 1. Determine project name
17/// 2. Select template (interactive or from args)
18/// 3. Detect package manager
19/// 4. Create project directory structure
20/// 5. Generate files from template
21/// 6. Show next steps
22///
23/// # Arguments
24///
25/// * `args` - Parsed init command arguments
26///
27/// # Errors
28///
29/// Returns errors for:
30/// - Invalid project names
31/// - Directory already exists
32/// - File write failures
33pub async fn execute(args: InitArgs) -> Result<()> {
34    // Step 1: Determine project name
35    let project_name = determine_project_name(&args)?;
36    validate_project_name(&project_name)?;
37
38    ui::info(&format!("Creating project: {}", project_name));
39
40    // Step 2: Select template
41    let template = select_template(&args)?;
42    ui::info(&format!("Using template: {}", template.name()));
43
44    // Step 3: Create project directory
45    let project_dir = Path::new(&project_name);
46    if project_dir.exists() {
47        return Err(CliError::InvalidArgument(format!(
48            "Directory '{}' already exists",
49            project_name
50        )));
51    }
52
53    fs::create_dir(project_dir)?;
54    ui::success(&format!("Created directory: {}", project_name));
55
56    // Step 4: Generate project files
57    generate_project_files(project_dir, &project_name, template)?;
58
59    // Step 5: Detect package manager
60    let pkg_mgr = if args.use_pnpm {
61        utils::PackageManager::Pnpm
62    } else if args.use_yarn {
63        utils::PackageManager::Yarn
64    } else {
65        // Default to npm or auto-detect
66        utils::PackageManager::Npm
67    };
68
69    // Step 6: Display next steps
70    print_next_steps(&project_name, pkg_mgr);
71
72    ui::success("Project created successfully!");
73    Ok(())
74}
75
76/// Determine the project name from args or current directory.
77fn determine_project_name(args: &InitArgs) -> Result<String> {
78    if let Some(ref name) = args.name {
79        Ok(name.clone())
80    } else {
81        // Use current directory name
82        let cwd = utils::get_cwd()?;
83        let dir_name = cwd
84            .file_name()
85            .and_then(|n| n.to_str())
86            .ok_or_else(|| CliError::InvalidArgument("Invalid directory name".to_string()))?;
87        Ok(dir_name.to_string())
88    }
89}
90
91/// Reserved package names that cannot be used (npm reserved names).
92const RESERVED_NAMES: &[&str] = &[
93    "node_modules",
94    "favicon.ico",
95    "index.js",
96    "index.html",
97    "package.json",
98    "tsconfig.json",
99    "fob.config.json",
100    ".git",
101    ".gitignore",
102    ".DS_Store",
103];
104
105/// Validate project name follows npm package naming rules.
106fn validate_project_name(name: &str) -> Result<()> {
107    if name.is_empty() {
108        return Err(CliError::InvalidArgument(
109            "Project name cannot be empty".to_string(),
110        ));
111    }
112
113    // Check for reserved names
114    if RESERVED_NAMES.contains(&name) {
115        return Err(CliError::InvalidArgument(format!(
116            "Project name '{}' is reserved and cannot be used",
117            name
118        )));
119    }
120
121    // Check for invalid characters
122    if !name
123        .chars()
124        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
125    {
126        return Err(CliError::InvalidArgument(
127            "Project name can only contain letters, numbers, hyphens, and underscores".to_string(),
128        ));
129    }
130
131    // Cannot start with a dot or number
132    // Note: empty check above guarantees name is non-empty
133    if name.starts_with('.') || name.as_bytes()[0].is_ascii_digit() {
134        return Err(CliError::InvalidArgument(
135            "Project name cannot start with a dot or number".to_string(),
136        ));
137    }
138
139    Ok(())
140}
141
142/// Select template based on args or interactive prompt.
143///
144/// Validates that the user-provided template name is valid and returns the
145/// corresponding Template enum variant. Security: Input validation prevents
146/// arbitrary file system operations.
147fn select_template(args: &InitArgs) -> Result<templates::Template> {
148    if let Some(ref template_name) = args.template {
149        templates::Template::from_str(template_name).ok_or_else(|| {
150            CliError::InvalidArgument(format!(
151                "Invalid template '{}'. Available: library, app, component-library, meta-framework",
152                template_name
153            ))
154        })
155    } else if args.yes {
156        // Default to library when using --yes
157        Ok(templates::Template::Library)
158    } else {
159        // In a real implementation, this would show an interactive prompt
160        // For now, default to library
161        ui::info("Defaulting to 'library' template (use --template to specify)");
162        Ok(templates::Template::Library)
163    }
164}
165
166/// Generate all project files from template.
167fn generate_project_files(
168    project_dir: &Path,
169    project_name: &str,
170    template: templates::Template,
171) -> Result<()> {
172    ui::info("Generating files...");
173
174    // Create src directory
175    let src_dir = project_dir.join("src");
176    fs::create_dir(&src_dir)?;
177
178    // Generate package.json
179    let package_json = templates::package_json(project_name, template);
180    fs::write(project_dir.join("package.json"), package_json)?;
181    ui::success("  Created package.json");
182
183    // Generate tsconfig.json
184    let tsconfig = templates::tsconfig_json(template);
185    fs::write(project_dir.join("tsconfig.json"), tsconfig)?;
186    ui::success("  Created tsconfig.json");
187
188    // Generate fob.config.json
189    let joy_config = templates::joy_config_json(template);
190    fs::write(project_dir.join("fob.config.json"), joy_config)?;
191    ui::success("  Created fob.config.json");
192
193    // Generate source file
194    let source_file = templates::source_file(template);
195    let source_filename = match template {
196        templates::Template::Library => "index.ts",
197        templates::Template::App => "main.ts",
198        templates::Template::ComponentLibrary => "index.ts",
199        templates::Template::MetaFramework => "index.ts",
200    };
201    fs::write(src_dir.join(source_filename), source_file)?;
202    ui::success(&format!("  Created src/{}", source_filename));
203
204    // Generate template-specific files
205    match template {
206        templates::Template::App => {
207            // Create index.html
208            let index_html = templates::index_html(project_name);
209            fs::write(project_dir.join("index.html"), index_html)?;
210            ui::success("  Created index.html");
211
212            // Create app.css
213            let app_css = templates::app_css();
214            fs::write(src_dir.join("app.css"), app_css)?;
215            ui::success("  Created src/app.css");
216        }
217        templates::Template::Library => {
218            // No additional files for library
219        }
220        templates::Template::ComponentLibrary => {
221            // Create Button.tsx
222            let button_content = templates::button_component();
223            fs::write(src_dir.join("Button.tsx"), button_content)?;
224            ui::success("  Created src/Button.tsx");
225        }
226        templates::Template::MetaFramework => {
227            // Create router.ts and server.ts
228            let router_content = templates::router_module();
229            let server_content = templates::server_module();
230            fs::write(src_dir.join("router.ts"), router_content)?;
231            ui::success("  Created src/router.ts");
232            fs::write(src_dir.join("server.ts"), server_content)?;
233            ui::success("  Created src/server.ts");
234        }
235    }
236
237    // Generate .gitignore
238    let gitignore = templates::gitignore();
239    fs::write(project_dir.join(".gitignore"), gitignore)?;
240    ui::success("  Created .gitignore");
241
242    // Generate README.md
243    let readme = templates::readme(project_name, template);
244    fs::write(project_dir.join("README.md"), readme)?;
245    ui::success("  Created README.md");
246
247    Ok(())
248}
249
250/// Print next steps for the user.
251fn print_next_steps(project_name: &str, pkg_mgr: utils::PackageManager) {
252    eprintln!();
253    ui::info("Next steps:");
254    eprintln!();
255    eprintln!("  cd {}", project_name);
256    eprintln!("  {}", pkg_mgr.install_cmd());
257    eprintln!("  {} run dev", pkg_mgr.command());
258    eprintln!();
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn test_validate_project_name_valid() {
267        assert!(validate_project_name("my-project").is_ok());
268        assert!(validate_project_name("my_project").is_ok());
269        assert!(validate_project_name("project123").is_ok());
270    }
271
272    #[test]
273    fn test_validate_project_name_empty() {
274        assert!(validate_project_name("").is_err());
275    }
276
277    #[test]
278    fn test_validate_project_name_starts_with_dot() {
279        assert!(validate_project_name(".hidden").is_err());
280    }
281
282    #[test]
283    fn test_validate_project_name_starts_with_number() {
284        assert!(validate_project_name("123project").is_err());
285    }
286
287    #[test]
288    fn test_validate_project_name_invalid_chars() {
289        assert!(validate_project_name("my@project").is_err());
290        assert!(validate_project_name("my project").is_err());
291        assert!(validate_project_name("my/project").is_err());
292    }
293
294    #[test]
295    fn test_validate_project_name_reserved() {
296        assert!(validate_project_name("node_modules").is_err());
297        assert!(validate_project_name("favicon.ico").is_err());
298        assert!(validate_project_name("index.js").is_err());
299        assert!(validate_project_name("package.json").is_err());
300    }
301
302    #[test]
303    fn test_select_template_from_args() {
304        let args = InitArgs {
305            name: None,
306            template: Some("library".to_string()),
307            yes: false,
308            use_npm: false,
309            use_yarn: false,
310            use_pnpm: false,
311        };
312
313        assert_eq!(
314            select_template(&args).unwrap(),
315            templates::Template::Library
316        );
317    }
318
319    #[test]
320    fn test_select_template_with_yes_flag() {
321        let args = InitArgs {
322            name: None,
323            template: None,
324            yes: true,
325            use_npm: false,
326            use_yarn: false,
327            use_pnpm: false,
328        };
329
330        assert_eq!(
331            select_template(&args).unwrap(),
332            templates::Template::Library
333        );
334    }
335
336    #[test]
337    fn test_select_template_invalid() {
338        let args = InitArgs {
339            name: None,
340            template: Some("invalid".to_string()),
341            yes: false,
342            use_npm: false,
343            use_yarn: false,
344            use_pnpm: false,
345        };
346
347        assert!(select_template(&args).is_err());
348    }
349}