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