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