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