boots_core/generator/
project.rs

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        // Sample project specific files
56        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        // Build authors string: "Name <email>" or empty array
75        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        // Build repository string: empty if no author info
91        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        // Use sample-specific Makefile for Sample projects
136        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        // Use sample-specific README for Sample projects
151        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        // Create build.rs for gRPC
351        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            // build.rs should be in the module directory, not src
356            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        // Copy frontend template files
410        let template_prefix = format!("frontend/{}/", frontend_type);
411
412        // package.json
413        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        // tsconfig.json
420        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        // Dockerfile
427        if let Some(template) = Templates::get_template(&format!("{}Dockerfile", template_prefix)) {
428            fs::write(frontend_dir.join("Dockerfile"), template)?;
429        }
430
431        // .dockerignore
432        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        // vite.config.ts
448        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        // index.html
453        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        // nginx.conf
459        if let Some(template) = Templates::get_template("frontend/spa/nginx.conf") {
460            fs::write(frontend_dir.join("nginx.conf"), template)?;
461        }
462
463        // src directory
464        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        // next.config.ts
485        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        // app directory
490        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    /// Create sample project specific files (board application)
534    fn create_sample_files(&self, path: &Path) -> Result<()> {
535        // Create board module in core
536        self.create_board_module(path)?;
537
538        // Create E2E test directory
539        self.create_e2e_tests(path)?;
540
541        // Create docs directory
542        self.create_sample_docs(path)?;
543
544        // Create sample-specific docker-compose with MinIO
545        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        // board/mod.rs
555        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        // board/models.rs
561        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        // board/permission.rs
567        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        // playwright.config.ts
580        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        // package.json
586        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        // helpers directory
592        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        // tests directory
600        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        // fixtures directory
608        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        // docs/api.md
620        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        // docs/architecture.md
626        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        // docs/e2e-testing.md
632        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        // Override docker-compose with sample version (includes MinIO)
642        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}