Skip to main content

tideway_cli/commands/
backend.rs

1//! Backend command - generates Rust backend scaffolding from templates.
2
3use anyhow::{Context, Result};
4use colored::Colorize;
5use std::path::Path;
6
7use crate::cli::{BackendArgs, BackendPreset};
8use crate::templates::{BackendTemplateContext, BackendTemplateEngine};
9use crate::{
10    TIDEWAY_VERSION, ensure_dir, is_json_output, is_plan_mode, print_info, print_success,
11    print_warning,
12    write_file,
13};
14
15/// Convert snake_case to PascalCase
16fn to_pascal_case(s: &str) -> String {
17    s.split('_')
18        .map(|word| {
19            let mut chars = word.chars();
20            match chars.next() {
21                None => String::new(),
22                Some(first) => first.to_uppercase().chain(chars).collect(),
23            }
24        })
25        .collect()
26}
27
28/// Run the backend command
29pub fn run(args: BackendArgs) -> Result<()> {
30    let plan_mode = is_plan_mode();
31    let has_organizations = args.preset == BackendPreset::B2b;
32    let preset_name = match args.preset {
33        BackendPreset::B2c => "B2C (Auth + Billing + Admin)",
34        BackendPreset::B2b => "B2B (Auth + Billing + Organizations + Admin)",
35    };
36
37    if !is_json_output() {
38        if plan_mode {
39            println!(
40                "\n{} Planning {} backend scaffolding\n",
41                "tideway".cyan().bold(),
42                preset_name.green()
43            );
44        } else {
45            println!(
46                "\n{} Generating {} backend scaffolding\n",
47                "tideway".cyan().bold(),
48                preset_name.green()
49            );
50        }
51        println!(
52            "  Project: {}\n  Database: {}\n  Output: {}\n",
53            args.name.yellow(),
54            args.database.yellow(),
55            args.output.yellow()
56        );
57    }
58
59    // Create output directories
60    let output_path = Path::new(&args.output);
61    let migrations_path = Path::new(&args.migrations_output);
62
63    if !output_path.exists() {
64        ensure_dir(output_path)
65            .with_context(|| format!("Failed to create output directory: {}", args.output))?;
66        print_info(&format!("Created directory: {}", args.output));
67    }
68
69    if !migrations_path.exists() {
70        ensure_dir(migrations_path).with_context(|| {
71            format!(
72                "Failed to create migrations directory: {}",
73                args.migrations_output
74            )
75        })?;
76        print_info(&format!("Created directory: {}", args.migrations_output));
77    }
78
79    // Create template context
80    let context = BackendTemplateContext {
81        project_name: args.name.clone(),
82        project_name_pascal: to_pascal_case(&args.name),
83        has_organizations,
84        database: args.database.clone(),
85        tideway_version: TIDEWAY_VERSION.to_string(),
86        tideway_features: Vec::new(),
87        has_tideway_features: false,
88        has_auth_feature: false,
89        has_database_feature: false,
90        has_openapi_feature: false,
91        needs_arc: false,
92        has_config: false,
93    };
94
95    // Initialize template engine
96    let engine = BackendTemplateEngine::new(context)?;
97
98    // Generate shared files
99    generate_shared(&engine, output_path, &args)?;
100
101    // Generate entities
102    generate_entities(&engine, output_path, &args)?;
103
104    // Generate auth module
105    generate_auth(&engine, output_path, &args)?;
106
107    // Generate billing module
108    generate_billing(&engine, output_path, &args)?;
109
110    // Generate organizations module (B2B only)
111    if has_organizations {
112        generate_organizations(&engine, output_path, &args)?;
113    }
114
115    // Generate admin module
116    generate_admin(&engine, output_path, &args)?;
117
118    // Generate migrations
119    generate_migrations(&engine, migrations_path, &args)?;
120
121    if plan_mode {
122        print_info("Plan complete: no files were written");
123        return Ok(());
124    }
125
126    if !is_json_output() {
127        println!(
128            "\n{} Backend scaffolding generated successfully!\n",
129            "✓".green().bold()
130        );
131
132        // Print next steps
133        println!("{}", "Next steps:".yellow().bold());
134        println!("  1. Add dependencies to Cargo.toml:");
135        println!(
136            "     tideway = {{ version = \"{}\", features = [\"auth\", \"auth-mfa\", \"database\", \"billing\", \"billing-seaorm\", \"organizations\", \"admin\"] }}",
137            TIDEWAY_VERSION
138        );
139        println!("     axum = {{ version = \"0.8\", features = [\"macros\"] }}");
140        println!(
141            "     sea-orm = {{ version = \"1.1\", features = [\"sqlx-postgres\", \"runtime-tokio-rustls\"] }}"
142        );
143        println!("     tokio = {{ version = \"1\", features = [\"full\"] }}");
144        println!("     serde = {{ version = \"1\", features = [\"derive\"] }}");
145        println!("     serde_json = \"1\"");
146        println!("     tracing = \"0.1\"");
147        println!("     async-trait = \"0.1\"");
148        println!("     chrono = {{ version = \"0.4\", features = [\"serde\"] }}");
149        println!("     uuid = {{ version = \"1\", features = [\"v4\", \"serde\"] }}");
150        println!();
151        println!("  2. Run migrations:");
152        println!("     sea-orm-cli migrate up");
153        println!();
154        println!("  3. Start the server:");
155        println!("     cargo run");
156        println!();
157    }
158
159    Ok(())
160}
161
162fn generate_shared(
163    engine: &BackendTemplateEngine,
164    output_path: &Path,
165    args: &BackendArgs,
166) -> Result<()> {
167    // Generate main.rs
168    if engine.has_template("shared/main") {
169        let content = engine.render("shared/main")?;
170        let file_path = output_path.join("main.rs");
171        write_file_with_force(&file_path, &content, args.force)?;
172        print_success("Generated main.rs");
173    }
174
175    // Generate lib.rs
176    if engine.has_template("shared/lib") {
177        let content = engine.render("shared/lib")?;
178        let file_path = output_path.join("lib.rs");
179        write_file_with_force(&file_path, &content, args.force)?;
180        print_success("Generated lib.rs");
181    }
182
183    // Generate config.rs
184    if engine.has_template("shared/config") {
185        let content = engine.render("shared/config")?;
186        let file_path = output_path.join("config.rs");
187        write_file_with_force(&file_path, &content, args.force)?;
188        print_success("Generated config.rs");
189    }
190
191    // Generate error.rs
192    if engine.has_template("shared/error") {
193        let content = engine.render("shared/error")?;
194        let file_path = output_path.join("error.rs");
195        write_file_with_force(&file_path, &content, args.force)?;
196        print_success("Generated error.rs");
197    }
198
199    Ok(())
200}
201
202fn generate_entities(
203    engine: &BackendTemplateEngine,
204    output_path: &Path,
205    args: &BackendArgs,
206) -> Result<()> {
207    let entities_path = output_path.join("entities");
208    ensure_dir(&entities_path)?;
209
210    // Generate entities/mod.rs
211    if engine.has_template("entities/mod") {
212        let content = engine.render("entities/mod")?;
213        let file_path = entities_path.join("mod.rs");
214        write_file_with_force(&file_path, &content, args.force)?;
215        print_success("Generated entities/mod.rs");
216    }
217
218    // Generate entities/prelude.rs
219    if engine.has_template("entities/prelude") {
220        let content = engine.render("entities/prelude")?;
221        let file_path = entities_path.join("prelude.rs");
222        write_file_with_force(&file_path, &content, args.force)?;
223        print_success("Generated entities/prelude.rs");
224    }
225
226    // Generate core entities
227    let core_entities = [
228        ("user.rs", "entities/user"),
229        ("refresh_token_family.rs", "entities/refresh_token_family"),
230        ("verification_token.rs", "entities/verification_token"),
231    ];
232
233    for (filename, template_name) in core_entities {
234        if engine.has_template(template_name) {
235            let content = engine.render(template_name)?;
236            let file_path = entities_path.join(filename);
237            write_file_with_force(&file_path, &content, args.force)?;
238            print_success(&format!("Generated entities/{}", filename));
239        }
240    }
241
242    // Generate organization entities (B2B only)
243    if args.preset == BackendPreset::B2b {
244        let org_entities = [
245            ("organization.rs", "entities/organization"),
246            ("organization_member.rs", "entities/organization_member"),
247        ];
248
249        for (filename, template_name) in org_entities {
250            if engine.has_template(template_name) {
251                let content = engine.render(template_name)?;
252                let file_path = entities_path.join(filename);
253                write_file_with_force(&file_path, &content, args.force)?;
254                print_success(&format!("Generated entities/{}", filename));
255            }
256        }
257    }
258
259    Ok(())
260}
261
262fn generate_auth(
263    engine: &BackendTemplateEngine,
264    output_path: &Path,
265    args: &BackendArgs,
266) -> Result<()> {
267    let auth_path = output_path.join("auth");
268    ensure_dir(&auth_path)?;
269
270    let templates = [
271        ("mod.rs", "auth/mod"),
272        ("routes.rs", "auth/routes"),
273        ("store.rs", "auth/store"),
274    ];
275
276    for (filename, template_name) in templates {
277        if engine.has_template(template_name) {
278            let content = engine.render(template_name)?;
279            let file_path = auth_path.join(filename);
280            write_file_with_force(&file_path, &content, args.force)?;
281            print_success(&format!("Generated auth/{}", filename));
282        }
283    }
284
285    Ok(())
286}
287
288fn generate_billing(
289    engine: &BackendTemplateEngine,
290    output_path: &Path,
291    args: &BackendArgs,
292) -> Result<()> {
293    let billing_path = output_path.join("billing");
294    ensure_dir(&billing_path)?;
295
296    let templates = [
297        ("mod.rs", "billing/mod"),
298        ("routes.rs", "billing/routes"),
299        ("store.rs", "billing/store"),
300    ];
301
302    for (filename, template_name) in templates {
303        if engine.has_template(template_name) {
304            let content = engine.render(template_name)?;
305            let file_path = billing_path.join(filename);
306            write_file_with_force(&file_path, &content, args.force)?;
307            print_success(&format!("Generated billing/{}", filename));
308        }
309    }
310
311    Ok(())
312}
313
314fn generate_organizations(
315    engine: &BackendTemplateEngine,
316    output_path: &Path,
317    args: &BackendArgs,
318) -> Result<()> {
319    let orgs_path = output_path.join("organizations");
320    ensure_dir(&orgs_path)?;
321
322    let templates = [
323        ("mod.rs", "organizations/mod"),
324        ("routes.rs", "organizations/routes"),
325        ("store.rs", "organizations/store"),
326    ];
327
328    for (filename, template_name) in templates {
329        if engine.has_template(template_name) {
330            let content = engine.render(template_name)?;
331            let file_path = orgs_path.join(filename);
332            write_file_with_force(&file_path, &content, args.force)?;
333            print_success(&format!("Generated organizations/{}", filename));
334        }
335    }
336
337    Ok(())
338}
339
340fn generate_admin(
341    engine: &BackendTemplateEngine,
342    output_path: &Path,
343    args: &BackendArgs,
344) -> Result<()> {
345    let admin_path = output_path.join("admin");
346    ensure_dir(&admin_path)?;
347
348    let templates = [
349        ("mod.rs", "admin/mod"),
350        ("routes.rs", "admin/routes"),
351        ("store.rs", "admin/store"),
352    ];
353
354    for (filename, template_name) in templates {
355        if engine.has_template(template_name) {
356            let content = engine.render(template_name)?;
357            let file_path = admin_path.join(filename);
358            write_file_with_force(&file_path, &content, args.force)?;
359            print_success(&format!("Generated admin/{}", filename));
360        }
361    }
362
363    Ok(())
364}
365
366fn generate_migrations(
367    engine: &BackendTemplateEngine,
368    migrations_path: &Path,
369    args: &BackendArgs,
370) -> Result<()> {
371    // Generate migration lib.rs
372    if engine.has_template("migrations/lib") {
373        let content = engine.render("migrations/lib")?;
374        let file_path = migrations_path.join("lib.rs");
375        write_file_with_force(&file_path, &content, args.force)?;
376        print_success("Generated migration/src/lib.rs");
377    }
378
379    // Core migrations (always generated)
380    let core_migrations = [
381        ("m001_create_users.rs", "migrations/m001_create_users"),
382        (
383            "m002_create_refresh_token_families.rs",
384            "migrations/m002_create_refresh_token_families",
385        ),
386        (
387            "m003_create_verification_tokens.rs",
388            "migrations/m003_create_verification_tokens",
389        ),
390        ("m004_create_billing.rs", "migrations/m004_create_billing"),
391        (
392            "m008_create_billing_plans.rs",
393            "migrations/m008_create_billing_plans",
394        ),
395        (
396            "m009_create_webhook_processed_events.rs",
397            "migrations/m009_create_webhook_processed_events",
398        ),
399    ];
400
401    for (filename, template_name) in core_migrations {
402        if engine.has_template(template_name) {
403            let content = engine.render(template_name)?;
404            let file_path = migrations_path.join(filename);
405            write_file_with_force(&file_path, &content, args.force)?;
406            print_success(&format!("Generated migration/src/{}", filename));
407        }
408    }
409
410    // B2B-specific migrations
411    if args.preset == BackendPreset::B2b {
412        let b2b_migrations = [
413            (
414                "m005_create_organizations.rs",
415                "migrations/m005_create_organizations",
416            ),
417            (
418                "m006_create_organization_members.rs",
419                "migrations/m006_create_organization_members",
420            ),
421            ("m007_add_admin_flag.rs", "migrations/m007_add_admin_flag"),
422        ];
423
424        for (filename, template_name) in b2b_migrations {
425            if engine.has_template(template_name) {
426                let content = engine.render(template_name)?;
427                let file_path = migrations_path.join(filename);
428                write_file_with_force(&file_path, &content, args.force)?;
429                print_success(&format!("Generated migration/src/{}", filename));
430            }
431        }
432    } else {
433        // B2C admin flag migration (different numbering)
434        if engine.has_template("migrations/m005_add_admin_flag") {
435            let content = engine.render("migrations/m005_add_admin_flag")?;
436            let file_path = migrations_path.join("m005_add_admin_flag.rs");
437            write_file_with_force(&file_path, &content, args.force)?;
438            print_success("Generated migration/src/m005_add_admin_flag.rs");
439        }
440    }
441
442    Ok(())
443}
444
445fn write_file_with_force(path: &Path, content: &str, force: bool) -> Result<()> {
446    if path.exists() && !force {
447        print_warning(&format!(
448            "Skipping {} (use --force to overwrite)",
449            path.display()
450        ));
451        return Ok(());
452    }
453    write_file(path, content).with_context(|| format!("Failed to write {}", path.display()))?;
454    Ok(())
455}