1use crate::cli::InitArgs;
6use crate::commands::{templates, utils};
7use crate::error::{CliError, Result};
8use crate::ui;
9use std::fs;
10use std::path::Path;
11
12pub async fn execute(args: InitArgs) -> Result<()> {
34 let project_name = determine_project_name(&args)?;
36 validate_project_name(&project_name)?;
37
38 ui::info(&format!("Creating project: {}", project_name));
39
40 let template = select_template(&args)?;
42 ui::info(&format!("Using template: {}", template.name()));
43
44 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 generate_project_files(project_dir, &project_name, template)?;
58
59 let pkg_mgr = if args.use_pnpm {
61 utils::PackageManager::Pnpm
62 } else if args.use_yarn {
63 utils::PackageManager::Yarn
64 } else {
65 utils::PackageManager::Npm
67 };
68
69 print_next_steps(&project_name, pkg_mgr);
71
72 ui::success("Project created successfully!");
73 Ok(())
74}
75
76fn determine_project_name(args: &InitArgs) -> Result<String> {
78 if let Some(ref name) = args.name {
79 Ok(name.clone())
80 } else {
81 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
91const 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
105fn 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 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 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 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
141fn 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 Ok(templates::Template::Library)
157 } else {
158 ui::info("Defaulting to 'library' template (use --template to specify)");
161 Ok(templates::Template::Library)
162 }
163}
164
165fn generate_project_files(
167 project_dir: &Path,
168 project_name: &str,
169 template: templates::Template,
170) -> Result<()> {
171 ui::info("Generating files...");
172
173 let src_dir = project_dir.join("src");
175 fs::create_dir(&src_dir)?;
176
177 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 let tsconfig = templates::tsconfig_json(template);
184 fs::write(project_dir.join("tsconfig.json"), tsconfig)?;
185 ui::success(" Created tsconfig.json");
186
187 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 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 match template {
205 templates::Template::App => {
206 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 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 }
219 templates::Template::ComponentLibrary => {
220 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 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 let gitignore = templates::gitignore();
238 fs::write(project_dir.join(".gitignore"), gitignore)?;
239 ui::success(" Created .gitignore");
240
241 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
249fn 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}