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