1use 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
12pub 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 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 let context = TemplateContext {
33 api_base_url: args.api_base.clone(),
34 style: args.style.clone(),
35 };
36
37 let engine = TemplateEngine::new(context)?;
39
40 let mut shadcn_components: Vec<&str> = Vec::new();
42
43 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 if !args.no_shared {
67 generate_shared(&engine, output_path, &args)?;
68 } else {
69 print_info("Skipping shared files (--no-shared)");
70 }
71
72 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_all()?;
104
105 update_app_vue()?;
107 }
108
109 if args.style == Style::Shadcn && !shadcn_components.is_empty() {
111 shadcn_components.sort();
112 shadcn_components.dedup();
113
114 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 if content.contains("LoginView") {
504 print_info("Routes already configured in router");
505 return Ok(());
506 }
507
508 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 let updated = if content.contains("routes: [") {
538 content.replace("routes: [", &format!("routes: [{}", all_routes))
539 } else if content.contains("const routes") {
540 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 if content.contains("Toaster") {
572 print_info("Toaster already in App.vue");
573 return Ok(());
574 }
575
576 let mut updated = content;
577
578 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 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 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 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
636fn 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}