@@ -299,6 +299,250 @@ def discover_pairs(infra_dir: Path) -> list[tuple[Path, Path]]:
299299# Reporting
300300# ---------------------------------------------------------------------------
301301
302+ # ---------------------------------------------------------------------------
303+ # HTML email report
304+ # ---------------------------------------------------------------------------
305+
306+ def _html_escape (text : str ) -> str :
307+ """Escape HTML special characters."""
308+ return (
309+ text .replace ("&" , "&" )
310+ .replace ("<" , "<" )
311+ .replace (">" , ">" )
312+ .replace ('"' , """ )
313+ )
314+
315+
316+ def generate_html_report (
317+ results : list [ValidationResult ],
318+ * ,
319+ accelerator_name : str = "" ,
320+ run_url : str = "" ,
321+ scan_dir : str = "" ,
322+ ) -> str :
323+ """Build a structured HTML email body from validation results."""
324+ total_errors = sum (
325+ 1 for r in results for i in r .issues if i .severity == "ERROR"
326+ )
327+ total_warnings = sum (
328+ 1 for r in results for i in r .issues if i .severity == "WARNING"
329+ )
330+ has_errors = total_errors > 0
331+ overall_status = "Issues Detected" if has_errors else "Passed"
332+ status_color = "#D32F2F" if has_errors else "#2E7D32"
333+ status_bg = "#FFEBEE" if has_errors else "#E8F5E9"
334+ status_icon = "❌" if has_errors else "✅"
335+
336+ parts : list [str ] = []
337+
338+ # --- Document wrapper (Outlook-compatible, no gradient/border-radius/box-shadow) ---
339+ parts .append (
340+ '<!DOCTYPE html><html><head><meta charset="utf-8"></head>'
341+ '<body style="margin:0;padding:0;font-family:Segoe UI,Helvetica,Arial,sans-serif;'
342+ 'background-color:#ffffff;">'
343+ '<table role="presentation" width="100%" cellpadding="0" cellspacing="0"'
344+ ' style="background-color:#ffffff;">'
345+ '<tr><td align="center" style="padding:0;">'
346+ '<table role="presentation" width="100%" cellpadding="0" cellspacing="0"'
347+ ' style="max-width:680px;background-color:#ffffff;">'
348+ )
349+
350+ # --- Header banner (solid color, Outlook-safe) ---
351+ parts .append (
352+ f'<tr><td style="background-color:#0078D4;padding:20px 24px;color:#ffffff;">'
353+ f'<h1 style="margin:0 0 4px 0;font-size:20px;font-weight:600;color:#ffffff;">'
354+ f'Bicep Parameter Validation Report</h1>'
355+ f'<p style="margin:0;font-size:13px;color:#ffffff;">'
356+ f'{ _html_escape (accelerator_name ) if accelerator_name else "Accelerator" } '
357+ f' — Automated Check</p>'
358+ f'</td></tr>'
359+ )
360+
361+ # --- Summary card ---
362+ parts .append (
363+ f'<tr><td style="padding:16px 24px 12px 24px;">'
364+ f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0"'
365+ f' style="background-color:{ status_bg } ;border-left:4px solid { status_color } ;">'
366+ f'<tr><td style="padding:12px 16px;">'
367+ f'<span style="font-size:16px;font-weight:600;color:{ status_color } ;">'
368+ f'{ status_icon } Overall Status: { overall_status } </span>'
369+ f'</td></tr>'
370+ f'<tr><td style="padding:4px 16px 12px 16px;">'
371+ f'<table role="presentation" cellpadding="0" cellspacing="0"><tr>'
372+ )
373+ # Accelerator name pill
374+ if accelerator_name :
375+ parts .append (
376+ f'<td style="padding-right:20px;vertical-align:top;">'
377+ f'<span style="font-size:11px;color:#666;">Accelerator</span><br>'
378+ f'<strong style="font-size:13px;">{ _html_escape (accelerator_name )} '
379+ f'</strong></td>'
380+ )
381+ # Scan directory pill
382+ if scan_dir :
383+ parts .append (
384+ f'<td style="padding-right:20px;vertical-align:top;">'
385+ f'<span style="font-size:11px;color:#666;">Scan Directory</span><br>'
386+ f'<strong style="font-size:13px;">{ _html_escape (scan_dir )} /</strong>'
387+ f'</td>'
388+ )
389+ # Error count pill
390+ err_pill_color = "#D32F2F" if total_errors > 0 else "#2E7D32"
391+ parts .append (
392+ f'<td style="padding-right:20px;vertical-align:top;">'
393+ f'<span style="font-size:11px;color:#666;">Errors</span><br>'
394+ f'<strong style="font-size:13px;color:{ err_pill_color } ;">'
395+ f'{ total_errors } </strong></td>'
396+ )
397+ # Warning count pill
398+ warn_pill_color = "#F57C00" if total_warnings > 0 else "#2E7D32"
399+ parts .append (
400+ f'<td style="vertical-align:top;">'
401+ f'<span style="font-size:11px;color:#666;">Warnings</span><br>'
402+ f'<strong style="font-size:13px;color:{ warn_pill_color } ;">'
403+ f'{ total_warnings } </strong></td>'
404+ )
405+ parts .append ("</tr></table></td></tr></table></td></tr>" )
406+
407+ # --- Per-pair detail sections ---
408+ parts .append ('<tr><td style="padding:8px 24px 0 24px;">' )
409+ for r in results :
410+ errors = [i for i in r .issues if i .severity == "ERROR" ]
411+ warnings = [i for i in r .issues if i .severity == "WARNING" ]
412+
413+ if not r .issues :
414+ badge = (
415+ '<span style="display:inline-block;padding:2px 8px;'
416+ 'font-size:11px;font-weight:700;'
417+ 'color:#2E7D32;background-color:#E8F5E9;">PASS</span>'
418+ )
419+ elif errors :
420+ badge = (
421+ '<span style="display:inline-block;padding:2px 8px;'
422+ 'font-size:11px;font-weight:700;'
423+ 'color:#D32F2F;background-color:#FFEBEE;">FAIL</span>'
424+ )
425+ else :
426+ badge = (
427+ '<span style="display:inline-block;padding:2px 8px;'
428+ 'font-size:11px;font-weight:700;'
429+ 'color:#F57C00;background-color:#FFF3E0;">WARN</span>'
430+ )
431+
432+ parts .append (
433+ f'<table role="presentation" width="100%" cellpadding="0"'
434+ f' cellspacing="0" style="margin-bottom:12px;border:1px solid #e0e0e0;">'
435+ f'<tr><td style="background-color:#fafafa;padding:10px 12px;'
436+ f'border-bottom:1px solid #e0e0e0;">'
437+ f'{ badge } '
438+ f'<strong style="font-size:13px;">'
439+ f'{ _html_escape (r .pair )} </strong>'
440+ f'<span style="float:right;font-size:11px;color:#888;">'
441+ f'{ len (errors )} error(s), { len (warnings )} warning(s)</span>'
442+ f'</td></tr>'
443+ )
444+
445+ if r .issues :
446+ # --- Errors section ---
447+ if errors :
448+ parts .append (
449+ '<tr><td style="padding:8px 12px 4px 12px;">'
450+ '<strong style="font-size:12px;color:#D32F2F;">'
451+ '● Errors</strong></td></tr>'
452+ '<tr><td style="padding:0 12px;">'
453+ '<table role="presentation" width="100%" cellpadding="0"'
454+ ' cellspacing="0" style="font-size:12px;border:1px solid #f5c6cb;">'
455+ '<tr style="background-color:#FFEBEE;">'
456+ '<th style="text-align:left;padding:6px 10px;'
457+ 'border-bottom:1px solid #f5c6cb;width:180px;">Parameter</th>'
458+ '<th style="text-align:left;padding:6px 10px;'
459+ 'border-bottom:1px solid #f5c6cb;">Details</th></tr>'
460+ )
461+ for idx , issue in enumerate (errors ):
462+ bg = "#ffffff" if idx % 2 == 0 else "#fff5f5"
463+ parts .append (
464+ f'<tr style="background-color:{ bg } ;">'
465+ f'<td style="padding:5px 10px;border-bottom:1px solid #f5c6cb;'
466+ f'vertical-align:top;font-family:Consolas,monospace;'
467+ f'font-size:11px;word-break:break-all;">'
468+ f'{ _html_escape (issue .param_name )} </td>'
469+ f'<td style="padding:5px 10px;border-bottom:1px solid #f5c6cb;'
470+ f'vertical-align:top;">{ _html_escape (issue .message )} </td>'
471+ f'</tr>'
472+ )
473+ parts .append ("</table></td></tr>" )
474+
475+ # --- Warnings section ---
476+ if warnings :
477+ parts .append (
478+ '<tr><td style="padding:8px 12px 4px 12px;">'
479+ '<strong style="font-size:12px;color:#F57C00;">'
480+ '● Warnings</strong></td></tr>'
481+ '<tr><td style="padding:0 12px 8px 12px;">'
482+ '<table role="presentation" width="100%" cellpadding="0"'
483+ ' cellspacing="0" style="font-size:12px;border:1px solid #ffe0b2;">'
484+ '<tr style="background-color:#FFF3E0;">'
485+ '<th style="text-align:left;padding:6px 10px;'
486+ 'border-bottom:1px solid #ffe0b2;width:180px;">Parameter</th>'
487+ '<th style="text-align:left;padding:6px 10px;'
488+ 'border-bottom:1px solid #ffe0b2;">Details</th></tr>'
489+ )
490+ for idx , issue in enumerate (warnings ):
491+ bg = "#ffffff" if idx % 2 == 0 else "#fffaf0"
492+ parts .append (
493+ f'<tr style="background-color:{ bg } ;">'
494+ f'<td style="padding:5px 10px;border-bottom:1px solid #ffe0b2;'
495+ f'vertical-align:top;font-family:Consolas,monospace;'
496+ f'font-size:11px;word-break:break-all;">'
497+ f'{ _html_escape (issue .param_name )} </td>'
498+ f'<td style="padding:5px 10px;border-bottom:1px solid #ffe0b2;'
499+ f'vertical-align:top;">{ _html_escape (issue .message )} </td>'
500+ f'</tr>'
501+ )
502+ parts .append ("</table></td></tr>" )
503+ else :
504+ parts .append (
505+ '<tr><td style="padding:10px 12px;color:#2E7D32;'
506+ 'font-size:12px;">All parameters validated successfully.'
507+ '</td></tr>'
508+ )
509+
510+ parts .append ("</table>" )
511+
512+ parts .append ("</td></tr>" )
513+
514+ # --- Footer with run URL ---
515+ footer_parts : list [str ] = []
516+ if run_url :
517+ footer_parts .append (
518+ f'<a href="{ _html_escape (run_url )} " style="display:inline-block;'
519+ f'padding:8px 16px;background-color:#0078D4;color:#ffffff;'
520+ f'text-decoration:none;font-size:12px;'
521+ f'font-weight:600;">View Workflow Run</a>'
522+ )
523+ if has_errors :
524+ footer_parts .append (
525+ '<p style="margin:10px 0 0 0;font-size:12px;color:#555;">'
526+ 'Please fix the parameter mapping issues at your earliest convenience.</p>'
527+ )
528+ footer_parts .append (
529+ '<p style="margin:10px 0 0 0;font-size:12px;color:#999;">'
530+ 'Best regards,<br>Your Automation Team</p>'
531+ )
532+ parts .append (
533+ f'<tr><td style="padding:14px 24px 20px 24px;border-top:1px solid #e0e0e0;">'
534+ f'{ "" .join (footer_parts )} </td></tr>'
535+ )
536+
537+ # --- Close wrapper ---
538+ parts .append ("</table></td></tr></table></body></html>" )
539+ return "" .join (parts )
540+
541+
542+ # ---------------------------------------------------------------------------
543+ # Console reporting
544+ # ---------------------------------------------------------------------------
545+
302546_COLORS = {
303547 "ERROR" : "\033 [91m" , # red
304548 "WARNING" : "\033 [93m" , # yellow
@@ -379,6 +623,23 @@ def main() -> int:
379623 type = Path ,
380624 help = "Write results as JSON to the given file path." ,
381625 )
626+ parser .add_argument (
627+ "--html-output" ,
628+ type = Path ,
629+ help = "Write a structured HTML email report to the given file path." ,
630+ )
631+ parser .add_argument (
632+ "--accelerator-name" ,
633+ type = str ,
634+ default = "" ,
635+ help = "Accelerator display name for the HTML report header." ,
636+ )
637+ parser .add_argument (
638+ "--run-url" ,
639+ type = str ,
640+ default = "" ,
641+ help = "Workflow run URL to include in the HTML report footer." ,
642+ )
382643 args = parser .parse_args ()
383644
384645 results : list [ValidationResult ] = []
@@ -415,6 +676,19 @@ def main() -> int:
415676 )
416677 print (f"\n JSON report written to { args .json_output } " )
417678
679+ # Optional HTML email report
680+ if args .html_output :
681+ scan_dir = str (args .dir ) if args .dir else ""
682+ html = generate_html_report (
683+ results ,
684+ accelerator_name = args .accelerator_name ,
685+ run_url = args .run_url ,
686+ scan_dir = scan_dir ,
687+ )
688+ args .html_output .parent .mkdir (parents = True , exist_ok = True )
689+ args .html_output .write_text (html , encoding = "utf-8" )
690+ print (f"HTML report written to { args .html_output } " )
691+
418692 has_errors = any (r .has_errors for r in results )
419693 return 1 if args .strict and has_errors else 0
420694
0 commit comments