1use std::{fmt::Write, fs, path::Path};
4
5use super::{CompareResult, Fixture, RunResult, TestSummary};
6use crate::error::Result;
7
8pub fn generate_html_report(
10 results: &[RunResult],
11 fixtures: &[Fixture],
12 output_path: &Path,
13) -> Result<()> {
14 let summary = TestSummary::from_results(results);
15 let html = render_report(results, fixtures, &summary);
16 fs::write(output_path, html)?;
17 Ok(())
18}
19
20fn render_report(results: &[RunResult], fixtures: &[Fixture], summary: &TestSummary) -> String {
21 let mut html = String::new();
22
23 let _ = write!(
25 html,
26 r#"<!DOCTYPE html>
27<html lang="en">
28<head>
29 <meta charset="UTF-8">
30 <meta name="viewport" content="width=device-width, initial-scale=1.0">
31 <title>lgit Fixture Test Report</title>
32 <style>
33 :root {{
34 --bg: #0d1117;
35 --fg: #c9d1d9;
36 --fg-muted: #8b949e;
37 --border: #30363d;
38 --bg-card: #161b22;
39 --green: #3fb950;
40 --red: #f85149;
41 --yellow: #d29922;
42 --blue: #58a6ff;
43 --purple: #a371f7;
44 }}
45 * {{ box-sizing: border-box; margin: 0; padding: 0; }}
46 body {{
47 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif;
48 background: var(--bg);
49 color: var(--fg);
50 line-height: 1.6;
51 padding: 2rem;
52 }}
53 .container {{ max-width: 1400px; margin: 0 auto; }}
54 h1 {{ margin-bottom: 1rem; font-weight: 600; }}
55 .summary {{
56 display: flex;
57 gap: 1rem;
58 margin-bottom: 2rem;
59 flex-wrap: wrap;
60 }}
61 .stat {{
62 background: var(--bg-card);
63 border: 1px solid var(--border);
64 border-radius: 6px;
65 padding: 1rem 1.5rem;
66 min-width: 120px;
67 }}
68 .stat-value {{ font-size: 2rem; font-weight: 600; }}
69 .stat-label {{ color: var(--fg-muted); font-size: 0.875rem; }}
70 .stat.passed .stat-value {{ color: var(--green); }}
71 .stat.failed .stat-value {{ color: var(--red); }}
72 .stat.no-golden .stat-value {{ color: var(--yellow); }}
73 .stat.errors .stat-value {{ color: var(--red); }}
74
75 .fixture {{
76 background: var(--bg-card);
77 border: 1px solid var(--border);
78 border-radius: 6px;
79 margin-bottom: 1.5rem;
80 overflow: hidden;
81 }}
82 .fixture-header {{
83 padding: 1rem 1.5rem;
84 border-bottom: 1px solid var(--border);
85 display: flex;
86 justify-content: space-between;
87 align-items: center;
88 cursor: pointer;
89 }}
90 .fixture-header:hover {{ background: rgba(255,255,255,0.03); }}
91 .fixture-name {{ font-weight: 600; font-size: 1.1rem; }}
92 .fixture-status {{ padding: 0.25rem 0.75rem; border-radius: 20px; font-size: 0.875rem; }}
93 .fixture-status.passed {{ background: rgba(63, 185, 80, 0.15); color: var(--green); }}
94 .fixture-status.failed {{ background: rgba(248, 81, 73, 0.15); color: var(--red); }}
95 .fixture-status.no-golden {{ background: rgba(210, 153, 34, 0.15); color: var(--yellow); }}
96 .fixture-status.error {{ background: rgba(248, 81, 73, 0.15); color: var(--red); }}
97
98 .fixture-content {{
99 display: none;
100 padding: 1.5rem;
101 }}
102 .fixture.expanded .fixture-content {{ display: block; }}
103
104 .comparison {{
105 display: grid;
106 grid-template-columns: 1fr 1fr;
107 gap: 1.5rem;
108 }}
109 @media (max-width: 1000px) {{
110 .comparison {{ grid-template-columns: 1fr; }}
111 }}
112 .comparison-column {{ }}
113 .comparison-column h3 {{
114 font-size: 0.875rem;
115 color: var(--fg-muted);
116 text-transform: uppercase;
117 letter-spacing: 0.05em;
118 margin-bottom: 0.75rem;
119 }}
120 .comparison-column h3.golden {{ color: var(--purple); }}
121 .comparison-column h3.actual {{ color: var(--blue); }}
122
123 .message-box {{
124 background: var(--bg);
125 border: 1px solid var(--border);
126 border-radius: 6px;
127 padding: 1rem;
128 font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
129 font-size: 0.875rem;
130 white-space: pre-wrap;
131 word-break: break-word;
132 }}
133
134 .diff-row {{
135 display: flex;
136 gap: 1rem;
137 margin-bottom: 0.5rem;
138 align-items: baseline;
139 }}
140 .diff-label {{
141 min-width: 80px;
142 font-weight: 500;
143 font-size: 0.875rem;
144 }}
145 .diff-value {{ flex: 1; }}
146 .diff-match {{ color: var(--green); }}
147 .diff-mismatch {{ color: var(--red); }}
148 .diff-arrow {{ color: var(--fg-muted); margin: 0 0.5rem; }}
149
150 .details-list {{
151 list-style: none;
152 font-size: 0.875rem;
153 }}
154 .details-list li {{
155 padding: 0.25rem 0;
156 padding-left: 1rem;
157 position: relative;
158 }}
159 .details-list li::before {{
160 content: "•";
161 position: absolute;
162 left: 0;
163 color: var(--fg-muted);
164 }}
165
166 .error-message {{
167 background: rgba(248, 81, 73, 0.1);
168 border: 1px solid var(--red);
169 color: var(--red);
170 padding: 1rem;
171 border-radius: 6px;
172 font-family: monospace;
173 font-size: 0.875rem;
174 }}
175
176 .timestamp {{
177 color: var(--fg-muted);
178 font-size: 0.875rem;
179 margin-bottom: 1rem;
180 }}
181 </style>
182</head>
183<body>
184 <div class="container">
185 <h1>lgit Fixture Test Report</h1>
186 <p class="timestamp">Generated: {}</p>
187
188 <div class="summary">
189 <div class="stat">
190 <div class="stat-value">{}</div>
191 <div class="stat-label">Total</div>
192 </div>
193 <div class="stat passed">
194 <div class="stat-value">{}</div>
195 <div class="stat-label">Passed</div>
196 </div>
197 <div class="stat failed">
198 <div class="stat-value">{}</div>
199 <div class="stat-label">Failed</div>
200 </div>
201 <div class="stat no-golden">
202 <div class="stat-value">{}</div>
203 <div class="stat-label">No Golden</div>
204 </div>
205 <div class="stat errors">
206 <div class="stat-value">{}</div>
207 <div class="stat-label">Errors</div>
208 </div>
209 </div>
210"#,
211 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
212 summary.total,
213 summary.passed,
214 summary.failed,
215 summary.no_golden,
216 summary.errors
217 );
218
219 for result in results {
221 let fixture = fixtures.iter().find(|f| f.name == result.name);
222 html.push_str(&render_fixture_result(result, fixture));
223 }
224
225 html.push_str(
227 r"
228 </div>
229 <script>
230 document.querySelectorAll('.fixture-header').forEach(header => {
231 header.addEventListener('click', () => {
232 header.parentElement.classList.toggle('expanded');
233 });
234 });
235 // Expand failed fixtures by default
236 document.querySelectorAll('.fixture.failed, .fixture.error').forEach(f => {
237 f.classList.add('expanded');
238 });
239 </script>
240</body>
241</html>
242",
243 );
244
245 html
246}
247
248fn render_fixture_result(result: &RunResult, fixture: Option<&Fixture>) -> String {
249 let (status_class, status_text) = if result.error.is_some() {
250 ("error", "Error")
251 } else if let Some(cmp) = &result.comparison {
252 if cmp.passed {
253 ("passed", "Passed")
254 } else {
255 ("failed", "Failed")
256 }
257 } else {
258 ("no-golden", "No Golden")
259 };
260
261 let fixture_class = format!("fixture {status_class}");
262
263 let mut html = format!(
264 r#"
265 <div class="{}">
266 <div class="fixture-header">
267 <span class="fixture-name">{}</span>
268 <span class="fixture-status {}">{}</span>
269 </div>
270 <div class="fixture-content">
271"#,
272 fixture_class, result.name, status_class, status_text
273 );
274
275 if let Some(err) = &result.error {
277 let _ = write!(html, r#"<div class="error-message">{}</div>"#, html_escape(err));
278 html.push_str("</div></div>\n");
279 return html;
280 }
281
282 if let Some(cmp) = &result.comparison {
284 html.push_str(&render_comparison(cmp, result, fixture));
285 } else {
286 html.push_str(&render_actual_only(result));
288 }
289
290 html.push_str("</div></div>\n");
291 html
292}
293
294fn render_comparison(cmp: &CompareResult, result: &RunResult, fixture: Option<&Fixture>) -> String {
295 let mut html = String::new();
296
297 html.push_str(r#"<div style="margin-bottom: 1.5rem;">"#);
299
300 let type_class = if cmp.type_match {
302 "diff-match"
303 } else {
304 "diff-mismatch"
305 };
306 if let Some(f) = fixture
307 && let Some(golden) = &f.golden
308 {
309 let _ = write!(
310 html,
311 r#"<div class="diff-row">
312 <span class="diff-label">Type:</span>
313 <span class="diff-value {}">
314 {}<span class="diff-arrow">→</span>{}
315 </span>
316 </div>"#,
317 type_class,
318 golden.analysis.commit_type.as_str(),
319 result.analysis.commit_type.as_str()
320 );
321 }
322
323 let scope_class = if cmp.scope_match {
325 "diff-match"
326 } else {
327 "diff-mismatch"
328 };
329 let scope_value = match &cmp.scope_diff {
330 Some(diff) => html_escape(diff),
331 None => result
332 .analysis
333 .scope
334 .as_ref()
335 .map_or_else(|| "(none)".to_string(), |s| s.to_string()),
336 };
337 let _ = write!(
338 html,
339 r#"<div class="diff-row">
340 <span class="diff-label">Scope:</span>
341 <span class="diff-value {scope_class}">{scope_value}</span>
342 </div>"#
343 );
344
345 let _ = write!(
347 html,
348 r#"<div class="diff-row">
349 <span class="diff-label">Details:</span>
350 <span class="diff-value">{} golden → {} actual</span>
351 </div>"#,
352 cmp.golden_detail_count, cmp.actual_detail_count
353 );
354
355 html.push_str("</div>");
356
357 html.push_str(r#"<div class="comparison">"#);
359
360 if let Some(f) = fixture
362 && let Some(golden) = &f.golden
363 {
364 let _ = write!(
365 html,
366 r#"<div class="comparison-column">
367 <h3 class="golden">Golden (Expected)</h3>
368 <div class="message-box">{}</div>
369 </div>"#,
370 html_escape(&golden.final_message)
371 );
372 }
373
374 let _ = write!(
376 html,
377 r#"<div class="comparison-column">
378 <h3 class="actual">Actual (Current)</h3>
379 <div class="message-box">{}</div>
380 </div>"#,
381 html_escape(&result.final_message)
382 );
383
384 html.push_str("</div>");
385
386 html
387}
388
389fn render_actual_only(result: &RunResult) -> String {
390 format!(
391 r#"<div>
392 <div class="diff-row">
393 <span class="diff-label">Type:</span>
394 <span class="diff-value">{}</span>
395 </div>
396 <div class="diff-row">
397 <span class="diff-label">Scope:</span>
398 <span class="diff-value">{}</span>
399 </div>
400 <div class="diff-row">
401 <span class="diff-label">Details:</span>
402 <span class="diff-value">{} points</span>
403 </div>
404 <h3 style="margin: 1rem 0 0.5rem; color: var(--blue); font-size: 0.875rem;">Generated Message</h3>
405 <div class="message-box">{}</div>
406 </div>"#,
407 result.analysis.commit_type.as_str(),
408 result
409 .analysis
410 .scope
411 .as_ref()
412 .map_or("(none)", |s| s.as_str()),
413 result.analysis.details.len(),
414 html_escape(&result.final_message)
415 )
416}
417
418fn html_escape(s: &str) -> String {
419 s.replace('&', "&")
420 .replace('<', "<")
421 .replace('>', ">")
422 .replace('"', """)
423 .replace('\'', "'")
424}