use std::{
fmt::Write as _,
fs, io,
path::{Path, PathBuf},
};
use chartlet::{RenderFormat, RenderOptions, render_json};
pub struct Entry<'a> {
pub name: &'a str,
pub variant: &'static str,
pub specification: &'a str,
pub reference: &'a str,
pub options: RenderOptions,
}
enum Status {
Changed { offset: usize },
Unchanged,
Failed(String),
Missing,
}
impl Status {
fn class(&self) -> &'static str {
match self {
Self::Changed { .. } => "changed",
Self::Unchanged => "unchanged",
Self::Failed(_) => "failed",
Self::Missing => "missing",
}
}
}
#[derive(Default)]
pub struct Summary {
pub changed: usize,
pub failed: usize,
pub unchanged: usize,
pub missing: usize,
}
pub fn write(
directory: &Path,
entries: &[Entry],
missing: &[(String, String)],
) -> io::Result<Summary> {
if directory.exists() {
fs::remove_dir_all(directory)?;
}
fs::create_dir_all(directory.join("svg"))?;
let rows = entries
.iter()
.map(|entry| {
let Entry {
name,
variant,
specification,
reference,
..
} = *entry;
(
name,
variant,
specification,
Some(reference),
entry.options.clone(),
)
})
.chain(missing.iter().map(|(name, specification)| {
let options = RenderOptions::default();
(
name.as_str(),
"desktop",
specification.as_str(),
None,
options,
)
}));
let mut summary = Summary::default();
let mut cards = String::new();
for (index, (name, variant, specification, reference, options)) in rows.enumerate() {
let current = render_json(specification, RenderFormat::Svg, &options)
.map(|output| output.content)
.map_err(|error| error.to_string());
let status = match (¤t, reference) {
(Err(error), _) => Status::Failed(error.clone()),
(Ok(_), None) => Status::Missing,
(Ok(current), Some(reference)) if current == reference => Status::Unchanged,
(Ok(current), Some(reference)) => Status::Changed {
offset: first_difference(reference, current),
},
};
let file = file_name(name, variant);
if let Some(reference) = reference {
fs::write(
directory.join(format!("svg/{file}.reference.svg")),
reference,
)?;
}
if let Ok(current) = ¤t {
fs::write(directory.join(format!("svg/{file}.current.svg")), current)?;
}
match status {
Status::Changed { .. } => summary.changed += 1,
Status::Unchanged => summary.unchanged += 1,
Status::Failed(_) => summary.failed += 1,
Status::Missing => summary.missing += 1,
}
let versions = (reference, current.as_deref().ok());
card(&mut cards, index, name, variant, &status, versions);
}
fs::write(directory.join("index.html"), page(&summary, &cards))?;
Ok(summary)
}
fn file_name(name: &str, variant: &str) -> String {
if variant == "desktop" {
name.to_owned()
} else {
format!("{name}.{variant}")
}
}
fn first_difference(reference: &str, current: &str) -> usize {
reference
.bytes()
.zip(current.bytes())
.position(|(a, b)| a != b)
.unwrap_or_else(|| reference.len().min(current.len()))
}
fn excerpt(text: &str, offset: usize) -> String {
let mut start = offset.saturating_sub(30).min(text.len());
while !text.is_char_boundary(start) {
start -= 1;
}
text[start..].chars().take(80).collect()
}
fn approve_command(name: &str, variant: &str) -> String {
let file = file_name(name, variant);
if variant == "desktop" {
format!("cargo run -- render examples/{name}.json -o examples/{file}.svg")
} else {
format!(
"cargo run -- render examples/{name}.json --variant {variant} -o examples/{file}.svg"
)
}
}
fn card(
cards: &mut String,
index: usize,
name: &str,
variant: &str,
status: &Status,
(reference, current): (Option<&str>, Option<&str>),
) {
let file = file_name(name, variant);
let class = status.class();
let _ = write!(
cards,
"<section class=\"card {class}\"><h2>{} <small>{variant}</small> <span class=\"badge\">{class}</span></h2>",
escape(name)
);
match status {
Status::Changed { offset } => {
let _ = write!(
cards,
"<p>First difference at byte {offset}:</p><pre>reference: {}\ncurrent: {}</pre>",
escape(&excerpt(reference.unwrap_or_default(), *offset)),
escape(&excerpt(current.unwrap_or_default(), *offset)),
);
}
Status::Failed(error) => {
let _ = write!(cards, "<pre>{}</pre>", escape(error));
}
Status::Missing => {
cards.push_str("<p>No reviewed reference in <code>tests/golden.rs</code>.</p>");
}
Status::Unchanged => {}
}
if matches!(status, Status::Changed { .. } | Status::Missing) {
let _ = write!(
cards,
"<p>After review, accept it with <code>{}</code>.</p>",
escape(&approve_command(name, variant))
);
}
if matches!(status, Status::Changed { .. }) {
let _ = write!(
cards,
"<fieldset class=\"views\"><legend>View</legend>\
<label><input type=\"radio\" name=\"view-{index}\" value=\"side\" checked> side by side</label>\
<label><input type=\"radio\" name=\"view-{index}\" value=\"overlay\"> overlay</label>\
<label><input type=\"radio\" name=\"view-{index}\" value=\"difference\"> difference</label>\
</fieldset>"
);
}
cards.push_str("<div class=\"stage\">");
if reference.is_some() {
let _ = write!(
cards,
"<figure class=\"reference\"><img src=\"svg/{file}.reference.svg\" alt=\"\"><figcaption>reference</figcaption></figure>"
);
}
if current.is_some() {
let _ = write!(
cards,
"<figure class=\"current\"><img src=\"svg/{file}.current.svg\" alt=\"\"><figcaption>current</figcaption></figure>"
);
}
cards.push_str("</div></section>\n");
}
fn page(summary: &Summary, cards: &str) -> String {
format!(
"<!doctype html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\">
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
<title>chartlet golden report</title>
<style>
:root {{ color-scheme: light; font: 15px/1.45 system-ui, sans-serif; }}
body {{ margin: 0 auto; max-width: 1400px; padding: 16px; background: #f4f4f5; color: #18181b; }}
header {{ position: sticky; top: 0; background: #f4f4f5; padding: 8px 0; border-bottom: 1px solid #d4d4d8; z-index: 1; }}
header label {{ margin-right: 1em; }}
.card {{ background: #fff; border: 1px solid #d4d4d8; border-radius: 6px; padding: 12px 16px; margin: 16px 0; }}
.card h2 {{ font-size: 1.05rem; margin: 0 0 8px; }}
.card h2 small {{ color: #52525b; font-weight: normal; }}
.badge {{ font-size: .75rem; font-weight: 600; padding: 2px 6px; border-radius: 4px; background: #e4e4e7; }}
.changed .badge {{ background: #fde68a; }}
.failed .badge {{ background: #fecaca; }}
.missing .badge {{ background: #bfdbfe; }}
pre {{ background: #f4f4f5; padding: 8px; overflow-x: auto; font-size: .8rem; }}
.views {{ border: 0; padding: 0; margin: 8px 0; }}
.views legend {{ float: left; margin-right: .75em; font-weight: 600; }}
.stage {{ display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-start; }}
.stage figure {{ margin: 0; flex: 1 1 320px; min-width: 0; }}
.stage img {{ display: block; max-width: 100%; height: auto; background: #fff; outline: 1px solid #e4e4e7; }}
figcaption {{ font-size: .8rem; color: #52525b; }}
.card:has([value=overlay]:checked) .stage, .card:has([value=difference]:checked) .stage {{ display: grid; }}
.card:has([value=overlay]:checked) .stage figure, .card:has([value=difference]:checked) .stage figure {{ grid-area: 1 / 1; }}
.card:has([value=overlay]:checked) .current {{ opacity: .5; }}
.card:has([value=difference]:checked) .current img {{ mix-blend-mode: difference; }}
.card:has([value=overlay]:checked) figcaption, .card:has([value=difference]:checked) figcaption {{ display: none; }}
body:has(#show-unchanged:not(:checked)) .card.unchanged {{ display: none; }}
body:has(#show-missing:not(:checked)) .card.missing {{ display: none; }}
</style>
</head>
<body>
<header>
<h1>chartlet golden report</h1>
<p>{} changed, {} failed, {} unchanged, {} without reference.
<label><input type=\"checkbox\" id=\"show-unchanged\"> show unchanged</label>
<label><input type=\"checkbox\" id=\"show-missing\"> show without reference</label></p>
</header>
<main>
{cards}</main>
</body>
</html>
",
summary.changed, summary.failed, summary.unchanged, summary.missing
)
}
fn escape(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
pub fn directory() -> PathBuf {
Path::new(env!("CARGO_TARGET_TMPDIR")).join("golden-report")
}