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