1use anyhow::{Context, Result};
4use colored::Colorize;
5use std::fs;
6use std::path::Path;
7
8use crate::cli::InitArgs;
9use crate::{
10 ensure_dir, is_json_output, print_info, print_success, print_warning, write_file,
11 TIDEWAY_VERSION,
12};
13
14#[derive(Debug, Default)]
16struct DetectedModules {
17 auth: bool,
18 billing: bool,
19 organizations: bool,
20 admin: bool,
21}
22
23impl DetectedModules {
24 fn any(&self) -> bool {
25 self.auth || self.billing || self.organizations || self.admin
26 }
27}
28
29pub fn run(args: InitArgs) -> Result<()> {
31 let src_path = Path::new(&args.src);
32
33 if args.minimal {
34 return run_minimal(src_path, &args);
35 }
36
37 if !is_json_output() {
38 println!(
39 "\n{} Scanning {} for modules...\n",
40 "tideway".cyan().bold(),
41 args.src.yellow()
42 );
43 }
44
45 let project_name = detect_project_name(&args)?;
47 let _project_name_pascal = to_pascal_case(&project_name);
48
49 print_info(&format!("Project name: {}", project_name.green()));
50
51 let modules = scan_modules(src_path)?;
53
54 if !modules.any() {
55 print_warning("No modules detected. Run 'tideway backend' first to generate modules.");
56 return Ok(());
57 }
58
59 if !is_json_output() {
61 println!("\n{}", "Detected modules:".yellow().bold());
62 if modules.auth {
63 println!(" {} auth", "✓".green());
64 }
65 if modules.billing {
66 println!(" {} billing", "✓".green());
67 }
68 if modules.organizations {
69 println!(" {} organizations", "✓".green());
70 }
71 if modules.admin {
72 println!(" {} admin", "✓".green());
73 }
74 println!();
75 }
76
77 let main_rs = generate_main_rs(&project_name, &modules, &args);
79 let main_path = src_path.join("main.rs");
80 write_file_with_force(&main_path, &main_rs, args.force)?;
81 print_success("Generated main.rs");
82
83 let config_path = src_path.join("config.rs");
85 if !config_path.exists() || args.force {
86 let config_rs = generate_config_rs(&modules, &args);
87 write_file_with_force(&config_path, &config_rs, args.force)?;
88 print_success("Generated config.rs");
89 } else {
90 print_info("config.rs already exists, skipping (use --force to overwrite)");
91 }
92
93 if args.env_example {
95 let env_example = generate_env_example(&project_name, &modules, &args);
96 let env_path = Path::new(".env.example");
97 write_file(env_path, &env_example).context("Failed to write .env.example")?;
99 print_success("Generated .env.example");
100 }
101
102 if !is_json_output() {
103 println!(
104 "\n{} Initialization complete!\n",
105 "✓".green().bold()
106 );
107
108 println!("{}", "Next steps:".yellow().bold());
110 println!(" 1. Copy .env.example to .env and fill in values:");
111 println!(" cp .env.example .env");
112 println!();
113 println!(" 2. Ensure dependencies in Cargo.toml:");
114 println!(
115 " tideway = {{ version = \"{}\", features = [\"auth\", \"auth-mfa\", \"database\", \"billing\", \"billing-seaorm\"] }}",
116 TIDEWAY_VERSION
117 );
118 println!();
119 if !args.no_migrations {
120 println!(" 3. Run migrations:");
121 println!(" cargo run -- migrate");
122 println!(" # or: sea-orm-cli migrate up");
123 println!();
124 }
125 println!(" 4. Start the server:");
126 println!(" cargo run");
127 println!();
128 }
129
130 Ok(())
131}
132
133fn run_minimal(src_path: &Path, args: &InitArgs) -> Result<()> {
134 if !is_json_output() {
135 println!(
136 "\n{} Generating minimal app...\n",
137 "tideway".cyan().bold()
138 );
139 }
140
141 let project_name = detect_project_name(args)?;
142 let project_name_pascal = to_pascal_case(&project_name);
143
144 print_info(&format!("Project name: {}", project_name.green()));
145
146 let main_rs = generate_minimal_main_rs(&project_name_pascal);
147 let routes_rs = generate_minimal_routes_rs();
148
149 let main_path = src_path.join("main.rs");
150 write_file_with_force(&main_path, &main_rs, args.force)?;
151 print_success("Generated main.rs");
152
153 let routes_path = src_path.join("routes").join("mod.rs");
154 write_file_with_force(&routes_path, &routes_rs, args.force)?;
155 print_success("Generated routes/mod.rs");
156
157 if !is_json_output() {
158 println!(
159 "\n{} Initialization complete!\n",
160 "✓".green().bold()
161 );
162
163 println!("{}", "Next steps:".yellow().bold());
164 println!(" 1. cargo run");
165 println!();
166 }
167
168 Ok(())
169}
170
171fn detect_project_name(args: &InitArgs) -> Result<String> {
173 if let Some(name) = &args.name {
174 return Ok(name.clone());
175 }
176
177 let cargo_toml = Path::new("Cargo.toml");
179 if cargo_toml.exists() {
180 let content = fs::read_to_string(cargo_toml)?;
181 for line in content.lines() {
182 if line.starts_with("name") {
183 if let Some(name) = line.split('=').nth(1) {
184 let name = name.trim().trim_matches('"').trim_matches('\'');
185 return Ok(name.replace('-', "_"));
186 }
187 }
188 }
189 }
190
191 let cwd = std::env::current_dir()?;
193 let dir_name = cwd
194 .file_name()
195 .and_then(|n| n.to_str())
196 .unwrap_or("my_app");
197
198 Ok(dir_name.replace('-', "_"))
199}
200
201fn to_pascal_case(s: &str) -> String {
203 s.split('_')
204 .map(|word| {
205 let mut chars = word.chars();
206 match chars.next() {
207 None => String::new(),
208 Some(first) => first.to_uppercase().chain(chars).collect(),
209 }
210 })
211 .collect()
212}
213
214fn scan_modules(src_path: &Path) -> Result<DetectedModules> {
216 let mut modules = DetectedModules::default();
217
218 let auth_path = src_path.join("auth");
220 if auth_path.is_dir() && has_module_file(&auth_path) {
221 modules.auth = true;
222 }
223
224 let billing_path = src_path.join("billing");
226 if billing_path.is_dir() && has_module_file(&billing_path) {
227 modules.billing = true;
228 }
229
230 let orgs_path = src_path.join("organizations");
232 if orgs_path.is_dir() && has_module_file(&orgs_path) {
233 modules.organizations = true;
234 }
235
236 let admin_path = src_path.join("admin");
238 if admin_path.is_dir() && has_module_file(&admin_path) {
239 modules.admin = true;
240 }
241
242 Ok(modules)
243}
244
245fn has_module_file(dir: &Path) -> bool {
247 dir.join("mod.rs").exists() || dir.join("routes.rs").exists()
248}
249
250fn generate_main_rs(project_name: &str, modules: &DetectedModules, args: &InitArgs) -> String {
252 let mut imports = vec![
253 format!("use {}::config::AppConfig;", project_name),
254 ];
255
256 if !args.no_database {
257 imports.push("use sea_orm::Database;".to_string());
258 if !args.no_migrations {
259 imports.push("use migration::Migrator;".to_string());
260 imports.push("use sea_orm_migration::MigratorTrait;".to_string());
261 }
262 }
263
264 imports.push("use std::sync::Arc;".to_string());
265 imports.push("use tideway::App;".to_string());
266
267 if modules.auth || modules.admin {
268 imports.push("use tideway::auth::{JwtIssuer, JwtIssuerConfig};".to_string());
269 }
270
271 if modules.auth {
272 imports.push(format!("use {}::auth::AuthModule;", project_name));
273 }
274
275 if modules.organizations {
276 imports.push(format!("use {}::organizations::OrganizationModule;", project_name));
277 }
278
279 if modules.admin {
280 imports.push(format!("use {}::admin::AdminModule;", project_name));
281 }
282
283 let mut body = String::new();
286
287 body.push_str(" // Initialize tracing\n");
289 body.push_str(" tracing_subscriber::fmt::init();\n\n");
290
291 body.push_str(" // Load configuration from environment\n");
293 body.push_str(" let config = AppConfig::from_env()?;\n\n");
294 body.push_str(" tracing::info!(\"Starting {} on {}:{}\", config.app_name, config.host, config.port);\n\n");
295
296 if !args.no_database {
298 body.push_str(" // Connect to database\n");
299 body.push_str(" let db = Database::connect(&config.database_url)\n");
300 body.push_str(" .await\n");
301 body.push_str(" .expect(\"Failed to connect to database\");\n");
302 body.push_str(" let db = Arc::new(db);\n\n");
303 body.push_str(" tracing::info!(\"Connected to database\");\n\n");
304
305 if !args.no_migrations {
306 body.push_str(" // Run migrations\n");
307 body.push_str(" tracing::info!(\"Running migrations...\");\n");
308 body.push_str(" Migrator::up(&*db, None).await?;\n");
309 body.push_str(" tracing::info!(\"Migrations complete\");\n\n");
310 }
311 }
312
313 if modules.auth || modules.admin {
315 body.push_str(" // Create JWT issuer\n");
316 body.push_str(" let jwt_config = JwtIssuerConfig::with_secret(&config.jwt_secret, &config.app_name);\n");
317 body.push_str(" let jwt_issuer = Arc::new(JwtIssuer::new(jwt_config)?);\n\n");
318 }
319
320 body.push_str(" // Create modules\n");
322
323 if modules.auth {
324 body.push_str(" let auth_module = AuthModule::new(\n");
325 body.push_str(" db.clone(),\n");
326 body.push_str(" jwt_issuer.clone(),\n");
327 body.push_str(" config.jwt_secret.clone(),\n");
328 body.push_str(" config.app_name.clone(),\n");
329 body.push_str(" );\n\n");
330 }
331
332 if modules.organizations {
333 body.push_str(" let org_module = OrganizationModule::new(\n");
334 body.push_str(" db.clone(),\n");
335 body.push_str(" config.jwt_secret.clone(),\n");
336 body.push_str(" );\n\n");
337 }
338
339 if modules.admin {
340 body.push_str(" let admin_module = AdminModule::new(\n");
341 body.push_str(" db.clone(),\n");
342 body.push_str(" config.jwt_secret.clone(),\n");
343 body.push_str(" jwt_issuer.clone(),\n");
344 body.push_str(" );\n\n");
345 }
346
347 body.push_str(" // Build application with modules\n");
349 body.push_str(" let app = App::new()");
350
351 if modules.auth {
352 body.push_str("\n .register_module(auth_module)");
353 }
354
355 if modules.organizations {
356 body.push_str("\n .register_module(org_module)");
357 }
358
359 if modules.admin {
360 body.push_str("\n .register_module(admin_module)");
361 }
362
363 body.push_str(";\n\n");
364
365 if modules.billing {
367 body.push_str(" // TODO: Set up billing routes\n");
368 body.push_str(" // let billing_router = billing::billing_routes();\n\n");
369 }
370
371 body.push_str(" // Start server\n");
373 body.push_str(" let addr = format!(\"{}:{}\", config.host, config.port);\n");
374 body.push_str(" tracing::info!(\"Server running on http://{}\", addr);\n\n");
375 body.push_str(" let listener = tokio::net::TcpListener::bind(&addr).await?;\n");
376 body.push_str(" let router = app.into_router_with_middleware();\n");
377 body.push_str(" axum::serve(listener, router).await?;\n\n");
378 body.push_str(" Ok(())");
379
380 format!(
381 r#"//! Application entry point.
382//!
383//! Generated by `tideway init`
384
385{}
386
387#[tokio::main]
388async fn main() -> anyhow::Result<()> {{
389{}
390}}
391"#,
392 imports.join("\n"),
393 body
394 )
395}
396
397fn generate_minimal_main_rs(project_name_pascal: &str) -> String {
398 format!(
399 "//! {} API server.\n\
400\n\
401use tideway::{{init_tracing, App}};\n\
402\n\
403mod routes;\n\
404\n\
405#[tokio::main]\n\
406async fn main() -> Result<(), std::io::Error> {{\n\
407 init_tracing();\n\
408\n\
409 let app = App::new()\n\
410 .register_module(routes::ApiModule);\n\
411\n\
412 app.serve().await\n\
413}}\n",
414 project_name_pascal
415 )
416}
417
418fn generate_minimal_routes_rs() -> String {
419 "//! Minimal API routes.\n\
420\n\
421use axum::{routing::get, Router};\n\
422use tideway::{AppContext, MessageResponse, RouteModule};\n\
423\n\
424pub struct ApiModule;\n\
425\n\
426impl RouteModule for ApiModule {\n\
427 fn routes(&self) -> Router<AppContext> {\n\
428 Router::new().route(\"/\", get(root))\n\
429 }\n\
430\n\
431 fn prefix(&self) -> Option<&str> {\n\
432 Some(\"/api\")\n\
433 }\n\
434}\n\
435\n\
436async fn root() -> MessageResponse {\n\
437 MessageResponse::success(\"Tideway is running\")\n\
438}\n"
439 .to_string()
440}
441
442fn generate_config_rs(modules: &DetectedModules, args: &InitArgs) -> String {
444 let mut fields = vec![
445 ("app_name", "String", "APP_NAME"),
446 ("host", "String", "HOST"),
447 ("port", "u16", "PORT"),
448 ];
449
450 if !args.no_database {
451 fields.push(("database_url", "String", "DATABASE_URL"));
452 }
453
454 if modules.auth || modules.admin {
455 fields.push(("jwt_secret", "String", "JWT_SECRET"));
456 }
457
458 if modules.billing {
459 fields.push(("stripe_secret_key", "String", "STRIPE_SECRET_KEY"));
460 fields.push(("stripe_webhook_secret", "String", "STRIPE_WEBHOOK_SECRET"));
461 }
462
463 let field_defs: Vec<String> = fields
464 .iter()
465 .map(|(name, ty, _)| format!(" pub {}: {},", name, ty))
466 .collect();
467
468 let env_reads: Vec<String> = fields
469 .iter()
470 .map(|(name, ty, env)| {
471 if *ty == "u16" {
472 format!(
473 " {}: std::env::var(\"{}\")?.parse()?,",
474 name, env
475 )
476 } else {
477 format!(" {}: std::env::var(\"{}\")?,", name, env)
478 }
479 })
480 .collect();
481
482 format!(
483 r#"//! Application configuration.
484//!
485//! Generated by `tideway init`
486
487use anyhow::Result;
488
489/// Application configuration loaded from environment variables.
490#[derive(Debug, Clone)]
491pub struct AppConfig {{
492{}
493}}
494
495impl AppConfig {{
496 /// Load configuration from environment variables.
497 pub fn from_env() -> Result<Self> {{
498 // Load .env file if present
499 dotenvy::dotenv().ok();
500
501 Ok(Self {{
502{}
503 }})
504 }}
505}}
506"#,
507 field_defs.join("\n"),
508 env_reads.join("\n")
509 )
510}
511
512fn generate_env_example(project_name: &str, modules: &DetectedModules, args: &InitArgs) -> String {
514 let mut lines = vec![
515 "# Application".to_string(),
516 format!("APP_NAME={}", project_name),
517 "HOST=127.0.0.1".to_string(),
518 "PORT=3000".to_string(),
519 "".to_string(),
520 ];
521
522 if !args.no_database {
523 lines.push("# Database".to_string());
524 lines.push(format!(
525 "DATABASE_URL=postgres://postgres:postgres@localhost:5432/{}",
526 project_name
527 ));
528 lines.push("".to_string());
529 }
530
531 if modules.auth || modules.admin {
532 lines.push("# Authentication".to_string());
533 lines.push("JWT_SECRET=your-super-secret-jwt-key-change-in-production".to_string());
534 lines.push("".to_string());
535 }
536
537 if modules.billing {
538 lines.push("# Stripe".to_string());
539 lines.push("STRIPE_SECRET_KEY=sk_test_...".to_string());
540 lines.push("STRIPE_WEBHOOK_SECRET=whsec_...".to_string());
541 lines.push("".to_string());
542 }
543
544 lines.join("\n")
545}
546
547fn write_file_with_force(path: &Path, content: &str, force: bool) -> Result<()> {
549 if path.exists() && !force {
550 print_warning(&format!(
551 "Skipping {} (use --force to overwrite)",
552 path.display()
553 ));
554 return Ok(());
555 }
556 if let Some(parent) = path.parent() {
557 ensure_dir(parent)
558 .with_context(|| format!("Failed to create {}", parent.display()))?;
559 }
560 write_file(path, content).with_context(|| format!("Failed to write {}", path.display()))?;
561 Ok(())
562}