llm_git/testing/
report.rs

1//! HTML report generation for fixture test results
2
3use std::{fmt::Write, fs, path::Path};
4
5use super::{CompareResult, Fixture, RunResult, TestSummary};
6use crate::error::Result;
7
8/// Generate an HTML report from test results
9pub 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   // Header
24   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   // Render each fixture result
220   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   // Footer and JS
226   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   // Error case
276   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   // Comparison details
283   if let Some(cmp) = &result.comparison {
284      html.push_str(&render_comparison(cmp, result, fixture));
285   } else {
286      // No golden - show actual output
287      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   // Type/Scope comparison row
298   html.push_str(r#"<div style="margin-bottom: 1.5rem;">"#);
299
300   // Type
301   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   // Scope
324   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   // Detail counts
346   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   // Side-by-side comparison
358   html.push_str(r#"<div class="comparison">"#);
359
360   // Golden column
361   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   // Actual column
375   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('&', "&amp;")
420      .replace('<', "&lt;")
421      .replace('>', "&gt;")
422      .replace('"', "&quot;")
423      .replace('\'', "&#39;")
424}