use std::{fmt::Write, fs, path::Path};
use super::{CompareResult, Fixture, RunResult, TestSummary};
use crate::error::Result;
pub fn generate_html_report(
results: &[RunResult],
fixtures: &[Fixture],
output_path: &Path,
) -> Result<()> {
let summary = TestSummary::from_results(results);
let html = render_report(results, fixtures, &summary);
fs::write(output_path, html)?;
Ok(())
}
fn render_report(results: &[RunResult], fixtures: &[Fixture], summary: &TestSummary) -> String {
let mut html = String::new();
let _ = write!(
html,
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>lgit Fixture Test Report</title>
<style>
:root {{
--bg: #0d1117;
--fg: #c9d1d9;
--fg-muted: #8b949e;
--border: #30363d;
--bg-card: #161b22;
--green: #3fb950;
--red: #f85149;
--yellow: #d29922;
--blue: #58a6ff;
--purple: #a371f7;
}}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif;
background: var(--bg);
color: var(--fg);
line-height: 1.6;
padding: 2rem;
}}
.container {{ max-width: 1400px; margin: 0 auto; }}
h1 {{ margin-bottom: 1rem; font-weight: 600; }}
.summary {{
display: flex;
gap: 1rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}}
.stat {{
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 1rem 1.5rem;
min-width: 120px;
}}
.stat-value {{ font-size: 2rem; font-weight: 600; }}
.stat-label {{ color: var(--fg-muted); font-size: 0.875rem; }}
.stat.passed .stat-value {{ color: var(--green); }}
.stat.failed .stat-value {{ color: var(--red); }}
.stat.no-golden .stat-value {{ color: var(--yellow); }}
.stat.errors .stat-value {{ color: var(--red); }}
.fixture {{
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 1.5rem;
overflow: hidden;
}}
.fixture-header {{
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
}}
.fixture-header:hover {{ background: rgba(255,255,255,0.03); }}
.fixture-name {{ font-weight: 600; font-size: 1.1rem; }}
.fixture-status {{ padding: 0.25rem 0.75rem; border-radius: 20px; font-size: 0.875rem; }}
.fixture-status.passed {{ background: rgba(63, 185, 80, 0.15); color: var(--green); }}
.fixture-status.failed {{ background: rgba(248, 81, 73, 0.15); color: var(--red); }}
.fixture-status.no-golden {{ background: rgba(210, 153, 34, 0.15); color: var(--yellow); }}
.fixture-status.error {{ background: rgba(248, 81, 73, 0.15); color: var(--red); }}
.fixture-content {{
display: none;
padding: 1.5rem;
}}
.fixture.expanded .fixture-content {{ display: block; }}
.comparison {{
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}}
@media (max-width: 1000px) {{
.comparison {{ grid-template-columns: 1fr; }}
}}
.comparison-column {{ }}
.comparison-column h3 {{
font-size: 0.875rem;
color: var(--fg-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.75rem;
}}
.comparison-column h3.golden {{ color: var(--purple); }}
.comparison-column h3.actual {{ color: var(--blue); }}
.message-box {{
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 1rem;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 0.875rem;
white-space: pre-wrap;
word-break: break-word;
}}
.diff-row {{
display: flex;
gap: 1rem;
margin-bottom: 0.5rem;
align-items: baseline;
}}
.diff-label {{
min-width: 80px;
font-weight: 500;
font-size: 0.875rem;
}}
.diff-value {{ flex: 1; }}
.diff-match {{ color: var(--green); }}
.diff-mismatch {{ color: var(--red); }}
.diff-arrow {{ color: var(--fg-muted); margin: 0 0.5rem; }}
.details-list {{
list-style: none;
font-size: 0.875rem;
}}
.details-list li {{
padding: 0.25rem 0;
padding-left: 1rem;
position: relative;
}}
.details-list li::before {{
content: "•";
position: absolute;
left: 0;
color: var(--fg-muted);
}}
.error-message {{
background: rgba(248, 81, 73, 0.1);
border: 1px solid var(--red);
color: var(--red);
padding: 1rem;
border-radius: 6px;
font-family: monospace;
font-size: 0.875rem;
}}
.timestamp {{
color: var(--fg-muted);
font-size: 0.875rem;
margin-bottom: 1rem;
}}
</style>
</head>
<body>
<div class="container">
<h1>lgit Fixture Test Report</h1>
<p class="timestamp">Generated: {}</p>
<div class="summary">
<div class="stat">
<div class="stat-value">{}</div>
<div class="stat-label">Total</div>
</div>
<div class="stat passed">
<div class="stat-value">{}</div>
<div class="stat-label">Passed</div>
</div>
<div class="stat failed">
<div class="stat-value">{}</div>
<div class="stat-label">Failed</div>
</div>
<div class="stat no-golden">
<div class="stat-value">{}</div>
<div class="stat-label">No Golden</div>
</div>
<div class="stat errors">
<div class="stat-value">{}</div>
<div class="stat-label">Errors</div>
</div>
</div>
"#,
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
summary.total,
summary.passed,
summary.failed,
summary.no_golden,
summary.errors
);
for result in results {
let fixture = fixtures.iter().find(|f| f.name == result.name);
html.push_str(&render_fixture_result(result, fixture));
}
html.push_str(
r"
</div>
<script>
document.querySelectorAll('.fixture-header').forEach(header => {
header.addEventListener('click', () => {
header.parentElement.classList.toggle('expanded');
});
});
// Expand failed fixtures by default
document.querySelectorAll('.fixture.failed, .fixture.error').forEach(f => {
f.classList.add('expanded');
});
</script>
</body>
</html>
",
);
html
}
fn render_fixture_result(result: &RunResult, fixture: Option<&Fixture>) -> String {
let (status_class, status_text) = if result.error.is_some() {
("error", "Error")
} else if let Some(cmp) = &result.comparison {
if cmp.passed {
("passed", "Passed")
} else {
("failed", "Failed")
}
} else {
("no-golden", "No Golden")
};
let fixture_class = format!("fixture {status_class}");
let mut html = format!(
r#"
<div class="{}">
<div class="fixture-header">
<span class="fixture-name">{}</span>
<span class="fixture-status {}">{}</span>
</div>
<div class="fixture-content">
"#,
fixture_class, result.name, status_class, status_text
);
if let Some(err) = &result.error {
let _ = write!(html, r#"<div class="error-message">{}</div>"#, html_escape(err));
html.push_str("</div></div>\n");
return html;
}
if let Some(cmp) = &result.comparison {
html.push_str(&render_comparison(cmp, result, fixture));
} else {
html.push_str(&render_actual_only(result));
}
html.push_str("</div></div>\n");
html
}
fn render_comparison(cmp: &CompareResult, result: &RunResult, fixture: Option<&Fixture>) -> String {
let mut html = String::new();
html.push_str(r#"<div style="margin-bottom: 1.5rem;">"#);
let type_class = if cmp.type_match {
"diff-match"
} else {
"diff-mismatch"
};
if let Some(f) = fixture
&& let Some(golden) = &f.golden
{
let _ = write!(
html,
r#"<div class="diff-row">
<span class="diff-label">Type:</span>
<span class="diff-value {}">
{}<span class="diff-arrow">→</span>{}
</span>
</div>"#,
type_class,
golden.analysis.commit_type.as_str(),
result.analysis.commit_type.as_str()
);
}
let scope_class = if cmp.scope_match {
"diff-match"
} else {
"diff-mismatch"
};
let scope_value = match &cmp.scope_diff {
Some(diff) => html_escape(diff),
None => result
.analysis
.scope
.as_ref()
.map_or_else(|| "(none)".to_string(), |s| s.to_string()),
};
let _ = write!(
html,
r#"<div class="diff-row">
<span class="diff-label">Scope:</span>
<span class="diff-value {scope_class}">{scope_value}</span>
</div>"#
);
let _ = write!(
html,
r#"<div class="diff-row">
<span class="diff-label">Details:</span>
<span class="diff-value">{} golden → {} actual</span>
</div>"#,
cmp.golden_detail_count, cmp.actual_detail_count
);
html.push_str("</div>");
html.push_str(r#"<div class="comparison">"#);
if let Some(f) = fixture
&& let Some(golden) = &f.golden
{
let _ = write!(
html,
r#"<div class="comparison-column">
<h3 class="golden">Golden (Expected)</h3>
<div class="message-box">{}</div>
</div>"#,
html_escape(&golden.final_message)
);
}
let _ = write!(
html,
r#"<div class="comparison-column">
<h3 class="actual">Actual (Current)</h3>
<div class="message-box">{}</div>
</div>"#,
html_escape(&result.final_message)
);
html.push_str("</div>");
html
}
fn render_actual_only(result: &RunResult) -> String {
format!(
r#"<div>
<div class="diff-row">
<span class="diff-label">Type:</span>
<span class="diff-value">{}</span>
</div>
<div class="diff-row">
<span class="diff-label">Scope:</span>
<span class="diff-value">{}</span>
</div>
<div class="diff-row">
<span class="diff-label">Details:</span>
<span class="diff-value">{} points</span>
</div>
<h3 style="margin: 1rem 0 0.5rem; color: var(--blue); font-size: 0.875rem;">Generated Message</h3>
<div class="message-box">{}</div>
</div>"#,
result.analysis.commit_type.as_str(),
result
.analysis
.scope
.as_ref()
.map_or("(none)", |s| s.as_str()),
result.analysis.details.len(),
html_escape(&result.final_message)
)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}