Skip to main content

tideway_cli/commands/
generate.rs

1//! Generate command - creates frontend components from templates.
2
3use anyhow::{Context, Result};
4use colored::Colorize;
5use std::fs;
6use std::path::Path;
7
8use crate::cli::{GenerateArgs, Module, Style};
9use crate::templates::{TemplateContext, TemplateEngine};
10use crate::{
11    ensure_dir, is_json_output, print_info, print_success, print_warning, write_file,
12};
13
14/// Run the generate command
15pub fn run(args: GenerateArgs) -> Result<()> {
16    if !is_json_output() {
17        println!(
18            "\n{} Generating {} components with {} style\n",
19            "tideway".cyan().bold(),
20            args.module.to_string().green(),
21            args.style.to_string().yellow()
22        );
23    }
24
25    // Create output directory if it doesn't exist
26    let output_path = Path::new(&args.output);
27    if !output_path.exists() {
28        ensure_dir(output_path)
29            .with_context(|| format!("Failed to create output directory: {}", args.output))?;
30        print_info(&format!("Created directory: {}", args.output));
31    }
32
33    // Create template context
34    let context = TemplateContext {
35        api_base_url: args.api_base.clone(),
36        style: args.style.clone(),
37    };
38
39    // Initialize template engine
40    let engine = TemplateEngine::new(context)?;
41
42    // Track which shadcn components are needed
43    let mut shadcn_components: Vec<&str> = Vec::new();
44
45    // Generate modules based on selection
46    match args.module {
47        Module::Auth => {
48            generate_auth(&engine, output_path, &args, &mut shadcn_components)?;
49        }
50        Module::Billing => {
51            generate_billing(&engine, output_path, &args, &mut shadcn_components)?;
52        }
53        Module::Organizations => {
54            generate_organizations(&engine, output_path, &args, &mut shadcn_components)?;
55        }
56        Module::Admin => {
57            generate_admin(&engine, output_path, &args, &mut shadcn_components)?;
58        }
59        Module::All => {
60            generate_auth(&engine, output_path, &args, &mut shadcn_components)?;
61            generate_billing(&engine, output_path, &args, &mut shadcn_components)?;
62            generate_organizations(&engine, output_path, &args, &mut shadcn_components)?;
63            generate_admin(&engine, output_path, &args, &mut shadcn_components)?;
64        }
65    }
66
67    // Generate shared files (types, composables) unless --no-shared is set
68    if !args.no_shared {
69        generate_shared(&engine, output_path, &args)?;
70    } else {
71        print_info("Skipping shared files (--no-shared)");
72    }
73
74    // Generate view files if --with-views is set
75    if args.with_views {
76        let views_path = Path::new(&args.views_output);
77        if !views_path.exists() {
78            ensure_dir(views_path)
79                .with_context(|| format!("Failed to create views directory: {}", args.views_output))?;
80        }
81
82        match args.module {
83            Module::Auth => {
84                generate_auth_views(&engine, views_path, &args)?;
85            }
86            Module::Billing => {
87                generate_billing_views(&engine, views_path, &args)?;
88            }
89            Module::Organizations => {
90                generate_org_views(&engine, views_path, &args)?;
91            }
92            Module::Admin => {
93                generate_admin_views(&engine, views_path, &args)?;
94            }
95            Module::All => {
96                generate_auth_views(&engine, views_path, &args)?;
97                generate_billing_views(&engine, views_path, &args)?;
98                generate_org_views(&engine, views_path, &args)?;
99                generate_admin_views(&engine, views_path, &args)?;
100            }
101        }
102
103        // Update router with all routes
104        update_router_all()?;
105
106        // Update App.vue with Toaster
107        update_app_vue()?;
108    }
109
110    // Print shadcn component requirements if using shadcn style
111    if args.style == Style::Shadcn && !shadcn_components.is_empty() {
112        shadcn_components.sort();
113        shadcn_components.dedup();
114
115        // Detect which components are already installed
116        let installed = detect_installed_shadcn_components();
117        let missing: Vec<&str> = shadcn_components
118            .iter()
119            .filter(|c| !installed.contains(&c.to_string()))
120            .copied()
121            .collect();
122
123        if !is_json_output() {
124            if missing.is_empty() {
125                println!(
126                    "\n{} All required shadcn-vue components are installed",
127                    "✓".green().bold()
128                );
129            } else {
130                println!("\n{}", "Missing shadcn-vue components:".yellow().bold());
131                println!(
132                    "  npx shadcn-vue@latest add {}",
133                    missing.join(" ")
134                );
135            }
136        }
137    }
138
139    if !is_json_output() {
140        println!(
141            "\n{} Components generated in {}\n",
142            "✓".green().bold(),
143            args.output.cyan()
144        );
145    }
146
147    Ok(())
148}
149
150fn generate_auth(
151    engine: &TemplateEngine,
152    output_path: &Path,
153    args: &GenerateArgs,
154    shadcn_components: &mut Vec<&str>,
155) -> Result<()> {
156    let auth_path = output_path.join("auth");
157    ensure_dir(&auth_path)?;
158
159    let composables_path = auth_path.join("composables");
160    ensure_dir(&composables_path)?;
161
162    // Generate components
163    let components = [
164        ("LoginForm.vue", "auth/LoginForm"),
165        ("RegisterForm.vue", "auth/RegisterForm"),
166        ("ForgotPassword.vue", "auth/ForgotPassword"),
167        ("ResetPassword.vue", "auth/ResetPassword"),
168        ("MfaVerify.vue", "auth/MfaVerify"),
169    ];
170
171    for (filename, template_name) in components {
172        let content = engine.render(template_name)?;
173        let file_path = auth_path.join(filename);
174        write_file_with_force(&file_path, &content, args.force)?;
175        print_success(&format!("Generated auth/{}", filename));
176    }
177
178    // Generate composable
179    let composable_content = engine.render("auth/composables/useAuth")?;
180    let composable_path = composables_path.join("useAuth.ts");
181    write_file_with_force(&composable_path, &composable_content, args.force)?;
182    print_success("Generated auth/composables/useAuth.ts");
183
184    // Track shadcn components needed for auth
185    if args.style == Style::Shadcn {
186        shadcn_components.extend(&[
187            "button", "card", "input", "label", "form", "alert", "separator",
188        ]);
189    }
190
191    Ok(())
192}
193
194fn generate_billing(
195    engine: &TemplateEngine,
196    output_path: &Path,
197    args: &GenerateArgs,
198    shadcn_components: &mut Vec<&str>,
199) -> Result<()> {
200    let billing_path = output_path.join("billing");
201    ensure_dir(&billing_path)?;
202
203    let composables_path = billing_path.join("composables");
204    ensure_dir(&composables_path)?;
205
206    // Generate components
207    let components = [
208        ("SubscriptionStatus.vue", "billing/SubscriptionStatus"),
209        ("CheckoutButton.vue", "billing/CheckoutButton"),
210        ("BillingPortalButton.vue", "billing/BillingPortalButton"),
211        ("InvoiceHistory.vue", "billing/InvoiceHistory"),
212        ("PlanSelector.vue", "billing/PlanSelector"),
213        ("PlanList.vue", "billing/PlanList"),
214        ("PlanForm.vue", "billing/PlanForm"),
215        ("PricingTable.vue", "billing/PricingTable"),
216    ];
217
218    for (filename, template_name) in components {
219        let content = engine.render(template_name)?;
220        let file_path = billing_path.join(filename);
221        write_file_with_force(&file_path, &content, args.force)?;
222        print_success(&format!("Generated billing/{}", filename));
223    }
224
225    // Generate composables
226    let composable_content = engine.render("billing/composables/useBilling")?;
227    let composable_path = composables_path.join("useBilling.ts");
228    write_file_with_force(&composable_path, &composable_content, args.force)?;
229    print_success("Generated billing/composables/useBilling.ts");
230
231    let plans_composable_content = engine.render("billing/composables/usePlans")?;
232    let plans_composable_path = composables_path.join("usePlans.ts");
233    write_file_with_force(&plans_composable_path, &plans_composable_content, args.force)?;
234    print_success("Generated billing/composables/usePlans.ts");
235
236    // Track shadcn components needed for billing
237    if args.style == Style::Shadcn {
238        shadcn_components.extend(&[
239            "button", "card", "badge", "table", "skeleton", "alert", "separator",
240            "dropdown-menu", "select", "switch", "textarea", "tabs", "input", "label",
241        ]);
242    }
243
244    Ok(())
245}
246
247fn generate_organizations(
248    engine: &TemplateEngine,
249    output_path: &Path,
250    args: &GenerateArgs,
251    shadcn_components: &mut Vec<&str>,
252) -> Result<()> {
253    let orgs_path = output_path.join("organizations");
254    ensure_dir(&orgs_path)?;
255
256    let composables_path = orgs_path.join("composables");
257    ensure_dir(&composables_path)?;
258
259    // Generate components
260    let components = [
261        ("OrgSwitcher.vue", "organizations/OrgSwitcher"),
262        ("OrgSettings.vue", "organizations/OrgSettings"),
263        ("MemberList.vue", "organizations/MemberList"),
264        ("InviteMember.vue", "organizations/InviteMember"),
265    ];
266
267    for (filename, template_name) in components {
268        let content = engine.render(template_name)?;
269        let file_path = orgs_path.join(filename);
270        write_file_with_force(&file_path, &content, args.force)?;
271        print_success(&format!("Generated organizations/{}", filename));
272    }
273
274    // Generate composable
275    let composable_content = engine.render("organizations/composables/useOrganization")?;
276    let composable_path = composables_path.join("useOrganization.ts");
277    write_file_with_force(&composable_path, &composable_content, args.force)?;
278    print_success("Generated organizations/composables/useOrganization.ts");
279
280    // Track shadcn components needed for organizations
281    if args.style == Style::Shadcn {
282        shadcn_components.extend(&[
283            "button",
284            "card",
285            "input",
286            "label",
287            "form",
288            "avatar",
289            "dropdown-menu",
290            "table",
291            "badge",
292            "dialog",
293            "alert",
294        ]);
295    }
296
297    Ok(())
298}
299
300fn generate_admin(
301    engine: &TemplateEngine,
302    output_path: &Path,
303    args: &GenerateArgs,
304    shadcn_components: &mut Vec<&str>,
305) -> Result<()> {
306    let admin_path = output_path.join("admin");
307    ensure_dir(&admin_path)?;
308
309    let composables_path = admin_path.join("composables");
310    ensure_dir(&composables_path)?;
311
312    // Generate components
313    let components = [
314        ("AdminDashboard.vue", "admin/AdminDashboard"),
315        ("UserList.vue", "admin/UserList"),
316        ("UserDetail.vue", "admin/UserDetail"),
317        ("OrganizationList.vue", "admin/OrganizationList"),
318        ("OrganizationDetail.vue", "admin/OrganizationDetail"),
319        ("ImpersonationBanner.vue", "admin/ImpersonationBanner"),
320    ];
321
322    for (filename, template_name) in components {
323        let content = engine.render(template_name)?;
324        let file_path = admin_path.join(filename);
325        write_file_with_force(&file_path, &content, args.force)?;
326        print_success(&format!("Generated admin/{}", filename));
327    }
328
329    // Generate composable
330    let composable_content = engine.render("admin/composables/useAdmin")?;
331    let composable_path = composables_path.join("useAdmin.ts");
332    write_file_with_force(&composable_path, &composable_content, args.force)?;
333    print_success("Generated admin/composables/useAdmin.ts");
334
335    // Track shadcn components needed for admin
336    if args.style == Style::Shadcn {
337        shadcn_components.extend(&[
338            "button",
339            "card",
340            "input",
341            "label",
342            "table",
343            "badge",
344            "skeleton",
345            "alert",
346            "separator",
347            "switch",
348        ]);
349    }
350
351    Ok(())
352}
353
354fn generate_admin_views(
355    engine: &TemplateEngine,
356    views_path: &Path,
357    args: &GenerateArgs,
358) -> Result<()> {
359    let admin_views_path = views_path.join("admin");
360    ensure_dir(&admin_views_path)?;
361
362    // Generate view files
363    let views = [
364        ("AdminLayout.vue", "views/admin/AdminLayout"),
365        ("AdminDashboardView.vue", "views/admin/AdminDashboardView"),
366        ("AdminUsersView.vue", "views/admin/AdminUsersView"),
367        ("AdminOrganizationsView.vue", "views/admin/AdminOrganizationsView"),
368    ];
369
370    for (filename, template_name) in views {
371        let content = engine.render(template_name)?;
372        let file_path = admin_views_path.join(filename);
373        write_file_with_force(&file_path, &content, args.force)?;
374        print_success(&format!("Generated views/admin/{}", filename));
375    }
376
377    Ok(())
378}
379
380fn generate_auth_views(
381    _engine: &TemplateEngine,
382    views_path: &Path,
383    args: &GenerateArgs,
384) -> Result<()> {
385    let auth_views_path = views_path.join("auth");
386    ensure_dir(&auth_views_path)?;
387
388    // Generate auth view files inline (simple wrappers around components)
389    let views = [
390        ("LoginView.vue", include_str!("../templates_inline/views/auth/LoginView.vue")),
391        ("RegisterView.vue", include_str!("../templates_inline/views/auth/RegisterView.vue")),
392        ("ForgotPasswordView.vue", include_str!("../templates_inline/views/auth/ForgotPasswordView.vue")),
393        ("ResetPasswordView.vue", include_str!("../templates_inline/views/auth/ResetPasswordView.vue")),
394        ("MfaView.vue", include_str!("../templates_inline/views/auth/MfaView.vue")),
395    ];
396
397    for (filename, content) in views {
398        let file_path = auth_views_path.join(filename);
399        write_file_with_force(&file_path, content, args.force)?;
400        print_success(&format!("Generated views/auth/{}", filename));
401    }
402
403    Ok(())
404}
405
406fn generate_billing_views(
407    _engine: &TemplateEngine,
408    views_path: &Path,
409    args: &GenerateArgs,
410) -> Result<()> {
411    let billing_views_path = views_path.join("billing");
412    ensure_dir(&billing_views_path)?;
413
414    let views = [
415        ("BillingView.vue", include_str!("../templates_inline/views/billing/BillingView.vue")),
416    ];
417
418    for (filename, content) in views {
419        let file_path = billing_views_path.join(filename);
420        write_file_with_force(&file_path, content, args.force)?;
421        print_success(&format!("Generated views/billing/{}", filename));
422    }
423
424    Ok(())
425}
426
427fn generate_org_views(
428    _engine: &TemplateEngine,
429    views_path: &Path,
430    args: &GenerateArgs,
431) -> Result<()> {
432    let org_views_path = views_path.join("settings");
433    ensure_dir(&org_views_path)?;
434
435    let views = [
436        ("OrganizationSettingsView.vue", include_str!("../templates_inline/views/settings/OrganizationSettingsView.vue")),
437        ("MembersView.vue", include_str!("../templates_inline/views/settings/MembersView.vue")),
438    ];
439
440    for (filename, content) in views {
441        let file_path = org_views_path.join(filename);
442        write_file_with_force(&file_path, content, args.force)?;
443        print_success(&format!("Generated views/settings/{}", filename));
444    }
445
446    Ok(())
447}
448
449fn update_router_all() -> Result<()> {
450    let router_path = Path::new("./src/router/index.ts");
451
452    if !router_path.exists() {
453        print_warning("Router file not found at src/router/index.ts - skipping router update");
454        return Ok(());
455    }
456
457    let content = fs::read_to_string(router_path)?;
458
459    // Check if routes already added
460    if content.contains("LoginView") {
461        print_info("Routes already configured in router");
462        return Ok(());
463    }
464
465    // All routes to add
466    let all_routes = r#"
467  // Auth routes (public)
468  { path: '/login', name: 'login', component: () => import('@/views/auth/LoginView.vue') },
469  { path: '/register', name: 'register', component: () => import('@/views/auth/RegisterView.vue') },
470  { path: '/forgot-password', name: 'forgot-password', component: () => import('@/views/auth/ForgotPasswordView.vue') },
471  { path: '/reset-password', name: 'reset-password', component: () => import('@/views/auth/ResetPasswordView.vue') },
472  { path: '/mfa', name: 'mfa', component: () => import('@/views/auth/MfaView.vue') },
473
474  // Billing (protected)
475  { path: '/billing', name: 'billing', component: () => import('@/views/billing/BillingView.vue'), meta: { requiresAuth: true } },
476
477  // Organization settings (protected)
478  { path: '/settings/organization', name: 'organization-settings', component: () => import('@/views/settings/OrganizationSettingsView.vue'), meta: { requiresAuth: true } },
479  { path: '/settings/members', name: 'members', component: () => import('@/views/settings/MembersView.vue'), meta: { requiresAuth: true } },
480
481  // Admin routes (protected, admin only)
482  {
483    path: '/admin',
484    component: () => import('@/views/admin/AdminLayout.vue'),
485    meta: { requiresAuth: true, requiresAdmin: true },
486    children: [
487      { path: '', name: 'admin-dashboard', component: () => import('@/views/admin/AdminDashboardView.vue') },
488      { path: 'users', name: 'admin-users', component: () => import('@/views/admin/AdminUsersView.vue') },
489      { path: 'organizations', name: 'admin-organizations', component: () => import('@/views/admin/AdminOrganizationsView.vue') },
490    ]
491  },"#;
492
493    // Try to insert after the first route in the routes array
494    let updated = if content.contains("routes: [") {
495        content.replace("routes: [", &format!("routes: [{}", all_routes))
496    } else if content.contains("const routes") {
497        // Handle const routes = [ ... ] pattern
498        content.replace("const routes = [", &format!("const routes = [{}", all_routes))
499    } else {
500        print_warning("Could not find routes array in router file");
501        if !is_json_output() {
502            println!("\n{}", "Add to your router manually:".yellow().bold());
503            println!("{}", all_routes);
504        }
505        return Ok(());
506    };
507
508    write_file(router_path, &updated)?;
509    print_success("Updated router with all routes (auth, billing, settings, admin)");
510
511    Ok(())
512}
513
514fn update_app_vue() -> Result<()> {
515    let app_path = Path::new("./src/App.vue");
516
517    if !app_path.exists() {
518        print_warning("App.vue not found - skipping App.vue update");
519        return Ok(());
520    }
521
522    let content = fs::read_to_string(app_path)?;
523
524    // Check if Toaster already added
525    if content.contains("Toaster") {
526        print_info("Toaster already in App.vue");
527        return Ok(());
528    }
529
530    let mut updated = content;
531
532    // Add Toaster import (shadcn-vue exports Sonner component as Toaster)
533    if updated.contains("<script setup") {
534        updated = updated.replace(
535            "<script setup lang=\"ts\">",
536            "<script setup lang=\"ts\">\nimport 'vue-sonner/style.css'\nimport { Toaster } from '@/components/ui/sonner'"
537        );
538    } else if updated.contains("<script setup>") {
539        updated = updated.replace(
540            "<script setup>",
541            "<script setup>\nimport 'vue-sonner/style.css'\nimport { Toaster } from '@/components/ui/sonner'"
542        );
543    }
544
545    // Add Toaster component before </template>
546    if updated.contains("</template>") {
547        updated = updated.replace(
548            "</template>",
549            "  <Toaster />\n</template>"
550        );
551    }
552
553    write_file(app_path, &updated)?;
554    print_success("Updated App.vue with Sonner");
555
556    Ok(())
557}
558
559fn generate_shared(engine: &TemplateEngine, output_path: &Path, args: &GenerateArgs) -> Result<()> {
560    // Generate shared types
561    let types_path = output_path.join("types");
562    ensure_dir(&types_path)?;
563
564    let types_content = engine.render("shared/types/index")?;
565    let types_file = types_path.join("index.ts");
566    write_file_with_force(&types_file, &types_content, args.force)?;
567    print_success("Generated types/index.ts");
568
569    // Generate shared API composable
570    let composables_path = output_path.join("composables");
571    ensure_dir(&composables_path)?;
572
573    let api_content = engine.render("shared/composables/useApi")?;
574    let api_file = composables_path.join("useApi.ts");
575    write_file_with_force(&api_file, &api_content, args.force)?;
576    print_success("Generated composables/useApi.ts");
577
578    Ok(())
579}
580
581fn write_file_with_force(path: &Path, content: &str, force: bool) -> Result<()> {
582    if path.exists() && !force {
583        print_warning(&format!(
584            "Skipping {} (use --force to overwrite)",
585            path.display()
586        ));
587        return Ok(());
588    }
589    write_file(path, content).with_context(|| format!("Failed to write {}", path.display()))?;
590    Ok(())
591}
592
593/// Detect installed shadcn-vue components by checking ./src/components/ui/ subdirectories
594fn detect_installed_shadcn_components() -> Vec<String> {
595    let ui_path = Path::new("./src/components/ui");
596
597    if !ui_path.exists() {
598        return Vec::new();
599    }
600
601    let Ok(entries) = fs::read_dir(ui_path) else {
602        return Vec::new();
603    };
604
605    entries
606        .filter_map(|entry| {
607            let entry = entry.ok()?;
608            if entry.file_type().ok()?.is_dir() {
609                entry.file_name().to_str().map(String::from)
610            } else {
611                None
612            }
613        })
614        .collect()
615}