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    if name.starts_with('.') || name.chars().next().unwrap().is_ascii_digit() {
133        return Err(CliError::InvalidArgument(
134            "Project name cannot start with a dot or number".to_string(),
135        ));
136    }
137
138    Ok(())
139}
140
141/// Select template based on args or interactive prompt.
142///
143/// Validates that the user-provided template name is valid and returns the
144/// corresponding Template enum variant. Security: Input validation prevents
145/// arbitrary file system operations.
146fn select_template(args: &InitArgs) -> Result<templates::Template> {
147    if let Some(ref template_name) = args.template {
148        templates::Template::from_str(template_name).ok_or_else(|| {
149            CliError::InvalidArgument(format!(
150                "Invalid template '{}'. Available: library, app, component-library, meta-framework",
151                template_name
152            ))
153        })
154    } else if args.yes {
155        // Default to library when using --yes
156        Ok(templates::Template::Library)
157    } else {
158        // In a real implementation, this would show an interactive prompt
159        // For now, default to library
160        ui::info("Defaulting to 'library' template (use --template to specify)");
161        Ok(templates::Template::Library)
162    }
163}
164
165/// Generate all project files from template.
166fn generate_project_files(
167    project_dir: &Path,
168    project_name: &str,
169    template: templates::Template,
170) -> Result<()> {
171    ui::info("Generating files...");
172
173    // Create src directory
174    let src_dir = project_dir.join("src");
175    fs::create_dir(&src_dir)?;
176
177    // Generate package.json
178    let package_json = templates::package_json(project_name, template);
179    fs::write(project_dir.join("package.json"), package_json)?;
180    ui::success("  Created package.json");
181
182    // Generate tsconfig.json
183    let tsconfig = templates::tsconfig_json(template);
184    fs::write(project_dir.join("tsconfig.json"), tsconfig)?;
185    ui::success("  Created tsconfig.json");
186
187    // Generate fob.config.json
188    let joy_config = templates::joy_config_json(template);
189    fs::write(project_dir.join("fob.config.json"), joy_config)?;
190    ui::success("  Created fob.config.json");
191
192    // Generate source file
193    let source_file = templates::source_file(template);
194    let source_filename = match template {
195        templates::Template::Library => "index.ts",
196        templates::Template::App => "main.ts",
197        templates::Template::ComponentLibrary => "index.ts",
198        templates::Template::MetaFramework => "index.ts",
199    };
200    fs::write(src_dir.join(source_filename), source_file)?;
201    ui::success(&format!("  Created src/{}", source_filename));
202
203    // Generate template-specific files
204    match template {
205        templates::Template::App => {
206            // Create index.html
207            let index_html = templates::index_html(project_name);
208            fs::write(project_dir.join("index.html"), index_html)?;
209            ui::success("  Created index.html");
210
211            // Create app.css
212            let app_css = templates::app_css();
213            fs::write(src_dir.join("app.css"), app_css)?;
214            ui::success("  Created src/app.css");
215        }
216        templates::Template::Library => {
217            // No additional files for library
218        }
219        templates::Template::ComponentLibrary => {
220            // Create Button.tsx
221            let button_content = templates::button_component();
222            fs::write(src_dir.join("Button.tsx"), button_content)?;
223            ui::success("  Created src/Button.tsx");
224        }
225        templates::Template::MetaFramework => {
226            // Create router.ts and server.ts
227            let router_content = templates::router_module();
228            let server_content = templates::server_module();
229            fs::write(src_dir.join("router.ts"), router_content)?;
230            ui::success("  Created src/router.ts");
231            fs::write(src_dir.join("server.ts"), server_content)?;
232            ui::success("  Created src/server.ts");
233        }
234    }
235
236    // Generate .gitignore
237    let gitignore = templates::gitignore();
238    fs::write(project_dir.join(".gitignore"), gitignore)?;
239    ui::success("  Created .gitignore");
240
241    // Generate README.md
242    let readme = templates::readme(project_name, template);
243    fs::write(project_dir.join("README.md"), readme)?;
244    ui::success("  Created README.md");
245
246    Ok(())
247}
248
249/// Print next steps for the user.
250fn print_next_steps(project_name: &str, pkg_mgr: utils::PackageManager) {
251    eprintln!();
252    ui::info("Next steps:");
253    eprintln!();
254    eprintln!("  cd {}", project_name);
255    eprintln!("  {}", pkg_mgr.install_cmd());
256    eprintln!("  {} run dev", pkg_mgr.command());
257    eprintln!();
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn test_validate_project_name_valid() {
266        assert!(validate_project_name("my-project").is_ok());
267        assert!(validate_project_name("my_project").is_ok());
268        assert!(validate_project_name("project123").is_ok());
269    }
270
271    #[test]
272    fn test_validate_project_name_empty() {
273        assert!(validate_project_name("").is_err());
274    }
275
276    #[test]
277    fn test_validate_project_name_starts_with_dot() {
278        assert!(validate_project_name(".hidden").is_err());
279    }
280
281    #[test]
282    fn test_validate_project_name_starts_with_number() {
283        assert!(validate_project_name("123project").is_err());
284    }
285
286    #[test]
287    fn test_validate_project_name_invalid_chars() {
288        assert!(validate_project_name("my@project").is_err());
289        assert!(validate_project_name("my project").is_err());
290        assert!(validate_project_name("my/project").is_err());
291    }
292
293    #[test]
294    fn test_validate_project_name_reserved() {
295        assert!(validate_project_name("node_modules").is_err());
296        assert!(validate_project_name("favicon.ico").is_err());
297        assert!(validate_project_name("index.js").is_err());
298        assert!(validate_project_name("package.json").is_err());
299    }
300
301    #[test]
302    fn test_select_template_from_args() {
303        let args = InitArgs {
304            name: None,
305            template: Some("library".to_string()),
306            yes: false,
307            use_npm: false,
308            use_yarn: false,
309            use_pnpm: false,
310        };
311
312        assert_eq!(
313            select_template(&args).unwrap(),
314            templates::Template::Library
315        );
316    }
317
318    #[test]
319    fn test_select_template_with_yes_flag() {
320        let args = InitArgs {
321            name: None,
322            template: None,
323            yes: true,
324            use_npm: false,
325            use_yarn: false,
326            use_pnpm: false,
327        };
328
329        assert_eq!(
330            select_template(&args).unwrap(),
331            templates::Template::Library
332        );
333    }
334
335    #[test]
336    fn test_select_template_invalid() {
337        let args = InitArgs {
338            name: None,
339            template: Some("invalid".to_string()),
340            yes: false,
341            use_npm: false,
342            use_yarn: false,
343            use_pnpm: false,
344        };
345
346        assert!(select_template(&args).is_err());
347    }
348}