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