Skip to main content

tideway_cli/commands/
new.rs

1//! New command - scaffold a minimal Tideway app.
2
3use 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
35/// Run the new command
36pub 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}