use crate::profile::{CategoricalStats, ColumnProfile, DatasetProfile, Histogram, NumericStats};
use crate::quality::checks::run_checks;
use crate::quality::{QualityIssue, Thresholds};
use crate::types::{ColumnType, Severity};
fn esc(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
out
}
fn fmt_num(v: f64) -> String {
if !v.is_finite() {
"—".to_string()
} else if v.abs() >= 1e6 || (v.abs() < 1e-4 && v != 0.0) {
format!("{:.3e}", v)
} else {
format!("{:.4}", v)
}
}
const CSS: &str = r#"
:root { color-scheme: light dark; }
body { font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0 auto; max-width: 72rem; padding: 2rem; color: #1b1b1b;
background: #fafaf7; }
h1 { font-size: 1.5rem; margin: 0 0 .25rem; }
h2 { font-size: 1.1rem; margin: 2rem 0 .5rem; border-bottom: 1px solid #ddd; padding-bottom: .25rem; }
.summary { display: flex; gap: 1.5rem; flex-wrap: wrap; margin: 1rem 0; }
.summary div { background: #fff; border: 1px solid #e3e3e3; border-radius: 6px;
padding: .75rem 1rem; min-width: 9rem; }
.summary dt { font-size: .75rem; text-transform: uppercase; letter-spacing: .03em;
color: #777; margin: 0; }
.summary dd { margin: .25rem 0 0; font-size: 1.15rem; font-weight: 600; }
.findings { list-style: none; padding: 0; }
.findings li { padding: .4rem .6rem; margin: .25rem 0; border-radius: 4px; border: 1px solid #eee; }
.findings .critical { border-left: 4px solid #c62828; background: #fdecea; }
.findings .warning { border-left: 4px solid #ef6c00; background: #fff4e5; }
.findings .info { border-left: 4px solid #1976d2; background: #e8f0fe; }
/* Per-column card grid */
.col-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
gap: 1rem; margin-top: .5rem; }
.col-card { background: #fff; border: 1px solid #e3e3e3; border-radius: 6px;
padding: .75rem 1rem; display: flex; flex-direction: column; gap: .5rem; }
.col-card > .head { display: flex; align-items: center; gap: .5rem;
justify-content: space-between; }
.col-card h3 { margin: 0; font-size: 1rem; font-weight: 600; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; }
.col-card .missing { font-size: .75rem; color: #777; white-space: nowrap; }
.col-card dl.stats { display: grid; grid-template-columns: auto 1fr auto 1fr;
gap: .15rem .6rem; margin: 0; font-size: .82rem; }
.col-card dl.stats dt { color: #777; }
.col-card dl.stats dd { margin: 0; font-variant-numeric: tabular-nums;
text-align: right; }
.col-card .full { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: .15rem 1rem;
font-size: .82rem; }
.col-card .full span b { color: #555; font-weight: 500; }
/* Histogram / frequency bar charts (pure CSS, no JS) */
.chart { display: flex; align-items: flex-end; gap: 2px; height: 3.5rem;
padding-top: .25rem; }
.chart .bar { flex: 1 1 0; min-width: 4px; min-height: 2px; border-radius: 2px 2px 0 0;
background: #1a56c4; }
.chart.cat .bar { background: #8a2be2; }
.chart .bar.zero { background: #eee; min-height: 1px; }
.chart-labels { display: flex; justify-content: space-between; font-size: .7rem;
color: #999; margin-top: .1rem; }
.cat-list { list-style: none; padding: 0; margin: 0; font-size: .8rem; }
.cat-list li { display: flex; align-items: center; gap: .4rem; margin: .15rem 0; }
.cat-list .swatch { width: .55rem; height: .55rem; border-radius: 2px; background: #8a2be2;
flex: 0 0 auto; }
.cat-list .label { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; }
.cat-list .count { font-variant-numeric: tabular-nums; color: #555; }
.badge { display: inline-block; padding: .05rem .4rem; border-radius: 3px;
font-size: .7rem; text-transform: uppercase; letter-spacing: .03em; }
.badge.numeric { background: #e8f0fe; color: #1a56c4; }
.badge.categorical { background: #f6e8fe; color: #8a2be2; }
.note { font-size: .75rem; color: #999; }
@media (prefers-color-scheme: dark) {
body { background: #161616; color: #e6e6e6; }
.summary div, .col-card { background: #1e1e1e; border-color: #333; }
.col-card dl.stats dt, .col-card .missing, .cat-list .count, .chart-labels { color: #999; }
.col-card .full span b, .cat-list .count { color: #aaa; }
.chart .bar.zero { background: #333; }
}
"#;
pub fn to_html(profile: &DatasetProfile) -> String {
let findings = run_checks(profile, &Thresholds::default());
to_html_with(profile, &findings)
}
pub fn to_html_with(profile: &DatasetProfile, findings: &[QualityIssue]) -> String {
let mut html = String::with_capacity(24 * 1024);
html.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n");
html.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
html.push_str("<title>datarust-profile report</title>\n<style>");
html.push_str(CSS);
html.push_str("</style>\n</head>\n<body>\n");
html.push_str("<h1>Dataset profile</h1>\n");
html.push_str("<dl class=\"summary\">\n");
summary_card(&mut html, "Rows", profile.n_rows.to_string());
summary_card(&mut html, "Columns", profile.n_columns.to_string());
summary_card(&mut html, "Memory", format_bytes(profile.memory_bytes));
summary_card(
&mut html,
"Duplicate rows",
format!(
"{} ({:.1}%)",
profile.duplicate_rows,
profile.duplicate_fraction * 100.0
),
);
summary_card(&mut html, "Findings", findings.len().to_string());
html.push_str("</dl>\n");
if findings.is_empty() {
html.push_str("<p>No data-quality findings.</p>\n");
} else {
html.push_str("<h2>Data quality findings</h2>\n<ul class=\"findings\">\n");
for issue in findings {
let class = match issue.severity {
Severity::Critical => "critical",
Severity::Warning => "warning",
Severity::Info => "info",
};
html.push_str(&format!(
"<li class=\"{class}\"><strong>{sev}</strong> — {msg}</li>\n",
class = class,
sev = esc(&issue.severity.to_string()),
msg = esc(&issue.message)
));
}
html.push_str("</ul>\n");
}
html.push_str("<h2>Per-column profile</h2>\n<div class=\"col-grid\">\n");
for col in &profile.columns {
render_col_card(&mut html, col);
}
html.push_str("</div>\n");
html.push_str("</body>\n</html>\n");
html
}
fn summary_card(html: &mut String, label: &str, value: String) {
html.push_str(&format!(
"<div><dt>{label}</dt><dd>{value}</dd></div>\n",
label = esc(label),
value = esc(&value)
));
}
fn render_col_card(html: &mut String, col: &ColumnProfile) {
html.push_str("<div class=\"col-card\">");
let (badge, class) = match col.column_type {
ColumnType::Numeric => ("numeric", "numeric"),
ColumnType::Categorical => ("categorical", "categorical"),
};
let missing = format!(
"{} missing ({:.1}%)",
col.missing_count,
col.missing_fraction * 100.0
);
html.push_str(&format!(
"<div class=\"head\"><h3>{name}</h3><span class=\"badge {class}\">{badge}</span></div>",
name = esc(&col.name),
class = class,
badge = badge
));
html.push_str(&format!("<div class=\"missing\">{}</div>", esc(&missing)));
match (&col.numeric, &col.categorical) {
(Some(n), None) => render_numeric_card(html, n),
(None, Some(c)) => render_categorical_card(html, c),
_ => html.push_str("<p class=\"note\">No non-missing values.</p>"),
}
html.push_str("</div>\n");
}
fn render_numeric_card(html: &mut String, n: &NumericStats) {
html.push_str("<dl class=\"stats\">\n");
stat_pair(html, "mean", &fmt_num(n.mean));
stat_pair(html, "std", &fmt_num(n.std));
stat_pair(html, "skew", &fmt_num(n.skewness));
stat_pair(html, "kurt", &fmt_num(n.kurtosis));
html.push_str("</dl>\n");
html.push_str("<div class=\"full\">");
html.push_str(&format!(
"<span><b>min</b> {}</span><span><b>Q1</b> {}</span><span><b>med</b> {}</span><span><b>Q3</b> {}</span><span><b>max</b> {}</span>",
esc(&fmt_num(n.five.min)),
esc(&fmt_num(n.five.q1)),
esc(&fmt_num(n.five.median)),
esc(&fmt_num(n.five.q3)),
esc(&fmt_num(n.five.max)),
));
html.push_str("</div>\n");
render_histogram(html, &n.histogram);
html.push_str(&format!(
"<div class=\"note\">outliers: {} ({:.1}%) beyond IQR fences</div>",
n.outlier_count,
n.outlier_fraction * 100.0
));
}
fn render_categorical_card(html: &mut String, c: &CategoricalStats) {
html.push_str("<dl class=\"stats\">\n");
stat_pair(html, "unique", &c.unique.to_string());
stat_pair(html, "imbalance", &format!("{:.3}", c.imbalance_ratio));
html.push_str("</dl>\n");
html.push_str(&format!(
"<div class=\"full\"><span><b>top</b> {} ({}, {:.1}%)</span></div>",
esc(&c.top),
c.freq,
c.imbalance_ratio * 100.0
));
if c.top_values.is_empty() {
html.push_str("<p class=\"note\">No non-missing values.</p>");
} else {
render_cat_bars(html, &c.top_values);
}
}
fn stat_pair(html: &mut String, label: &str, value: &str) {
html.push_str(&format!(
"<dt>{label}</dt><dd>{value}</dd>\n",
label = esc(label),
value = esc(value)
));
}
fn render_histogram(html: &mut String, hist: &Histogram) {
if hist.counts.is_empty() {
html.push_str("<p class=\"note\">histogram: empty</p>");
return;
}
let max = hist.max_count().max(1) as f64;
html.push_str("<div class=\"chart\">\n");
for &c in &hist.counts {
let pct = (c as f64 / max) * 100.0;
let class = if c == 0 { "bar zero" } else { "bar" };
html.push_str(&format!(
"<div class=\"{class}\" style=\"height:{pct:.1}%\" title=\"{c}\"></div>\n",
class = class,
pct = pct.max(2.0),
c = c,
));
}
html.push_str("</div>\n");
if hist.edges.len() >= 2 {
let lo = hist.edges.first().copied().unwrap_or(f64::NAN);
let hi = hist.edges.last().copied().unwrap_or(f64::NAN);
html.push_str(&format!(
"<div class=\"chart-labels\"><span>{}</span><span>{}</span></div>\n",
esc(&fmt_num(lo)),
esc(&fmt_num(hi))
));
}
}
fn render_cat_bars(html: &mut String, top_values: &[(String, usize)]) {
let max = top_values.iter().map(|(_, c)| *c).max().unwrap_or(0).max(1) as f64;
html.push_str("<div class=\"chart cat\">\n");
for (_, c) in top_values {
let pct = (*c as f64 / max) * 100.0;
let class = if *c == 0 { "bar zero" } else { "bar" };
html.push_str(&format!(
"<div class=\"{class}\" style=\"height:{pct:.1}%\" title=\"{c}\"></div>\n",
class = class,
pct = pct.max(2.0),
c = c,
));
}
html.push_str("</div>\n");
html.push_str("<ul class=\"cat-list\">\n");
for (label, c) in top_values {
html.push_str(&format!(
"<li><span class=\"swatch\"></span><span class=\"label\">{label}</span><span class=\"count\">{c}</span></li>\n",
label = esc(label),
c = c,
));
}
html.push_str("</ul>\n");
}
fn format_bytes(bytes: usize) -> String {
const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB", "TiB"];
let mut value = bytes as f64;
let mut unit = 0;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{} {}", bytes, UNITS[unit])
} else {
format!("{:.1} {}", value, UNITS[unit])
}
}