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::{
11 ensure_dir, is_json_output, print_info, print_success, print_warning, write_file,
12};
13
14pub 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 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 let context = TemplateContext {
35 api_base_url: args.api_base.clone(),
36 style: args.style.clone(),
37 };
38
39 let engine = TemplateEngine::new(context)?;
41
42 let mut shadcn_components: Vec<&str> = Vec::new();
44
45 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 if !args.no_shared {
69 generate_shared(&engine, output_path, &args)?;
70 } else {
71 print_info("Skipping shared files (--no-shared)");
72 }
73
74 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_all()?;
105
106 update_app_vue()?;
108 }
109
110 if args.style == Style::Shadcn && !shadcn_components.is_empty() {
112 shadcn_components.sort();
113 shadcn_components.dedup();
114
115 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 if content.contains("LoginView") {
461 print_info("Routes already configured in router");
462 return Ok(());
463 }
464
465 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 let updated = if content.contains("routes: [") {
495 content.replace("routes: [", &format!("routes: [{}", all_routes))
496 } else if content.contains("const routes") {
497 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 if content.contains("Toaster") {
526 print_info("Toaster already in App.vue");
527 return Ok(());
528 }
529
530 let mut updated = content;
531
532 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 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 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 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
593fn 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}