1use anyhow::{anyhow, Context, Result};
4use colored::Colorize;
5use dialoguer::{console::Term, theme::ColorfulTheme, Confirm, Input, MultiSelect, Select};
6use std::collections::BTreeSet;
7use std::fs;
8use std::path::{Path, PathBuf};
9use toml_edit::{Array, InlineTable, Item, Table, Value};
10
11use crate::cli::{
12 BackendPreset, DbBackend, NewArgs, NewPreset, ResourceArgs, ResourceIdType,
13};
14use crate::templates::{BackendTemplateContext, BackendTemplateEngine};
15use crate::{
16 ensure_dir, is_json_output, print_info, print_success, print_warning, write_file,
17 TIDEWAY_VERSION,
18};
19
20#[derive(Default)]
21struct WizardOptions {
22 backend_preset: Option<BackendPreset>,
23 resource: Option<ResourceWizardOptions>,
24}
25
26struct ResourceWizardOptions {
27 name: String,
28 db: bool,
29 repo: bool,
30 repo_tests: bool,
31 service: bool,
32 paginate: bool,
33 search: bool,
34 with_tests: bool,
35}
36
37pub fn run(mut args: NewArgs) -> Result<()> {
39 if let Some(NewPreset::List) = args.preset {
40 print_presets();
41 return Ok(());
42 }
43
44 if let Some(preset) = args.preset {
45 apply_preset(preset, &mut args);
46 }
47
48 let name = args
49 .name
50 .clone()
51 .ok_or_else(|| anyhow!("Project name is required (e.g. `tideway new my_app`)"))?;
52
53 let mut wizard = WizardOptions::default();
54 if should_prompt(&args) {
55 wizard = prompt_for_options(&mut args)?;
56 if let Some(preset) = args.preset {
57 apply_preset(preset, &mut args);
58 }
59 }
60
61 let dir_name = args.path.clone().unwrap_or_else(|| name.clone());
62 let project_name = normalize_project_name(&name);
63 let project_name_pascal = to_pascal_case(&project_name);
64 let features = normalize_features(&args.features);
65 let has_auth_feature = features.contains("auth");
66 let has_database_feature = features.contains("database");
67 let has_openapi_feature = features.contains("openapi");
68 let has_tideway_features = !features.is_empty();
69
70 let target_dir = PathBuf::from(&dir_name);
71 if target_dir.exists() {
72 if !args.force {
73 return Err(anyhow!(
74 "Destination already exists: {} (use --force to overwrite)",
75 target_dir.display()
76 ));
77 }
78 print_warning(&format!(
79 "Destination exists, files may be overwritten: {}",
80 target_dir.display()
81 ));
82 }
83
84 ensure_dir(&target_dir)
85 .with_context(|| format!("Failed to create {}", target_dir.display()))?;
86
87 let needs_arc = has_auth_feature || has_database_feature;
88 let context = BackendTemplateContext {
89 project_name: project_name.clone(),
90 project_name_pascal,
91 has_organizations: false,
92 database: "postgres".to_string(),
93 tideway_version: TIDEWAY_VERSION.to_string(),
94 tideway_features: features.iter().cloned().collect(),
95 has_tideway_features,
96 has_auth_feature,
97 has_database_feature,
98 has_openapi_feature,
99 needs_arc,
100 has_config: args.with_config,
101 };
102 let engine = BackendTemplateEngine::new(context)?;
103
104 let needs_env = needs_env_from_args(&args);
105 scaffold_files(&target_dir, &engine, &args, needs_env)?;
106 if matches!(args.preset, Some(NewPreset::Api)) {
107 scaffold_api_preset(&target_dir)?;
108 }
109 if let Some(backend_preset) = wizard.backend_preset {
110 scaffold_backend_preset(&target_dir, &project_name, backend_preset)?;
111 ensure_backend_dependencies(&target_dir.join("Cargo.toml"))?;
112 }
113 if let Some(resource) = wizard.resource {
114 scaffold_wizard_resource(&target_dir, resource)?;
115 }
116 let created = expected_files(&args);
117
118 if !is_json_output() {
119 println!(
120 "\n{} {}\n",
121 "tideway".cyan().bold(),
122 "starter app created".green().bold()
123 );
124 }
125
126 print_info(&format!("Project name: {}", project_name.green()));
127 print_info(&format!("Location: {}", target_dir.display().to_string().yellow()));
128 if let Some(preset) = args.preset {
129 print_info(&format!("Preset: {}", preset_label(preset).green()));
130 }
131 if has_tideway_features {
132 print_info(&format!(
133 "Tideway features: {}",
134 features
135 .iter()
136 .map(|s| s.as_str())
137 .collect::<Vec<_>>()
138 .join(", ")
139 .green()
140 ));
141 }
142
143 if !is_json_output() {
144 if args.summary {
145 println!("\n{}", "Generated files:".yellow().bold());
146 for path in &created {
147 println!(" - {}", path);
148 }
149 }
150
151 println!("\n{}", "Next steps:".yellow().bold());
152 println!(" 1. cd {}", dir_name);
153 let mut step = 2;
154 if args.with_docker {
155 println!(" {}. docker compose up -d", step);
156 step += 1;
157 }
158 if has_auth_feature || has_database_feature || args.with_config {
159 println!(" {}. cp .env.example .env", step);
160 step += 1;
161 }
162 if has_database_feature {
163 println!(" {}. tideway migrate", step);
164 step += 1;
165 }
166 println!(" {}. cargo run", step);
167 println!();
168
169 if matches!(args.preset, Some(NewPreset::Api)) {
170 println!("{}", "First request:".yellow().bold());
171 println!(" curl http://localhost:8000/api/todos");
172 println!(" # OpenAPI (if enabled): http://localhost:8000/docs");
173 println!();
174 }
175 }
176
177 print_success("Ready to build");
178 Ok(())
179}
180
181fn scaffold_files(
182 target_dir: &Path,
183 engine: &BackendTemplateEngine,
184 args: &NewArgs,
185 needs_env: bool,
186) -> Result<()> {
187 let has_auth_feature = normalize_features(&args.features).contains("auth");
188 let is_api_preset = matches!(args.preset, Some(NewPreset::Api));
189
190 write_file_with_force(
191 &target_dir.join("Cargo.toml"),
192 &engine.render("starter/Cargo.toml")?,
193 args.force,
194 )?;
195 write_file_with_force(
196 &target_dir.join("src/main.rs"),
197 &engine.render("starter/src/main.rs")?,
198 args.force,
199 )?;
200 write_file_with_force(
201 &target_dir.join("src/routes/mod.rs"),
202 &engine.render("starter/src/routes/mod.rs")?,
203 args.force,
204 )?;
205
206 if has_auth_feature {
207 write_file_with_force(
208 &target_dir.join("src/auth/mod.rs"),
209 &engine.render("starter/src/auth/mod.rs")?,
210 args.force,
211 )?;
212 write_file_with_force(
213 &target_dir.join("src/auth/provider.rs"),
214 &engine.render("starter/src/auth/provider.rs")?,
215 args.force,
216 )?;
217 write_file_with_force(
218 &target_dir.join("src/auth/routes.rs"),
219 &engine.render("starter/src/auth/routes.rs")?,
220 args.force,
221 )?;
222 }
223
224 if args.with_config {
225 write_file_with_force(
226 &target_dir.join("src/config.rs"),
227 &engine.render("starter/src/config.rs")?,
228 args.force,
229 )?;
230 write_file_with_force(
231 &target_dir.join("src/error.rs"),
232 &engine.render("starter/src/error.rs")?,
233 args.force,
234 )?;
235 }
236 if args.with_docker {
237 write_file_with_force(
238 &target_dir.join("docker-compose.yml"),
239 &engine.render("starter/docker-compose")?,
240 args.force,
241 )?;
242 }
243 if args.with_ci {
244 write_file_with_force(
245 &target_dir.join(".github/workflows/ci.yml"),
246 &engine.render("starter/github-ci")?,
247 args.force,
248 )?;
249 }
250 write_file_with_force(
251 &target_dir.join(".gitignore"),
252 &engine.render("starter/gitignore")?,
253 args.force,
254 )?;
255
256 write_file_with_force(
257 &target_dir.join("tests/health.rs"),
258 &engine.render("starter/tests/health")?,
259 args.force,
260 )?;
261
262 if needs_env {
263 write_file_with_force(
264 &target_dir.join(".env.example"),
265 &engine.render("starter/env_example")?,
266 args.force,
267 )?;
268 }
269
270 if is_api_preset {
271 write_file_with_force(
272 &target_dir.join("migration/Cargo.toml"),
273 &engine.render("starter/migration/Cargo.toml")?,
274 args.force,
275 )?;
276 write_file_with_force(
277 &target_dir.join("migration/src/lib.rs"),
278 &engine.render("starter/migration/src/lib.rs")?,
279 args.force,
280 )?;
281 }
282
283 Ok(())
284}
285
286pub fn expected_files(args: &NewArgs) -> Vec<String> {
287 let needs_env = needs_env_from_args(args);
288 let has_auth_feature = normalize_features(&args.features).contains("auth");
289 let mut files = vec![
290 "Cargo.toml".to_string(),
291 "src/main.rs".to_string(),
292 "src/routes/mod.rs".to_string(),
293 ];
294
295 if has_auth_feature {
296 files.push("src/auth/mod.rs".to_string());
297 files.push("src/auth/provider.rs".to_string());
298 files.push("src/auth/routes.rs".to_string());
299 }
300
301 if args.with_config {
302 files.push("src/config.rs".to_string());
303 files.push("src/error.rs".to_string());
304 }
305 if args.with_docker {
306 files.push("docker-compose.yml".to_string());
307 }
308 if args.with_ci {
309 files.push(".github/workflows/ci.yml".to_string());
310 }
311
312 files.push(".gitignore".to_string());
313 files.push("tests/health.rs".to_string());
314
315 if needs_env {
316 files.push(".env.example".to_string());
317 }
318
319 if matches!(args.preset, Some(NewPreset::Api)) {
320 files.push("migration/Cargo.toml".to_string());
321 files.push("migration/src/lib.rs".to_string());
322 files.push("migration/src/m001_create_todos.rs".to_string());
323 files.push("src/entities/mod.rs".to_string());
324 files.push("src/entities/todo.rs".to_string());
325 files.push("src/routes/todo.rs".to_string());
326 files.push("src/openapi_docs.rs".to_string());
327 }
328
329 files
330}
331fn write_file_with_force(path: &Path, contents: &str, force: bool) -> Result<()> {
332 if path.exists() && !force {
333 return Err(anyhow!(
334 "File already exists: {} (use --force to overwrite)",
335 path.display()
336 ));
337 }
338
339 if let Some(parent) = path.parent() {
340 ensure_dir(parent)
341 .with_context(|| format!("Failed to create {}", parent.display()))?;
342 }
343
344 write_file(path, contents)
345 .with_context(|| format!("Failed to write {}", path.display()))?;
346 Ok(())
347}
348
349fn normalize_project_name(name: &str) -> String {
350 name.trim().replace('-', "_")
351}
352
353fn normalize_features(features: &[String]) -> BTreeSet<String> {
354 let mut normalized = BTreeSet::new();
355 for feature in features {
356 let trimmed = feature.trim();
357 if trimmed.is_empty() {
358 continue;
359 }
360 let lowered = trimmed.to_lowercase();
361 let mapped = match lowered.as_str() {
362 "db" => "database",
363 "session" => "sessions",
364 other => other,
365 };
366 normalized.insert(mapped.to_string());
367 }
368 normalized
369}
370
371fn apply_preset(preset: NewPreset, args: &mut NewArgs) {
372 let preset_features: &[&str] = match preset {
373 NewPreset::Minimal => &[],
374 NewPreset::Api => &["auth", "database", "openapi", "validation"],
375 NewPreset::List => &[],
376 };
377
378 for feature in preset_features {
379 if !args.features.iter().any(|item| item.eq_ignore_ascii_case(feature)) {
380 args.features.push(feature.to_string());
381 }
382 }
383
384 if preset == NewPreset::Api {
385 args.with_config = true;
386 args.with_docker = true;
387 args.with_ci = true;
388 args.with_env = true;
389 }
390}
391
392fn apply_backend_defaults(args: &mut NewArgs, has_organizations: bool) {
393 let mut features = vec![
394 "auth",
395 "auth-mfa",
396 "database",
397 "billing",
398 "billing-seaorm",
399 "admin",
400 ];
401 if has_organizations {
402 features.push("organizations");
403 }
404
405 args.features = features.into_iter().map(|feature| feature.to_string()).collect();
406 args.with_config = true;
407 args.with_docker = true;
408 args.with_ci = true;
409 args.with_env = true;
410}
411
412fn preset_label(preset: NewPreset) -> &'static str {
413 match preset {
414 NewPreset::Minimal => "minimal",
415 NewPreset::Api => "api",
416 NewPreset::List => "list",
417 }
418}
419
420fn print_presets() {
421 if is_json_output() {
422 return;
423 }
424 println!("Available presets:");
425 println!(" - minimal: basic starter (no extra features)");
426 println!(" - api: auth + database + openapi + validation, plus config, docker, CI, env, and a sample DB-backed resource");
427}
428
429fn scaffold_api_preset(target_dir: &Path) -> Result<()> {
430 let args = ResourceArgs {
431 name: "todo".to_string(),
432 path: target_dir.to_string_lossy().to_string(),
433 wire: true,
434 with_tests: true,
435 db: true,
436 repo: false,
437 repo_tests: false,
438 service: false,
439 id_type: ResourceIdType::Int,
440 add_uuid: false,
441 paginate: false,
442 search: false,
443 db_backend: DbBackend::Auto,
444 };
445
446 crate::commands::resource::run(args)?;
447 Ok(())
448}
449
450fn scaffold_backend_preset(
451 target_dir: &Path,
452 project_name: &str,
453 preset: BackendPreset,
454) -> Result<()> {
455 let has_organizations = matches!(preset, BackendPreset::B2b);
456 let backend_args = crate::cli::BackendArgs {
457 preset,
458 name: project_name.to_string(),
459 output: target_dir.join("src").to_string_lossy().to_string(),
460 migrations_output: target_dir
461 .join("migration/src")
462 .to_string_lossy()
463 .to_string(),
464 force: true,
465 database: "postgres".to_string(),
466 };
467
468 crate::commands::backend::run(backend_args)?;
469
470 let context = BackendTemplateContext {
471 project_name: project_name.to_string(),
472 project_name_pascal: to_pascal_case(project_name),
473 has_organizations,
474 database: "postgres".to_string(),
475 tideway_version: TIDEWAY_VERSION.to_string(),
476 tideway_features: Vec::new(),
477 has_tideway_features: false,
478 has_auth_feature: false,
479 has_database_feature: false,
480 has_openapi_feature: false,
481 needs_arc: false,
482 has_config: false,
483 };
484 let engine = BackendTemplateEngine::new(context)?;
485 write_file_with_force(
486 &target_dir.join("migration/Cargo.toml"),
487 &engine.render("starter/migration/Cargo.toml")?,
488 true,
489 )?;
490
491 Ok(())
492}
493
494fn scaffold_wizard_resource(target_dir: &Path, resource: ResourceWizardOptions) -> Result<()> {
495 let args = ResourceArgs {
496 name: resource.name,
497 path: target_dir.to_string_lossy().to_string(),
498 wire: true,
499 with_tests: resource.with_tests,
500 db: resource.db,
501 repo: resource.repo,
502 repo_tests: resource.repo_tests,
503 service: resource.service,
504 id_type: ResourceIdType::Int,
505 add_uuid: false,
506 paginate: resource.paginate,
507 search: resource.search,
508 db_backend: DbBackend::Auto,
509 };
510
511 crate::commands::resource::run(args)?;
512 Ok(())
513}
514
515fn ensure_backend_dependencies(cargo_path: &Path) -> Result<()> {
516 let contents = fs::read_to_string(cargo_path)
517 .with_context(|| format!("Failed to read {}", cargo_path.display()))?;
518 let mut doc = contents.parse::<toml_edit::DocumentMut>()?;
519
520 let deps = doc["dependencies"].or_insert(Item::Table(Table::new()));
521 let deps_table = deps
522 .as_table_mut()
523 .expect("dependencies should be a table");
524
525 ensure_dependency_value(deps_table, "tracing", Value::from("0.1"));
526 ensure_dependency_value(deps_table, "dotenvy", Value::from("0.15"));
527 ensure_dependency_inline(
528 deps_table,
529 "uuid",
530 "1",
531 &["v4", "serde"],
532 );
533 ensure_dependency_inline(
534 deps_table,
535 "chrono",
536 "0.4",
537 &["serde"],
538 );
539
540 write_file(cargo_path, &doc.to_string())
541 .with_context(|| format!("Failed to write {}", cargo_path.display()))?;
542 Ok(())
543}
544
545fn ensure_dependency_value(deps: &mut Table, name: &str, value: Value) {
546 if !deps.contains_key(name) {
547 deps.insert(name, Item::Value(value));
548 }
549}
550
551fn ensure_dependency_inline(deps: &mut Table, name: &str, version: &str, features: &[&str]) {
552 if deps.contains_key(name) {
553 return;
554 }
555
556 let mut table = InlineTable::new();
557 table.get_or_insert("version", version);
558 let mut array = Array::new();
559 for feature in features {
560 array.push(*feature);
561 }
562 table.get_or_insert("features", Value::Array(array));
563 deps.insert(name, Item::Value(Value::InlineTable(table)));
564}
565
566fn needs_env_from_args(args: &NewArgs) -> bool {
567 let features = normalize_features(&args.features);
568 features.contains("auth")
569 || features.contains("database")
570 || args.with_config
571 || args.with_env
572}
573
574fn to_pascal_case(s: &str) -> String {
575 s.split('_')
576 .filter(|part| !part.is_empty())
577 .map(|word| {
578 let mut chars = word.chars();
579 match chars.next() {
580 None => String::new(),
581 Some(first) => first.to_uppercase().chain(chars).collect(),
582 }
583 })
584 .collect()
585}
586
587fn should_prompt(args: &NewArgs) -> bool {
588 args.features.is_empty()
589 && args.preset.is_none()
590 && !args.with_config
591 && !args.with_docker
592 && !args.with_ci
593 && !args.no_prompt
594 && Term::stdout().is_term()
595}
596
597fn prompt_for_options(args: &mut NewArgs) -> Result<WizardOptions> {
598 let theme = ColorfulTheme::default();
599 let mut wizard = WizardOptions::default();
600
601 let preset_options = [
602 "Minimal (no extra features)",
603 "API preset (auth + database + openapi + validation)",
604 "Backend preset: B2C (auth + billing + admin)",
605 "Backend preset: B2B (auth + billing + orgs + admin)",
606 "Custom (pick features)",
607 ];
608
609 let preset_choice = Select::with_theme(&theme)
610 .with_prompt("Choose a starter preset")
611 .items(&preset_options)
612 .default(1)
613 .interact()
614 .map_err(|e| anyhow!("Prompt failed: {}", e))?;
615
616 match preset_choice {
617 0 => {
618 args.preset = Some(NewPreset::Minimal);
619 }
620 1 => {
621 args.preset = Some(NewPreset::Api);
622 }
623 2 => {
624 wizard.backend_preset = Some(BackendPreset::B2c);
625 apply_backend_defaults(args, false);
626 }
627 3 => {
628 wizard.backend_preset = Some(BackendPreset::B2b);
629 apply_backend_defaults(args, true);
630 }
631 _ => {
632 let options = [
633 "auth",
634 "database",
635 "cache",
636 "sessions",
637 "jobs",
638 "email",
639 "websocket",
640 "metrics",
641 "validation",
642 "openapi",
643 ];
644
645 let selections = MultiSelect::with_theme(&theme)
646 .with_prompt("Select Tideway features (space to select)")
647 .items(&options)
648 .interact()
649 .map_err(|e| anyhow!("Prompt failed: {}", e))?;
650
651 args.features = selections
652 .iter()
653 .map(|idx| options[*idx].to_string())
654 .collect();
655
656 args.with_config = Confirm::with_theme(&theme)
657 .with_prompt("Generate config.rs and error.rs?")
658 .default(false)
659 .interact()
660 .map_err(|e| anyhow!("Prompt failed: {}", e))?;
661
662 args.with_docker = Confirm::with_theme(&theme)
663 .with_prompt("Generate docker-compose.yml for Postgres?")
664 .default(false)
665 .interact()
666 .map_err(|e| anyhow!("Prompt failed: {}", e))?;
667
668 args.with_ci = Confirm::with_theme(&theme)
669 .with_prompt("Generate GitHub Actions CI workflow?")
670 .default(false)
671 .interact()
672 .map_err(|e| anyhow!("Prompt failed: {}", e))?;
673 }
674 }
675
676 if Confirm::with_theme(&theme)
677 .with_prompt("Generate your first resource now?")
678 .default(true)
679 .interact()
680 .map_err(|e| anyhow!("Prompt failed: {}", e))?
681 {
682 let name = Input::<String>::with_theme(&theme)
683 .with_prompt("Resource name (singular, e.g. carehome)")
684 .interact_text()
685 .map_err(|e| anyhow!("Prompt failed: {}", e))?;
686
687 let has_database_feature = normalize_features(&args.features).contains("database");
688 let db = Confirm::with_theme(&theme)
689 .with_prompt("Use database-backed CRUD?")
690 .default(has_database_feature)
691 .interact()
692 .map_err(|e| anyhow!("Prompt failed: {}", e))?;
693
694 let mut repo = false;
695 let mut repo_tests = false;
696 let mut service = false;
697 let mut paginate = false;
698 let mut search = false;
699
700 if db {
701 repo = Confirm::with_theme(&theme)
702 .with_prompt("Generate a repository layer?")
703 .default(true)
704 .interact()
705 .map_err(|e| anyhow!("Prompt failed: {}", e))?;
706 if repo {
707 repo_tests = Confirm::with_theme(&theme)
708 .with_prompt("Generate repository tests? (requires DATABASE_URL)")
709 .default(false)
710 .interact()
711 .map_err(|e| anyhow!("Prompt failed: {}", e))?;
712 service = Confirm::with_theme(&theme)
713 .with_prompt("Generate a service layer?")
714 .default(true)
715 .interact()
716 .map_err(|e| anyhow!("Prompt failed: {}", e))?;
717 }
718 paginate = Confirm::with_theme(&theme)
719 .with_prompt("Add pagination to list endpoints?")
720 .default(true)
721 .interact()
722 .map_err(|e| anyhow!("Prompt failed: {}", e))?;
723 if paginate {
724 search = Confirm::with_theme(&theme)
725 .with_prompt("Add a search filter (q) to list endpoints?")
726 .default(true)
727 .interact()
728 .map_err(|e| anyhow!("Prompt failed: {}", e))?;
729 }
730 }
731
732 let with_tests = Confirm::with_theme(&theme)
733 .with_prompt("Generate route tests?")
734 .default(true)
735 .interact()
736 .map_err(|e| anyhow!("Prompt failed: {}", e))?;
737
738 wizard.resource = Some(ResourceWizardOptions {
739 name,
740 db,
741 repo,
742 repo_tests,
743 service,
744 paginate,
745 search,
746 with_tests,
747 });
748 }
749
750 Ok(wizard)
751}