|
| 1 | +"""Terminal and HTML reporting.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +from math import pi |
| 6 | +from pathlib import Path |
| 7 | + |
| 8 | +from jinja2 import Environment, FileSystemLoader, select_autoescape |
| 9 | +from rich import box |
| 10 | +from rich.console import Console |
| 11 | +from rich.panel import Panel |
| 12 | +from rich.table import Table |
| 13 | +from rich.text import Text |
| 14 | + |
| 15 | +from roast.analyzer import AnalysisReport, Issue |
| 16 | +from roast.roaster import RoastResult |
| 17 | + |
| 18 | +HEADER_ART = r""" |
| 19 | +██████╗ ██████╗ █████╗ ███████╗████████╗ |
| 20 | +██╔══██╗██╔═══██╗██╔══██╗██╔════╝╚══██╔══╝ |
| 21 | +██████╔╝██║ ██║███████║███████╗ ██║ |
| 22 | +██╔══██╗██║ ██║██╔══██║╚════██║ ██║ |
| 23 | +██║ ██║╚██████╔╝██║ ██║███████║ ██║ |
| 24 | +╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═╝ |
| 25 | +""" |
| 26 | + |
| 27 | + |
| 28 | +def _severity_order(issue: Issue) -> int: |
| 29 | + return {"high": 0, "medium": 1, "low": 2}.get(issue.severity, 3) |
| 30 | + |
| 31 | + |
| 32 | +def _score_color(score: int) -> str: |
| 33 | + if score >= 75: |
| 34 | + return "green" |
| 35 | + if score >= 40: |
| 36 | + return "yellow" |
| 37 | + return "red" |
| 38 | + |
| 39 | + |
| 40 | +def _score_bar(score: int, width: int = 24) -> str: |
| 41 | + filled = int((score / 100) * width) |
| 42 | + empty = width - filled |
| 43 | + color = _score_color(score) |
| 44 | + return f"[{color}]{'█' * filled}[/]{'░' * empty}" |
| 45 | + |
| 46 | + |
| 47 | +def render_terminal_report( |
| 48 | + report: AnalysisReport, |
| 49 | + roast: RoastResult, |
| 50 | + output_path: str | Path, |
| 51 | + console: Console | None = None, |
| 52 | +) -> None: |
| 53 | + """Render the terminal scorecard with Rich.""" |
| 54 | + console = console or Console() |
| 55 | + console.print(Panel.fit(Text(HEADER_ART, style="bold magenta"), title="🔥 ROAST-MY-CODE", border_style="red")) |
| 56 | + |
| 57 | + score_table = Table(title="Score Card", box=box.SIMPLE_HEAVY) |
| 58 | + score_table.add_column("Category", style="bold") |
| 59 | + score_table.add_column("Score", justify="right") |
| 60 | + score_table.add_column("Bar Chart") |
| 61 | + for category in ("AI Slop", "Code Quality", "Style", "Overall"): |
| 62 | + score = report.scores.get(category, 0) |
| 63 | + score_table.add_row( |
| 64 | + category, |
| 65 | + f"[{_score_color(score)}]{score}[/]", |
| 66 | + _score_bar(score), |
| 67 | + ) |
| 68 | + console.print(score_table) |
| 69 | + |
| 70 | + issues_table = Table(title="Top 10 Issues", box=box.SIMPLE) |
| 71 | + issues_table.add_column("File", overflow="fold") |
| 72 | + issues_table.add_column("Line", justify="right", width=6) |
| 73 | + issues_table.add_column("Severity", width=8) |
| 74 | + issues_table.add_column("Description", overflow="fold") |
| 75 | + |
| 76 | + top_issues = sorted(report.issues, key=lambda issue: (_severity_order(issue), issue.file, issue.line or 0))[:10] |
| 77 | + if not top_issues: |
| 78 | + issues_table.add_row("-", "-", "-", "No issues found. Miracles happen.") |
| 79 | + for issue in top_issues: |
| 80 | + row_style = {"high": "bold red", "medium": "yellow", "low": "white"}.get(issue.severity, "white") |
| 81 | + issues_table.add_row( |
| 82 | + issue.file, |
| 83 | + str(issue.line) if issue.line is not None else "-", |
| 84 | + issue.severity.upper(), |
| 85 | + issue.description, |
| 86 | + style=row_style, |
| 87 | + ) |
| 88 | + console.print(issues_table) |
| 89 | + |
| 90 | + console.print(Panel.fit(Text(roast.headline, style="bold yellow"), title="Roast Headline", border_style="yellow")) |
| 91 | + for line in roast.roast_lines: |
| 92 | + console.print(f"🔥 {line}") |
| 93 | + |
| 94 | + verdict_color = {"SHIP IT": "green", "NEEDS WORK": "yellow", "BURN IT DOWN": "red"}.get(roast.verdict, "white") |
| 95 | + verdict_text = Text(f"{roast.verdict_emoji} {roast.verdict} {roast.verdict_emoji}", style=f"bold {verdict_color}") |
| 96 | + console.print(Panel.fit(verdict_text, title="Verdict", border_style=verdict_color)) |
| 97 | + |
| 98 | + console.print(f"\n[bold cyan]HTML report saved to: {Path(output_path)}[/]") |
| 99 | + |
| 100 | + |
| 101 | +def _badge_color(score: int) -> str: |
| 102 | + if score >= 75: |
| 103 | + return "#2ea043" |
| 104 | + if score >= 40: |
| 105 | + return "#d29922" |
| 106 | + return "#f85149" |
| 107 | + |
| 108 | + |
| 109 | +def export_html_report( |
| 110 | + report: AnalysisReport, |
| 111 | + roast: RoastResult, |
| 112 | + output_path: str | Path, |
| 113 | +) -> Path: |
| 114 | + """Render a single self-contained HTML report.""" |
| 115 | + template_dir = Path(__file__).resolve().parent / "templates" |
| 116 | + env = Environment( |
| 117 | + loader=FileSystemLoader(str(template_dir)), |
| 118 | + autoescape=select_autoescape(enabled_extensions=("html",)), |
| 119 | + ) |
| 120 | + template = env.get_template("report.html") |
| 121 | + |
| 122 | + overall_score = report.scores.get("Overall", 0) |
| 123 | + radius = 90 |
| 124 | + circumference = 2 * pi * radius |
| 125 | + dash_offset = circumference * (1 - (overall_score / 100)) |
| 126 | + issue_rows = sorted(report.issues, key=lambda issue: (_severity_order(issue), issue.file, issue.line or 0)) |
| 127 | + score_items = [ |
| 128 | + { |
| 129 | + "name": "AI Slop", |
| 130 | + "value": report.scores.get("AI Slop", 0), |
| 131 | + "color": _badge_color(report.scores.get("AI Slop", 0)), |
| 132 | + }, |
| 133 | + { |
| 134 | + "name": "Code Quality", |
| 135 | + "value": report.scores.get("Code Quality", 0), |
| 136 | + "color": _badge_color(report.scores.get("Code Quality", 0)), |
| 137 | + }, |
| 138 | + { |
| 139 | + "name": "Style", |
| 140 | + "value": report.scores.get("Style", 0), |
| 141 | + "color": _badge_color(report.scores.get("Style", 0)), |
| 142 | + }, |
| 143 | + ] |
| 144 | + |
| 145 | + badge_markdown = f"" |
| 146 | + rendered = template.render( |
| 147 | + report=report, |
| 148 | + roast=roast, |
| 149 | + overall_score=overall_score, |
| 150 | + score_items=score_items, |
| 151 | + issues=issue_rows, |
| 152 | + score_ring_circumference=f"{circumference:.2f}", |
| 153 | + score_ring_offset=f"{dash_offset:.2f}", |
| 154 | + overall_color=_badge_color(overall_score), |
| 155 | + badge_markdown=badge_markdown, |
| 156 | + ) |
| 157 | + |
| 158 | + output = Path(output_path).expanduser().resolve() |
| 159 | + output.write_text(rendered, encoding="utf-8") |
| 160 | + return output |
0 commit comments