use crate::core::{AuditResult, Finding, ForgeGuardError, RiskLevel, Severity};
pub fn generate_report(result: &AuditResult) -> Result<String, ForgeGuardError> {
let mut html = String::new();
html.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n");
html.push_str("<meta charset=\"UTF-8\">\n");
html.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n");
html.push_str("<title>Forge Guard — Security Audit Report</title>\n");
html.push_str("<style>\n");
html.push_str(STYLESHEET);
html.push_str("</style>\n</head>\n<body>\n");
html.push_str("<div class=\"container\">\n");
html.push_str(
r#"<header>
<div class="logo">🔒 Forge Guard</div>
<div class="subtitle">Security Audit Report</div>
</header>"#,
);
html.push_str(r#"<div class="meta-bar">"#);
html.push_str(&format!(
r#"<div class="meta-item"><span class="meta-label">Project</span><span class="meta-value">{}</span></div>"#,
escape_html(&result.project_name)
));
html.push_str(&format!(
r#"<div class="meta-item"><span class="meta-label">Chain</span><span class="meta-value">{}</span></div>"#,
escape_html(&result.chain)
));
html.push_str(&format!(
r#"<div class="meta-item"><span class="meta-label">Duration</span><span class="meta-value">{:.2}s</span></div>"#,
result.duration_seconds
));
html.push_str(&format!(
r#"<div class="meta-item"><span class="meta-label">Files</span><span class="meta-value">{}</span></div>"#,
result.summary.files_analyzed
));
html.push_str(&format!(
r#"<div class="meta-item"><span class="meta-label">Date</span><span class="meta-value">{}</span></div>"#,
&result.timestamp[..10]
));
html.push_str("</div>\n");
let verdict_class = if result.deployment_approved && result.production_ready {
"verdict-pass"
} else {
"verdict-fail"
};
let verdict_text = if result.deployment_approved && result.production_ready {
"✅ PASS — Ready for Deployment"
} else if result.production_ready {
"⚠️ PASS WITH WARNINGS — Review findings before deploying"
} else {
"❌ FAIL — Must fix issues before deployment"
};
html.push_str(&format!(
r#"<div class="verdict {}"><div class="verdict-text">{}</div></div>"#,
verdict_class, verdict_text
));
html.push_str(r#"<div class="section"><h2>📊 Score Overview</h2><div class="score-grid">"#);
let overall_color = score_color(result.overall_score);
html.push_str(&format!(
r#"<div class="score-card overall" style="border-left: 4px solid {};">
<div class="score-label">Overall Score</div>
<div class="score-value" style="color: {};">{}/100</div>
</div>"#,
overall_color, overall_color, result.overall_score
));
let risk_color = risk_color(result.risk_level);
html.push_str(&format!(
r#"<div class="score-card" style="border-left: 4px solid {};">
<div class="score-label">Risk Level</div>
<div class="score-value" style="color: {};">{}</div>
</div>"#,
risk_color, risk_color, result.risk_level
));
let dep_icon = if result.deployment_approved {
"✅"
} else {
"❌"
};
let dep_color = if result.deployment_approved {
"#22c55e"
} else {
"#ef4444"
};
html.push_str(&format!(
r#"<div class="score-card" style="border-left: 4px solid {};">
<div class="score-label">Deployment</div>
<div class="score-value" style="color: {};">{} {}</div>
</div>"#,
dep_color,
dep_color,
dep_icon,
if result.deployment_approved {
"APPROVED"
} else {
"BLOCKED"
}
));
html.push_str("</div></div>\n");
html.push_str(r#"<div class="section"><h2>🐛 Finding Breakdown</h2><div class="finding-bar">"#);
let total = result.summary.total_findings.max(1);
let crit_pct = result.summary.critical_count as f64 / total as f64 * 100.0;
let high_pct = result.summary.high_count as f64 / total as f64 * 100.0;
let med_pct = result.summary.medium_count as f64 / total as f64 * 100.0;
let low_pct = result.summary.low_count as f64 / total as f64 * 100.0;
let info_pct = result.summary.info_count as f64 / total as f64 * 100.0;
if crit_pct > 0.0 {
html.push_str(&format!(r#"<div class="bar-segment bar-critical" style="width: {:.1}%;" title="Critical: {}"></div>"#, crit_pct, result.summary.critical_count));
}
if high_pct > 0.0 {
html.push_str(&format!(
r#"<div class="bar-segment bar-high" style="width: {:.1}%;" title="High: {}"></div>"#,
high_pct, result.summary.high_count
));
}
if med_pct > 0.0 {
html.push_str(&format!(r#"<div class="bar-segment bar-medium" style="width: {:.1}%;" title="Medium: {}"></div>"#, med_pct, result.summary.medium_count));
}
if low_pct > 0.0 {
html.push_str(&format!(
r#"<div class="bar-segment bar-low" style="width: {:.1}%;" title="Low: {}"></div>"#,
low_pct, result.summary.low_count
));
}
if info_pct > 0.0 {
html.push_str(&format!(
r#"<div class="bar-segment bar-info" style="width: {:.1}%;" title="Info: {}"></div>"#,
info_pct, result.summary.info_count
));
}
html.push_str("</div><div class=\"finding-legend\">");
html.push_str(&format!(r#"<span class="legend-item"><span class="legend-dot" style="background:#dc2626;"></span> Critical <strong>{}</strong></span>"#, result.summary.critical_count));
html.push_str(&format!(r#"<span class="legend-item"><span class="legend-dot" style="background:#f97316;"></span> High <strong>{}</strong></span>"#, result.summary.high_count));
html.push_str(&format!(r#"<span class="legend-item"><span class="legend-dot" style="background:#eab308;"></span> Medium <strong>{}</strong></span>"#, result.summary.medium_count));
html.push_str(&format!(r#"<span class="legend-item"><span class="legend-dot" style="background:#3b82f6;"></span> Low <strong>{}</strong></span>"#, result.summary.low_count));
html.push_str(&format!(r#"<span class="legend-item"><span class="legend-dot" style="background:#6b7280;"></span> Info <strong>{}</strong></span>"#, result.summary.info_count));
html.push_str("</div></div>\n");
html.push_str(r#"<div class="section"><h2>📈 Security Scores</h2><div class="scores-table">"#);
let score_pairs = [
("🔐 Access Control", result.scores.access_control),
("🛡️ Security", result.scores.security),
("🎯 Fuzzing", result.scores.fuzzing),
("⛽ Gas", result.scores.gas),
("🏗️ Architecture", result.scores.architecture),
("⬆️ Upgradeability", result.scores.upgradeability),
("📦 Dependencies", result.scores.dependencies),
("🚀 Deployment", result.scores.deployment),
("🔗 Proxy Safety", result.scores.proxy_safety),
("⛓️ Chain Compat", result.scores.chain_compatibility),
("✅ Production Ready", result.scores.production_readiness),
("💥 Exploit Resistance", result.scores.exploit_resistance),
];
for (label, score) in &score_pairs {
let bar_color = score_color(*score);
html.push_str(&format!(
r#"<div class="score-row">
<div class="score-row-label">{}</div>
<div class="score-row-bar"><div class="score-row-fill" style="width:{}%;background:{};"></div></div>
<div class="score-row-value" style="color:{};">{}/100</div>
</div>"#,
label, score, bar_color, bar_color, score
));
}
html.push_str("</div></div>\n");
if !result.findings.is_empty() {
html.push_str(&format!(
r#"<div class="section"><h2>📋 Detailed Findings <span class="badge">{}</span></h2>"#,
result.findings.len()
));
let severity_order = [
Severity::Critical,
Severity::High,
Severity::Medium,
Severity::Low,
Severity::Informational,
];
let severity_labels = [
("🛑 CRITICAL", "critical"),
("🔴 HIGH", "high"),
("🟡 MEDIUM", "medium"),
("🔵 LOW", "low"),
("⚪ INFO", "info"),
];
for (sev_idx, severity) in severity_order.iter().enumerate() {
let sev_findings: Vec<&Finding> = result
.findings
.iter()
.filter(|f| f.severity == *severity)
.collect();
if sev_findings.is_empty() {
continue;
}
let (sev_label, sev_class) = severity_labels[sev_idx];
html.push_str(&format!(
r#"<details open><summary class="finding-group-header {}">{} ({} found)</summary>"#,
sev_class,
sev_label,
sev_findings.len()
));
for (i, finding) in sev_findings.iter().enumerate() {
html.push_str(&format!(
r#"<div class="finding-card {}">
<div class="finding-title">{}. <span class="finding-id">{}</span> {}</div>
<div class="finding-meta">
<span class="meta-tag severity-{}">{}</span>
<span class="meta-tag">📂 {}</span>
<span class="meta-tag">📁 {}</span>"#,
sev_class,
i + 1,
escape_html(&finding.id),
escape_html(&finding.title),
sev_class,
finding.severity,
escape_html(&finding.category),
finding.file.as_deref().unwrap_or("unknown"),
));
if let Some(line) = finding.line {
html.push_str(&format!(
r#"<span class="meta-tag">📍 Line {}</span>"#,
line
));
}
html.push_str("</div>");
if !finding.description.is_empty() {
html.push_str(&format!(
r#"<div class="finding-desc">{}</div>"#,
escape_html(&finding.description)
));
}
if let Some(snippet) = &finding.code_snippet {
html.push_str(&format!(
r#"<pre class="code-snippet"><code>{}</code></pre>"#,
escape_html(snippet)
));
}
html.push_str(&format!(
r#"<div class="finding-rec"><strong>💡 Recommendation:</strong> {}</div>"#,
escape_html(&finding.recommendation)
));
if let Some(exploit_path) = &finding.exploit_path {
html.push_str(
r#"<div class="finding-exploit"><strong>🔗 Exploit Path:</strong><ol>"#,
);
for step in exploit_path {
html.push_str(&format!("<li>{}</li>", escape_html(step)));
}
html.push_str("</ol></div>");
}
if !finding.references.is_empty() {
html.push_str(
r#"<div class="finding-refs"><strong>📎 References:</strong><ul>"#,
);
for ref_ in &finding.references {
html.push_str(&format!("<li>{}</li>", escape_html(ref_)));
}
html.push_str("</ul></div>");
}
html.push_str("</div>\n");
}
html.push_str("</details>\n");
}
html.push_str("</div>\n");
}
html.push_str(&format!(
r#"<footer>
<p>Report generated by <a href="https://github.com/codetibo/forge-guard">Forge Guard</a>
on {}</p>
</footer>"#,
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
));
html.push_str("</div>\n</body>\n</html>\n");
Ok(html)
}
const STYLESHEET: &str = r##"
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f172a; --surface: #1e293b; --surface2: #334155;
--text: #f1f5f9; --text2: #94a3b8; --accent: #38bdf8;
--critical: #dc2626; --high: #f97316; --medium: #eab308;
--low: #3b82f6; --info: #6b7280;
--border: #334155; --radius: 8px; --shadow: 0 1px 3px rgba(0,0,0,0.3);
}
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg); color: var(--text); line-height: 1.6; padding: 20px; }
.container { max-width: 960px; margin: 0 auto; }
header { text-align: center; padding: 32px 0 16px; }
.logo { font-size: 28px; font-weight: 800; background: linear-gradient(135deg,#38bdf8,#818cf8);
-webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.subtitle { font-size: 14px; color: var(--text2); margin-top: 4px; letter-spacing: 1px;
text-transform: uppercase; }
.meta-bar { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center;
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
padding: 16px 20px; margin: 16px 0; }
.meta-item { display: flex; flex-direction: column; align-items: center; min-width: 90px; }
.meta-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text2); }
.meta-value { font-size: 15px; font-weight: 600; margin-top: 2px; }
.verdict { text-align: center; padding: 20px; border-radius: var(--radius);
margin: 16px 0; font-size: 18px; font-weight: 700; }
.verdict-pass { background: linear-gradient(135deg,#064e3b,#065f46);
border: 1px solid #22c55e; color: #bbf7d0; }
.verdict-fail { background: linear-gradient(135deg,#450a0a,#7f1d1d);
border: 1px solid #ef4444; color: #fecaca; }
.section { background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 24px; margin: 16px 0; }
.section h2 { font-size: 18px; font-weight: 700; margin-bottom: 16px;
display: flex; align-items: center; gap: 8px; }
.score-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px; }
.score-card { background: var(--surface2); border-radius: 6px; padding: 16px; }
.score-label { font-size: 12px; color: var(--text2); text-transform: uppercase;
letter-spacing: 0.5px; }
.score-value { font-size: 28px; font-weight: 800; margin-top: 4px; }
.finding-bar { display: flex; height: 24px; border-radius: 12px; overflow: hidden;
background: var(--surface2); }
.bar-segment { transition: width 0.3s; }
.bar-critical { background: var(--critical); }
.bar-high { background: var(--high); }
.bar-medium { background: var(--medium); }
.bar-low { background: var(--low); }
.bar-info { background: var(--info); }
.finding-legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 12px;
justify-content: center; }
.legend-item { display: flex; align-items: center; gap: 4px; font-size: 13px; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.scores-table { display: flex; flex-direction: column; gap: 8px; }
.score-row { display: flex; align-items: center; gap: 12px; }
.score-row-label { width: 160px; font-size: 13px; flex-shrink: 0; }
.score-row-bar { flex: 1; height: 20px; background: var(--surface2); border-radius: 10px;
overflow: hidden; }
.score-row-fill { height: 100%; border-radius: 10px; transition: width 0.3s; }
.score-row-value { width: 60px; text-align: right; font-size: 13px; font-weight: 700;
flex-shrink: 0; }
.badge { background: var(--accent); color: #0f172a; font-size: 13px; font-weight: 700;
padding: 2px 10px; border-radius: 12px; }
details { margin-bottom: 12px; }
summary { cursor: pointer; padding: 12px 16px; border-radius: 6px; font-weight: 700;
font-size: 15px; }
summary:hover { filter: brightness(1.1); }
.finding-group-header.critical { background: #450a0a; color: #fca5a5; }
.finding-group-header.high { background: #431407; color: #fdba74; }
.finding-group-header.medium { background: #422006; color: #fde68a; }
.finding-group-header.low { background: #172554; color: #93c5fd; }
.finding-group-header.info { background: #1f2937; color: #d1d5db; }
.finding-card { background: var(--surface2); border-radius: 6px; padding: 16px;
margin: 8px 0; }
.finding-title { font-size: 15px; font-weight: 600; margin-bottom: 8px; }
.finding-id { color: var(--accent); font-family: monospace; font-size: 13px; }
.finding-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.meta-tag { font-size: 11px; padding: 2px 8px; border-radius: 4px;
background: var(--surface); border: 1px solid var(--border); }
.severity-critical { border-color: var(--critical); color: #fca5a5; }
.severity-high { border-color: var(--high); color: #fdba74; }
.severity-medium { border-color: var(--medium); color: #fde68a; }
.severity-low { border-color: var(--low); color: #93c5fd; }
.finding-desc { font-size: 14px; color: var(--text2); margin-bottom: 8px; }
.code-snippet { background: #0f172a; border: 1px solid var(--border); border-radius: 4px;
padding: 12px; font-size: 13px; overflow-x: auto; margin-bottom: 8px; }
.code-snippet code { font-family: 'JetBrains Mono', 'Fira Code', monospace; }
.finding-rec { font-size: 14px; padding: 8px 12px; background: #064e3b;
border-radius: 4px; margin-bottom: 8px; }
.finding-exploit { font-size: 14px; padding: 8px 12px; background: #1c1917;
border-radius: 4px; margin-bottom: 8px; }
.finding-exploit ol { padding-left: 20px; margin-top: 4px; }
.finding-refs { font-size: 14px; padding: 8px 12px; background: #172554;
border-radius: 4px; }
.finding-refs ul { padding-left: 20px; margin-top: 4px; }
footer { text-align: center; padding: 24px; color: var(--text2); font-size: 13px; }
footer a { color: var(--accent); text-decoration: none; }
footer a:hover { text-decoration: underline; }
@media (max-width: 640px) {
body { padding: 12px; }
.score-grid { grid-template-columns: 1fr; }
.score-row { flex-wrap: wrap; }
.score-row-label { width: 100%; }
.score-row-value { width: auto; }
.meta-bar { flex-direction: column; align-items: stretch; }
.meta-item { flex-direction: row; justify-content: space-between; }
}
"##;
fn escape_html(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn score_color(score: u8) -> &'static str {
if score >= 85 {
"#22c55e"
} else if score >= 70 {
"#eab308"
} else if score >= 50 {
"#f97316"
} else {
"#ef4444"
}
}
fn risk_color(risk: RiskLevel) -> &'static str {
match risk {
RiskLevel::Critical => "#ef4444",
RiskLevel::High => "#f97316",
RiskLevel::Medium => "#eab308",
RiskLevel::Low => "#22c55e",
RiskLevel::Minimal => "#22c55e",
}
}
pub fn write_report(result: &AuditResult, path: &std::path::Path) -> Result<(), ForgeGuardError> {
let html = generate_report(result)?;
std::fs::write(path, html)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::*;
fn sample_result() -> AuditResult {
AuditResult {
project_name: "test-project".into(),
chain: "ethereum".into(),
timestamp: "2026-07-28T12:00:00Z".into(),
duration_seconds: 2.5,
findings: vec![
Finding::builder()
.id("FA-H-001")
.title("Reentrancy Vulnerability")
.description("CEI violation in withdraw function")
.severity(Severity::High)
.file("Vault.sol")
.location(42, 8)
.code("msg.sender.call{value: amount}(\"\");\nbalances[msg.sender] -= amount;")
.recommendation(
"Use ReentrancyGuard or apply checks-effects-interactions pattern",
)
.category("Logic")
.blocks_deployment(true)
.build(),
Finding::builder()
.id("FA-M-002")
.title("Unbounded Loop")
.description("Loop over dynamic array may cause out-of-gas")
.severity(Severity::Medium)
.file("Vault.sol")
.location(88, 12)
.code("for (uint i; i < holders.length; i++) { ... }")
.recommendation("Track array length or use pagination")
.category("Gas")
.blocks_deployment(false)
.reference("SWC-128")
.build(),
Finding::builder()
.id("FA-L-003")
.title("Unused Variable")
.description("Variable 'oldBalance' is declared but never used")
.severity(Severity::Low)
.file("Vault.sol")
.location(15, 20)
.code("uint256 oldBalance = balances[msg.sender];")
.recommendation("Remove unused variable")
.category("Best Practices")
.blocks_deployment(false)
.build(),
],
scores: SecurityScores {
access_control: 100,
security: 70,
fuzzing: 100,
gas: 85,
architecture: 65,
upgradeability: 100,
dependencies: 100,
deployment: 100,
proxy_safety: 100,
chain_compatibility: 100,
production_readiness: 55,
exploit_resistance: 100,
},
overall_score: 76,
risk_level: RiskLevel::High,
production_ready: false,
deployment_approved: false,
summary: AuditSummary {
total_findings: 3,
critical_count: 0,
high_count: 1,
medium_count: 1,
low_count: 1,
info_count: 0,
files_analyzed: 5,
lines_analyzed: 500,
contracts_analyzed: 3,
},
}
}
#[test]
fn test_html_report_contains_basic_elements() {
let result = sample_result();
let html = generate_report(&result).unwrap();
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("Forge Guard"));
assert!(html.contains("Security Audit Report"));
assert!(html.contains("test-project"));
assert!(html.contains("ethereum"));
assert!(html.contains("FAIL"));
assert!(html.contains("76/100"));
assert!(html.contains("HIGH"));
}
#[test]
fn test_html_report_contains_findings() {
let result = sample_result();
let html = generate_report(&result).unwrap();
assert!(html.contains("Reentrancy Vulnerability"));
assert!(html.contains("FA-H-001"));
assert!(html.contains("Unbounded Loop"));
assert!(html.contains("FA-M-002"));
assert!(html.contains("Unused Variable"));
assert!(html.contains("FA-L-003"));
}
#[test]
fn test_html_report_empty_findings() {
let result = AuditResult {
project_name: "clean".into(),
chain: "base".into(),
timestamp: "2026-07-28T00:00:00Z".into(),
duration_seconds: 1.0,
findings: vec![],
scores: SecurityScores::perfect(),
overall_score: 100,
risk_level: RiskLevel::Minimal,
production_ready: true,
deployment_approved: true,
summary: AuditSummary {
total_findings: 0,
critical_count: 0,
high_count: 0,
medium_count: 0,
low_count: 0,
info_count: 0,
files_analyzed: 3,
lines_analyzed: 200,
contracts_analyzed: 2,
},
};
let html = generate_report(&result).unwrap();
assert!(html.contains("PASS"));
assert!(html.contains("APPROVED"));
assert!(html.contains("100/100"));
assert!(html.contains("MINIMAL"));
}
#[test]
fn test_html_report_all_severities() {
let result = AuditResult {
project_name: "all-sev".into(),
chain: "polygon".into(),
timestamp: "2026-01-01T00:00:00Z".into(),
duration_seconds: 0.5,
findings: vec![
Finding::builder()
.id("C1")
.title("Critical Bug")
.description("")
.severity(Severity::Critical)
.file("c.sol")
.code("x")
.recommendation("Fix")
.category("Security")
.build(),
Finding::builder()
.id("H1")
.title("High Bug")
.description("")
.severity(Severity::High)
.file("h.sol")
.code("x")
.recommendation("Fix")
.category("Security")
.build(),
Finding::builder()
.id("M1")
.title("Medium Bug")
.description("")
.severity(Severity::Medium)
.file("m.sol")
.code("x")
.recommendation("Fix")
.category("Gas")
.build(),
Finding::builder()
.id("L1")
.title("Low Bug")
.description("")
.severity(Severity::Low)
.file("l.sol")
.code("x")
.recommendation("Fix")
.category("Style")
.build(),
Finding::builder()
.id("I1")
.title("Info Note")
.description("")
.severity(Severity::Informational)
.file("i.sol")
.code("x")
.recommendation("Note")
.category("Style")
.build(),
],
scores: SecurityScores::perfect(),
overall_score: 50,
risk_level: RiskLevel::High,
production_ready: false,
deployment_approved: false,
summary: AuditSummary {
total_findings: 5,
critical_count: 1,
high_count: 1,
medium_count: 1,
low_count: 1,
info_count: 1,
files_analyzed: 1,
lines_analyzed: 50,
contracts_analyzed: 1,
},
};
let html = generate_report(&result).unwrap();
assert!(html.contains("CRITICAL"));
assert!(html.contains("HIGH"));
assert!(html.contains("MEDIUM"));
assert!(html.contains("LOW"));
assert!(html.contains("INFO"));
assert!(html.contains("Critical Bug"));
assert!(html.contains("High Bug"));
assert!(html.contains("Medium Bug"));
assert!(html.contains("Low Bug"));
assert!(html.contains("Info Note"));
}
#[test]
fn test_html_report_contains_exploit_path() {
let mut result = sample_result();
result.findings[0].exploit_path = Some(vec![
"Attacker calls withdraw()".into(),
"Fallback receives ETH before balance update".into(),
"Re-enters withdraw() recursively".into(),
]);
let html = generate_report(&result).unwrap();
assert!(html.contains("Exploit Path"));
assert!(html.contains("Attacker calls withdraw()"));
}
#[test]
fn test_html_report_contains_references() {
let html = generate_report(&sample_result()).unwrap();
assert!(html.contains("SWC-128"));
}
#[test]
fn test_html_escape_prevents_injection() {
let result = AuditResult {
project_name: "<script>alert('xss')</script>".into(),
chain: "test".into(),
timestamp: "2026-01-01T00:00:00Z".into(),
duration_seconds: 0.1,
findings: vec![Finding::builder()
.id("X1")
.title("<img src=x onerror=alert(1)>")
.description("<b>bold</b>")
.severity(Severity::Low)
.file("<script>evil</script>")
.code("<script>alert(1)</script>")
.recommendation("Sanitize")
.category("Security")
.build()],
scores: SecurityScores::perfect(),
overall_score: 100,
risk_level: RiskLevel::Minimal,
production_ready: true,
deployment_approved: true,
summary: AuditSummary {
total_findings: 1,
critical_count: 0,
high_count: 0,
medium_count: 0,
low_count: 1,
info_count: 0,
files_analyzed: 1,
lines_analyzed: 10,
contracts_analyzed: 1,
},
};
let html = generate_report(&result).unwrap();
assert!(html.contains("<script>"));
assert!(html.contains("<img"));
assert!(html.contains("<b>"));
assert!(!html.contains("<script>alert"));
assert!(!html.contains("<img src=x"));
}
#[test]
fn test_html_report_score_colors() {
assert_eq!(score_color(100), "#22c55e");
assert_eq!(score_color(85), "#22c55e");
assert_eq!(score_color(75), "#eab308");
assert_eq!(score_color(70), "#eab308");
assert_eq!(score_color(60), "#f97316");
assert_eq!(score_color(50), "#f97316");
assert_eq!(score_color(30), "#ef4444");
assert_eq!(score_color(0), "#ef4444");
}
#[test]
fn test_html_report_risk_colors() {
assert_eq!(risk_color(RiskLevel::Critical), "#ef4444");
assert_eq!(risk_color(RiskLevel::High), "#f97316");
assert_eq!(risk_color(RiskLevel::Medium), "#eab308");
assert_eq!(risk_color(RiskLevel::Low), "#22c55e");
assert_eq!(risk_color(RiskLevel::Minimal), "#22c55e");
}
#[test]
fn test_html_write_report() {
let result = sample_result();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("report.html");
write_report(&result, &path).unwrap();
assert!(path.exists());
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("Forge Guard"));
}
#[test]
fn test_html_report_responsive_viewport() {
let html = generate_report(&sample_result()).unwrap();
assert!(html.contains("viewport"));
assert!(html.contains("width=device-width"));
}
#[test]
fn test_html_report_dark_theme_css_vars() {
let html = generate_report(&sample_result()).unwrap();
assert!(html.contains("--bg: #0f172a"));
}
}