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