pub fn html_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(ch),
}
}
out
}
pub fn report_css() -> &'static str {
r#" :root { --bg: #1a1a2e; --card: #161b22; --accent: #0f3460; --text: #e0e0e0;
--link: #58a6ff; --border: #30363d; --green: #3fb950; --red: #f85149;
--yellow: #d29922; --surface: #161b22; }
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace;
background: var(--bg); color: var(--text); padding: 2rem; line-height: 1.5; }
h1 { font-size: 1.5rem; margin-bottom: .25rem; }
h2 { font-size: 1.15rem; margin: 1.5rem 0 .8rem; color: var(--link); }
.timestamp { color: #888; margin-bottom: 1.5rem; font-size: .9rem; }
table { width: 100%; border-collapse: collapse; margin-bottom: 1.2rem; }
th, td { padding: .55rem .75rem; text-align: left; border: 1px solid var(--border); font-size: .85rem; }
th { background: var(--surface); font-weight: 600; color: #8b949e; }
tr:hover td { background: #1c2333; }
p { margin-bottom: .75rem; }
.pass { color: var(--green); font-weight: 600; }
.fail { color: var(--red); font-weight: 600; }
.warn { color: var(--yellow); font-weight: 600; }
.stat { background: var(--card); border: 1px solid var(--border); border-radius: 8px;
padding: 1rem; text-align: center; }
.stat .value { font-size: 1.5rem; font-weight: bold; }
.stat .label { font-size: .75rem; color: #8b949e; margin-top: .25rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 1.25rem; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; }
.card h2 { font-size: 1.1rem; margin-bottom: .75rem; text-transform: capitalize;
border-bottom: 1px solid var(--border); padding-bottom: .5rem; }
.card ul { list-style: none; }
.card li { padding: .25rem 0; }
.card a { color: var(--link); text-decoration: none; }
.card a:hover { text-decoration: underline; }
.badge { display: inline-block; font-size: .7rem; padding: .1rem .4rem; border-radius: 4px;
margin-left: .4rem; vertical-align: middle; }
.html { background: #1f6feb33; color: #58a6ff; }
.json { background: #23863633; color: #3fb950; }
.txt { background: #d2992233; color: #d29922; }
details { margin: .4rem 0; }
summary { cursor: pointer; font-size: .85rem; color: #8b949e; }
summary:hover { color: var(--text); }
pre { background: var(--surface); border: 1px solid var(--border); border-radius: 4px;
padding: .6rem .8rem; font-size: .8rem; overflow-x: auto; white-space: pre-wrap; }
footer { margin-top: 2rem; color: #555; font-size: .8rem; text-align: center; }"#
}
pub fn html_head(title: &str) -> String {
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{}</title>
<style>
{}
</style>
</head>
<body>
"#,
html_escape(title),
report_css()
)
}
pub fn html_footer() -> String {
"<footer>Generated by conproxy test suite</footer>\n</body>\n</html>\n".to_string()
}
pub fn timestamp_now() -> String {
let d = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
format!("{}s-since-epoch", d.as_secs())
}
pub fn render_sparkline_svg(
points: &[(f64, f64)],
label: &str,
color: &str,
w: u32,
h: u32,
) -> String {
if points.is_empty() {
return String::new();
}
let x_min = points.iter().map(|p| p.0).fold(f64::INFINITY, f64::min);
let x_max = points.iter().map(|p| p.0).fold(f64::NEG_INFINITY, f64::max);
let y_min = points.iter().map(|p| p.1).fold(f64::INFINITY, f64::min);
let y_max = points.iter().map(|p| p.1).fold(f64::NEG_INFINITY, f64::max);
let x_range = if (x_max - x_min).abs() < f64::EPSILON {
1.0
} else {
x_max - x_min
};
let y_range = if (y_max - y_min).abs() < f64::EPSILON {
1.0
} else {
y_max - y_min
};
let margin = 4.0;
let plot_w = w as f64 - 2.0 * margin;
let plot_h = h as f64 - 2.0 * margin - 14.0;
let mut path = String::new();
for (i, (x, y)) in points.iter().enumerate() {
let px = margin + (x - x_min) / x_range * plot_w;
let py = margin + plot_h - (y - y_min) / y_range * plot_h;
if i == 0 {
path.push_str(&format!("M{px:.1},{py:.1}"));
} else {
path.push_str(&format!(" L{px:.1},{py:.1}"));
}
}
format!(
r##"<svg width="{w}" height="{h}" viewBox="0 0 {w} {h}" xmlns="http://www.w3.org/2000/svg">
<path d="{path}" fill="none" stroke="{color}" stroke-width="1.5" stroke-linejoin="round"/>
<text x="{}" y="{}" font-size="10" fill="#8b949e" font-family="monospace">{}</text>
</svg>"##,
margin,
h as f64 - 2.0,
html_escape(label),
)
}