Skip to main content

tideway_cli/commands/
new.rs

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