1use crate::config::{FrontendType, Module, PersistenceType, ProjectConfig, ProjectType};
2use crate::error::{BootsError, Result};
3use crate::template::{TemplateEngine, Templates};
4use std::fs;
5use std::path::Path;
6
7pub struct ProjectGenerator {
8 config: ProjectConfig,
9 engine: TemplateEngine,
10}
11
12impl ProjectGenerator {
13 pub fn new(config: ProjectConfig) -> Self {
14 let mut engine = TemplateEngine::new();
15 engine.set("project_name", &config.name);
16 engine.set("project_name_snake", &config.name.replace('-', "_"));
17
18 Self { config, engine }
19 }
20
21 pub fn generate(&self, base_path: &Path) -> Result<()> {
22 let project_path = base_path.join(&self.config.name);
23
24 if project_path.exists() {
25 return Err(BootsError::DirectoryExists(self.config.name.clone()));
26 }
27
28 fs::create_dir_all(&project_path)?;
29
30 self.create_workspace(&project_path)?;
31 self.create_github_workflows(&project_path)?;
32 self.create_docker(&project_path)?;
33 self.create_makefile(&project_path)?;
34 self.create_readme(&project_path)?;
35 self.create_gitignore(&project_path)?;
36 self.create_rust_toolchain(&project_path)?;
37
38 if self.config.has_grpc {
39 self.create_proto(&project_path)?;
40 }
41
42 if self.config.persistence.is_some() {
43 self.create_env_example(&project_path)?;
44 }
45
46 if self.config.frontend.is_some() {
47 self.create_frontend(&project_path)?;
48 self.create_docker_compose(&project_path)?;
49 }
50
51 for module in self.config.modules() {
52 self.create_module(&project_path, &module)?;
53 }
54
55 if self.config.project_type == ProjectType::Sample {
57 self.create_sample_files(&project_path)?;
58 }
59
60 Ok(())
61 }
62
63 fn create_workspace(&self, path: &Path) -> Result<()> {
64 let template = Templates::get_template("base/Cargo.workspace.toml")
65 .ok_or_else(|| BootsError::Template("Cargo.workspace.toml not found".to_string()))?;
66
67 let modules: Vec<String> = self
68 .config
69 .modules()
70 .iter()
71 .map(|m| format!("\"crates/{}\"", module_name(m)))
72 .collect();
73
74 let authors = if !self.config.author_name.is_empty() || !self.config.author_email.is_empty()
76 {
77 let author =
78 if !self.config.author_name.is_empty() && !self.config.author_email.is_empty() {
79 format!("{} <{}>", self.config.author_name, self.config.author_email)
80 } else if !self.config.author_name.is_empty() {
81 self.config.author_name.clone()
82 } else {
83 format!("<{}>", self.config.author_email)
84 };
85 format!("\"{}\"", author)
86 } else {
87 String::new()
88 };
89
90 let repository = if !self.config.author_name.is_empty() {
92 format!("https://github.com/{}", self.config.name)
93 } else {
94 String::new()
95 };
96
97 let mut engine = TemplateEngine::new();
98 engine.set("project_name", &self.config.name);
99 engine.set("modules", &modules.join(", "));
100 engine.set("authors", &authors);
101 engine.set("repository", &repository);
102
103 let content = engine.render(&template);
104 fs::write(path.join("Cargo.toml"), content)?;
105 Ok(())
106 }
107
108 fn create_github_workflows(&self, path: &Path) -> Result<()> {
109 let workflow_dir = path.join(".github/workflows");
110 fs::create_dir_all(&workflow_dir)?;
111
112 for name in &["build.yml", "test.yml", "release.yml"] {
113 if let Some(template) = Templates::get_template(&format!("github/{}", name)) {
114 let content = self.engine.render(&template);
115 fs::write(workflow_dir.join(name), content)?;
116 }
117 }
118 Ok(())
119 }
120
121 fn create_docker(&self, path: &Path) -> Result<()> {
122 if let Some(template) = Templates::get_template("docker/Dockerfile") {
123 let content = self.engine.render(&template);
124 fs::write(path.join("Dockerfile"), content)?;
125 }
126
127 if let Some(template) = Templates::get_template("docker/dockerignore") {
128 let content = self.engine.render(&template);
129 fs::write(path.join(".dockerignore"), content)?;
130 }
131 Ok(())
132 }
133
134 fn create_makefile(&self, path: &Path) -> Result<()> {
135 let template_path = if self.config.project_type == ProjectType::Sample {
137 "samples/Makefile"
138 } else {
139 "base/Makefile"
140 };
141
142 if let Some(template) = Templates::get_template(template_path) {
143 let content = self.engine.render(&template);
144 fs::write(path.join("Makefile"), content)?;
145 }
146 Ok(())
147 }
148
149 fn create_readme(&self, path: &Path) -> Result<()> {
150 let template_path = if self.config.project_type == ProjectType::Sample {
152 "samples/README.md"
153 } else {
154 "base/README.md"
155 };
156
157 if let Some(template) = Templates::get_template(template_path) {
158 let content = self.engine.render(&template);
159 fs::write(path.join("README.md"), content)?;
160 }
161 Ok(())
162 }
163
164 fn create_gitignore(&self, path: &Path) -> Result<()> {
165 if let Some(template) = Templates::get_template("base/gitignore") {
166 let content = self.engine.render(&template);
167 fs::write(path.join(".gitignore"), content)?;
168 }
169 Ok(())
170 }
171
172 fn create_rust_toolchain(&self, path: &Path) -> Result<()> {
173 if let Some(template) = Templates::get_template("base/rust-toolchain.toml") {
174 let content = self.engine.render(&template);
175 fs::write(path.join("rust-toolchain.toml"), content)?;
176 }
177 Ok(())
178 }
179
180 fn create_proto(&self, path: &Path) -> Result<()> {
181 let proto_dir = path.join("proto");
182 fs::create_dir_all(&proto_dir)?;
183
184 if let Some(template) = Templates::get_template("proto/service.proto") {
185 let mut engine = TemplateEngine::new();
186 engine.set("project_name", &self.config.name);
187 engine.set("project_name_snake", &self.config.name.replace('-', "_"));
188 engine.set("project_name_pascal", &to_pascal_case(&self.config.name));
189
190 let content = engine.render(&template);
191 fs::write(proto_dir.join("service.proto"), content)?;
192 }
193 Ok(())
194 }
195
196 fn create_env_example(&self, path: &Path) -> Result<()> {
197 if let Some(template) = Templates::get_template("base/env.example") {
198 let content = self.engine.render(&template);
199 fs::write(path.join(".env.example"), content)?;
200 }
201 Ok(())
202 }
203
204 fn create_module(&self, path: &Path, module: &Module) -> Result<()> {
205 let module_name = module_name(module);
206 let module_dir = path.join("crates").join(&module_name);
207 fs::create_dir_all(&module_dir)?;
208
209 self.create_module_cargo(&module_dir, module)?;
210 self.create_module_src(&module_dir, module)?;
211
212 if *module == Module::Core {
213 self.create_examples(&module_dir)?;
214 }
215
216 if *module == Module::Persistence && self.config.persistence.is_some() {
217 self.create_migrations(&module_dir)?;
218 }
219
220 Ok(())
221 }
222
223 fn create_module_cargo(&self, path: &Path, module: &Module) -> Result<()> {
224 let module_name_str = module_name(module);
225
226 let template_path = if *module == Module::Cli {
227 match self.config.project_type {
228 ProjectType::Sample => "samples/cli/Cargo.toml".to_string(),
229 ProjectType::Service => "modules/cli/Cargo_service.toml".to_string(),
230 _ => format!("modules/{}/Cargo.toml", module_name_str),
231 }
232 } else {
233 format!("modules/{}/Cargo.toml", module_name_str)
234 };
235
236 if let Some(template) = Templates::get_template(&template_path) {
237 let mut engine = TemplateEngine::new();
238 engine.set("project_name", &self.config.name);
239 engine.set("module_name", &module_name_str);
240
241 if *module == Module::Persistence {
242 let persistence_deps = match self.config.persistence {
243 Some(PersistenceType::Postgres) => {
244 r#"sqlx = { version = "0.7", features = ["runtime-tokio", "postgres"] }"#
245 }
246 Some(PersistenceType::Sqlite) => {
247 r#"sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite"] }"#
248 }
249 _ => "",
250 };
251 engine.set("persistence_deps", persistence_deps);
252 }
253
254 if *module == Module::Api && self.config.has_grpc {
255 engine.set(
256 "grpc_deps",
257 r#"tonic = "0.11"
258prost = "0.12""#,
259 );
260 engine.set(
261 "build_deps",
262 r#"
263[build-dependencies]
264tonic-build = "0.11""#,
265 );
266 } else {
267 engine.set("grpc_deps", "");
268 engine.set("build_deps", "");
269 }
270
271 let content = engine.render(&template);
272 fs::write(path.join("Cargo.toml"), content)?;
273 }
274 Ok(())
275 }
276
277 fn create_module_src(&self, path: &Path, module: &Module) -> Result<()> {
278 let module_name_str = module_name(module);
279 let src_dir = path.join("src");
280 fs::create_dir_all(&src_dir)?;
281
282 let main_file = if *module == Module::Cli {
283 "main.rs"
284 } else {
285 "lib.rs"
286 };
287
288 let template_path = if *module == Module::Cli {
289 match self.config.project_type {
290 ProjectType::Sample => "samples/cli/main.rs".to_string(),
291 ProjectType::Service => "modules/cli/main_service.rs".to_string(),
292 _ => format!("modules/{}/{}", module_name_str, main_file),
293 }
294 } else {
295 format!("modules/{}/{}", module_name_str, main_file)
296 };
297
298 if let Some(template) = Templates::get_template(&template_path) {
299 let content = self.engine.render(&template);
300 fs::write(src_dir.join(main_file), content)?;
301 }
302
303 if *module == Module::Core {
304 self.create_core_files(&src_dir)?;
305 }
306
307 if *module == Module::Api {
308 self.create_api_files(&src_dir)?;
309 }
310
311 if *module == Module::Runtime {
312 self.create_runtime_files(&src_dir)?;
313 }
314
315 if *module == Module::Client {
316 self.create_client_files(&src_dir)?;
317 }
318
319 Ok(())
320 }
321
322 fn create_core_files(&self, src_dir: &Path) -> Result<()> {
323 if let Some(template) = Templates::get_template("modules/core/error.rs") {
324 let content = self.engine.render(&template);
325 fs::write(src_dir.join("error.rs"), content)?;
326 }
327 Ok(())
328 }
329
330 fn create_api_files(&self, src_dir: &Path) -> Result<()> {
331 let (routes_path, handlers_path) = if self.config.project_type == ProjectType::Sample {
332 ("samples/api/routes.rs", "samples/api/handlers/mod.rs")
333 } else {
334 ("modules/api/routes.rs", "modules/api/handlers/mod.rs")
335 };
336
337 if let Some(template) = Templates::get_template(routes_path) {
338 let content = self.engine.render(&template);
339 fs::write(src_dir.join("routes.rs"), content)?;
340 }
341
342 let handlers_dir = src_dir.join("handlers");
343 fs::create_dir_all(&handlers_dir)?;
344
345 if let Some(template) = Templates::get_template(handlers_path) {
346 let content = self.engine.render(&template);
347 fs::write(handlers_dir.join("mod.rs"), content)?;
348 }
349
350 if self.config.has_grpc
352 && let Some(template) = Templates::get_template("modules/api/build.rs")
353 {
354 let content = self.engine.render(&template);
355 if let Some(module_dir) = src_dir.parent() {
357 fs::write(module_dir.join("build.rs"), content)?;
358 }
359 }
360
361 Ok(())
362 }
363
364 fn create_runtime_files(&self, src_dir: &Path) -> Result<()> {
365 if let Some(template) = Templates::get_template("modules/runtime/server.rs") {
366 let content = self.engine.render(&template);
367 fs::write(src_dir.join("server.rs"), content)?;
368 }
369 Ok(())
370 }
371
372 fn create_client_files(&self, src_dir: &Path) -> Result<()> {
373 if let Some(template) = Templates::get_template("modules/client/http.rs") {
374 let content = self.engine.render(&template);
375 fs::write(src_dir.join("http.rs"), content)?;
376 }
377 Ok(())
378 }
379
380 fn create_examples(&self, module_dir: &Path) -> Result<()> {
381 let examples_dir = module_dir.join("examples");
382 fs::create_dir_all(&examples_dir)?;
383
384 if let Some(template) = Templates::get_template("modules/core/examples/basic.rs") {
385 let content = self.engine.render(&template);
386 fs::write(examples_dir.join("basic.rs"), content)?;
387 }
388 Ok(())
389 }
390
391 fn create_migrations(&self, module_dir: &Path) -> Result<()> {
392 let migrations_dir = module_dir.join("migrations");
393 fs::create_dir_all(&migrations_dir)?;
394
395 fs::write(migrations_dir.join(".gitkeep"), "")?;
396 Ok(())
397 }
398
399 fn create_frontend(&self, path: &Path) -> Result<()> {
400 let frontend_type = match self.config.frontend {
401 Some(FrontendType::Spa) => "spa",
402 Some(FrontendType::Ssr) => "ssr",
403 None => return Ok(()),
404 };
405
406 let frontend_dir = path.join("frontend");
407 fs::create_dir_all(&frontend_dir)?;
408
409 let template_prefix = format!("frontend/{}/", frontend_type);
411
412 if let Some(template) = Templates::get_template(&format!("{}package.json", template_prefix))
414 {
415 let content = self.engine.render(&template);
416 fs::write(frontend_dir.join("package.json"), content)?;
417 }
418
419 if let Some(template) =
421 Templates::get_template(&format!("{}tsconfig.json", template_prefix))
422 {
423 fs::write(frontend_dir.join("tsconfig.json"), template)?;
424 }
425
426 if let Some(template) = Templates::get_template(&format!("{}Dockerfile", template_prefix)) {
428 fs::write(frontend_dir.join("Dockerfile"), template)?;
429 }
430
431 if let Some(template) = Templates::get_template(&format!("{}dockerignore", template_prefix))
433 {
434 fs::write(frontend_dir.join(".dockerignore"), template)?;
435 }
436
437 match self.config.frontend {
438 Some(FrontendType::Spa) => self.create_spa_files(&frontend_dir)?,
439 Some(FrontendType::Ssr) => self.create_ssr_files(&frontend_dir)?,
440 None => {}
441 }
442
443 Ok(())
444 }
445
446 fn create_spa_files(&self, frontend_dir: &Path) -> Result<()> {
447 if let Some(template) = Templates::get_template("frontend/spa/vite.config.ts") {
449 fs::write(frontend_dir.join("vite.config.ts"), template)?;
450 }
451
452 if let Some(template) = Templates::get_template("frontend/spa/index.html") {
454 let content = self.engine.render(&template);
455 fs::write(frontend_dir.join("index.html"), content)?;
456 }
457
458 if let Some(template) = Templates::get_template("frontend/spa/nginx.conf") {
460 fs::write(frontend_dir.join("nginx.conf"), template)?;
461 }
462
463 let src_dir = frontend_dir.join("src");
465 fs::create_dir_all(&src_dir)?;
466
467 if let Some(template) = Templates::get_template("frontend/spa/src/main.tsx") {
468 fs::write(src_dir.join("main.tsx"), template)?;
469 }
470
471 if let Some(template) = Templates::get_template("frontend/spa/src/App.tsx") {
472 let content = self.engine.render(&template);
473 fs::write(src_dir.join("App.tsx"), content)?;
474 }
475
476 if let Some(template) = Templates::get_template("frontend/spa/src/vite-env.d.ts") {
477 fs::write(src_dir.join("vite-env.d.ts"), template)?;
478 }
479
480 Ok(())
481 }
482
483 fn create_ssr_files(&self, frontend_dir: &Path) -> Result<()> {
484 if let Some(template) = Templates::get_template("frontend/ssr/next.config.ts") {
486 fs::write(frontend_dir.join("next.config.ts"), template)?;
487 }
488
489 let app_dir = frontend_dir.join("app");
491 fs::create_dir_all(&app_dir)?;
492
493 if let Some(template) = Templates::get_template("frontend/ssr/app/layout.tsx") {
494 let content = self.engine.render(&template);
495 fs::write(app_dir.join("layout.tsx"), content)?;
496 }
497
498 if let Some(template) = Templates::get_template("frontend/ssr/app/page.tsx") {
499 let content = self.engine.render(&template);
500 fs::write(app_dir.join("page.tsx"), content)?;
501 }
502
503 if let Some(template) = Templates::get_template("frontend/ssr/app/globals.css") {
504 fs::write(app_dir.join("globals.css"), template)?;
505 }
506
507 Ok(())
508 }
509
510 fn create_docker_compose(&self, path: &Path) -> Result<()> {
511 if let Some(template) = Templates::get_template("base/docker-compose.yml") {
512 let frontend_service = match self.config.frontend {
513 Some(FrontendType::Spa) => {
514 Templates::get_template("frontend/spa/docker-compose.service.yml")
515 .unwrap_or_default()
516 }
517 Some(FrontendType::Ssr) => {
518 Templates::get_template("frontend/ssr/docker-compose.service.yml")
519 .unwrap_or_default()
520 }
521 None => String::new(),
522 };
523
524 let mut engine = TemplateEngine::new();
525 engine.set("frontend_service", &frontend_service);
526
527 let content = engine.render(&template);
528 fs::write(path.join("docker-compose.yml"), content)?;
529 }
530 Ok(())
531 }
532
533 fn create_sample_files(&self, path: &Path) -> Result<()> {
535 self.create_board_module(path)?;
537
538 self.create_e2e_tests(path)?;
540
541 self.create_sample_docs(path)?;
543
544 self.create_sample_docker_compose(path)?;
546
547 Ok(())
548 }
549
550 fn create_board_module(&self, path: &Path) -> Result<()> {
551 let board_dir = path.join("crates/core/src/board");
552 fs::create_dir_all(&board_dir)?;
553
554 if let Some(template) = Templates::get_template("samples/board/mod.rs") {
556 let content = self.engine.render(&template);
557 fs::write(board_dir.join("mod.rs"), content)?;
558 }
559
560 if let Some(template) = Templates::get_template("samples/board/models.rs") {
562 let content = self.engine.render(&template);
563 fs::write(board_dir.join("models.rs"), content)?;
564 }
565
566 if let Some(template) = Templates::get_template("samples/board/permission.rs") {
568 let content = self.engine.render(&template);
569 fs::write(board_dir.join("permission.rs"), content)?;
570 }
571
572 Ok(())
573 }
574
575 fn create_e2e_tests(&self, path: &Path) -> Result<()> {
576 let e2e_dir = path.join("e2e");
577 fs::create_dir_all(&e2e_dir)?;
578
579 if let Some(template) = Templates::get_template("samples/e2e/playwright.config.ts") {
581 let content = self.engine.render(&template);
582 fs::write(e2e_dir.join("playwright.config.ts"), content)?;
583 }
584
585 if let Some(template) = Templates::get_template("samples/e2e/package.json") {
587 let content = self.engine.render(&template);
588 fs::write(e2e_dir.join("package.json"), content)?;
589 }
590
591 let helpers_dir = e2e_dir.join("helpers");
593 fs::create_dir_all(&helpers_dir)?;
594
595 if let Some(template) = Templates::get_template("samples/e2e/helpers/auth.ts") {
596 fs::write(helpers_dir.join("auth.ts"), template)?;
597 }
598
599 let tests_dir = e2e_dir.join("tests");
601 fs::create_dir_all(&tests_dir)?;
602
603 if let Some(template) = Templates::get_template("samples/e2e/tests/posts.spec.ts") {
604 fs::write(tests_dir.join("posts.spec.ts"), template)?;
605 }
606
607 let fixtures_dir = e2e_dir.join("fixtures");
609 fs::create_dir_all(&fixtures_dir)?;
610 fs::write(fixtures_dir.join(".gitkeep"), "")?;
611
612 Ok(())
613 }
614
615 fn create_sample_docs(&self, path: &Path) -> Result<()> {
616 let docs_dir = path.join("docs");
617 fs::create_dir_all(&docs_dir)?;
618
619 if let Some(template) = Templates::get_template("samples/docs/api.md") {
621 let content = self.engine.render(&template);
622 fs::write(docs_dir.join("api.md"), content)?;
623 }
624
625 if let Some(template) = Templates::get_template("samples/docs/architecture.md") {
627 let content = self.engine.render(&template);
628 fs::write(docs_dir.join("architecture.md"), content)?;
629 }
630
631 if let Some(template) = Templates::get_template("samples/docs/e2e-testing.md") {
633 let content = self.engine.render(&template);
634 fs::write(docs_dir.join("e2e-testing.md"), content)?;
635 }
636
637 Ok(())
638 }
639
640 fn create_sample_docker_compose(&self, path: &Path) -> Result<()> {
641 if let Some(template) = Templates::get_template("samples/docker-compose.yml") {
643 let content = self.engine.render(&template);
644 fs::write(path.join("docker-compose.yml"), content)?;
645 }
646 Ok(())
647 }
648}
649
650fn module_name(module: &Module) -> String {
651 match module {
652 Module::Core => "core".to_string(),
653 Module::Api => "api".to_string(),
654 Module::Runtime => "runtime".to_string(),
655 Module::Cli => "cli".to_string(),
656 Module::Client => "client".to_string(),
657 Module::Persistence => "persistence".to_string(),
658 }
659}
660
661fn to_pascal_case(s: &str) -> String {
662 s.split(['-', '_'])
663 .map(|word| {
664 let mut chars = word.chars();
665 match chars.next() {
666 None => String::new(),
667 Some(first) => first.to_uppercase().chain(chars).collect(),
668 }
669 })
670 .collect()
671}