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    TIDEWAY_VERSION, ensure_dir, is_json_output, print_info, print_success, print_warning,
11    write_file,
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!("\n{} Initialization complete!\n", "✓".green().bold());
104
105        // Print next steps
106        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
162/// Detect project name from Cargo.toml or directory name
163fn detect_project_name(args: &InitArgs) -> Result<String> {
164    if let Some(name) = &args.name {
165        return Ok(name.clone());
166    }
167
168    // Try to read from Cargo.toml
169    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    // Fall back to current directory name
183    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
189/// Convert snake_case to PascalCase
190fn 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
202/// Scan source directory for modules
203fn scan_modules(src_path: &Path) -> Result<DetectedModules> {
204    let mut modules = DetectedModules::default();
205
206    // Check for auth module
207    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    // Check for billing module
213    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    // Check for organizations module
219    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    // Check for admin module
225    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
233/// Check if directory has a mod.rs or routes.rs file
234fn has_module_file(dir: &Path) -> bool {
235    dir.join("mod.rs").exists() || dir.join("routes.rs").exists()
236}
237
238/// Generate main.rs content
239fn 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    // Note: billing is commented out for now as it needs manual setup
273
274    let mut body = String::new();
275
276    // Tracing init
277    body.push_str("    // Initialize tracing\n");
278    body.push_str("    tracing_subscriber::fmt::init();\n\n");
279
280    // Config loading
281    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    // Database connection
286    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    // JWT issuer
303    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    // Module instantiation
310    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    // App builder
337    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    // Billing note
357    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    // Server binding
363    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
435/// Generate config.rs content
436fn 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
505/// Generate .env.example content
506fn 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
540/// Write file with optional force overwrite
541fn 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}