1use 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
15fn 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
28pub 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 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 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 let engine = BackendTemplateEngine::new(context)?;
97
98 generate_shared(&engine, output_path, &args)?;
100
101 generate_entities(&engine, output_path, &args)?;
103
104 generate_auth(&engine, output_path, &args)?;
106
107 generate_billing(&engine, output_path, &args)?;
109
110 if has_organizations {
112 generate_organizations(&engine, output_path, &args)?;
113 }
114
115 generate_admin(&engine, output_path, &args)?;
117
118 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 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 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 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 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 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 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 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 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 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 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 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 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 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}