Skip to main content

tideway_cli/commands/
init.rs

1//! Init command - scans for modules and generates main.rs with proper wiring.
2
3use 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/// Detected modules in the project
15#[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
29/// Run the init command
30pub 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    // Detect project name
46    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    // Scan for modules
52    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    // Print detected modules
60    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    // Generate main.rs
78    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    // Generate config.rs if it doesn't exist
84    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    // Generate .env.example
94    if args.env_example {
95        let env_example = generate_env_example(&project_name, &modules, &args);
96        let env_path = Path::new(".env.example");
97        // Always overwrite .env.example
98        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        // Print next steps
109        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
171/// Detect project name from Cargo.toml or directory name
172fn detect_project_name(args: &InitArgs) -> Result<String> {
173    if let Some(name) = &args.name {
174        return Ok(name.clone());
175    }
176
177    // Try to read from Cargo.toml
178    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    // Fall back to current directory name
192    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
201/// Convert snake_case to PascalCase
202fn 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
214/// Scan source directory for modules
215fn scan_modules(src_path: &Path) -> Result<DetectedModules> {
216    let mut modules = DetectedModules::default();
217
218    // Check for auth module
219    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    // Check for billing module
225    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    // Check for organizations module
231    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    // Check for admin module
237    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
245/// Check if directory has a mod.rs or routes.rs file
246fn has_module_file(dir: &Path) -> bool {
247    dir.join("mod.rs").exists() || dir.join("routes.rs").exists()
248}
249
250/// Generate main.rs content
251fn 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    // Note: billing is commented out for now as it needs manual setup
284
285    let mut body = String::new();
286
287    // Tracing init
288    body.push_str("    // Initialize tracing\n");
289    body.push_str("    tracing_subscriber::fmt::init();\n\n");
290
291    // Config loading
292    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    // Database connection
297    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    // JWT issuer
314    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    // Module instantiation
321    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    // App builder
348    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    // Billing note
366    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    // Server binding
372    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
442/// Generate config.rs content
443fn 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
512/// Generate .env.example content
513fn 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
547/// Write file with optional force overwrite
548fn 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}