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