Skip to main content

warble_claude_code/
render.rs

1//! Warble reference renderer (v0.3, programmatic flavor) — Rust port of `render.ts`.
2//!
3//! This is the deterministic half of the render contract: the agent stays fully
4//! read-only and emits a structured `{ blocks, summary }` envelope as its final
5//! output; THIS module turns that envelope into a self-contained `dashboard.html`
6//! — no LLM in the loop, same input always yields the same bytes.
7
8use crate::error::DispatchError;
9use serde_json::Value;
10
11/// Document rendering options.
12#[derive(Debug, Clone, Default)]
13pub struct RenderOptions {
14    /// Document title (default: "Dashboard"). Kept out of the envelope so the
15    /// same envelope renders identically regardless of who invokes it.
16    pub title: Option<String>,
17}
18
19/// A parsed Warble render envelope (blocks + optional summary + optional verify facet).
20#[derive(Debug, Clone)]
21pub struct Envelope {
22    pub blocks: Vec<Value>,
23    pub summary: Option<String>,
24    /// The per-answer verify cue (G2, hard line): `Some(true)` ⇒ the agent executed the query and
25    /// validated the result set before answering, and the renderer shows a `✓ Verified` pill. Any
26    /// other value (absent/false) shows no pill — verification is asserted, never assumed.
27    pub verified: Option<bool>,
28}
29
30// ---------------------------------------------------------------------------
31// Envelope extraction — tolerate the model wrapping the JSON
32// ---------------------------------------------------------------------------
33
34/// Extract a Warble render envelope from raw agent output. The agent is asked
35/// to emit *only* the JSON envelope, but models routinely wrap it in ```json
36/// fences or surround it with a sentence. This is deliberately forgiving:
37///
38///  1. try to parse the whole string as JSON;
39///  2. else pull the contents of the first ```json / ``` fenced block;
40///  3. else scan for the first balanced `{...}` object that contains a
41///     top-level `"blocks"` key.
42///
43/// Returns an error if none of those yield an object with a `blocks` array.
44pub fn parse_envelope(raw: &str) -> Result<Envelope, DispatchError> {
45    let fenced = extract_fenced_block(raw);
46    let balanced = extract_balanced_object(raw);
47    let candidates: [Option<&str>; 3] = [Some(raw.trim()), fenced.as_deref(), balanced.as_deref()];
48
49    for candidate in candidates.into_iter().flatten() {
50        if let Some(parsed) = try_parse_object(candidate) {
51            if let Some(obj) = parsed.as_object() {
52                if let Some(Value::Array(_)) = obj.get("blocks") {
53                    return Ok(normalize_envelope(obj));
54                }
55            }
56        }
57    }
58
59    Err(DispatchError::new(
60        "could not find a render envelope ({ \"blocks\": [...] }) in the agent output",
61    ))
62}
63
64fn try_parse_object(text: &str) -> Option<Value> {
65    serde_json::from_str(text).ok()
66}
67
68fn extract_fenced_block(raw: &str) -> Option<String> {
69    // Port of /```(?:json)?\s*\n([\s\S]*?)\n```/i — first fenced block, case-insensitive.
70    let lower = raw.to_ascii_lowercase();
71    let mut search_from = 0usize;
72    loop {
73        let rel_start = lower[search_from..].find("```")?;
74        let start = search_from + rel_start;
75        let mut cursor = start + 3;
76        // optional "json" (case-insensitive)
77        if lower[cursor..].starts_with("json") {
78            cursor += 4;
79        }
80        // \s* then a required \n
81        let bytes = raw.as_bytes();
82        let mut i = cursor;
83        while i < bytes.len() && (bytes[i] as char).is_whitespace() && bytes[i] != b'\n' {
84            i += 1;
85        }
86        if i >= bytes.len() || bytes[i] != b'\n' {
87            // no newline right after the opening fence marker; try next occurrence
88            search_from = start + 3;
89            continue;
90        }
91        let content_start = i + 1;
92        if let Some(rel_end) = raw[content_start..].find("\n```") {
93            let content_end = content_start + rel_end;
94            return Some(raw[content_start..content_end].trim().to_string());
95        }
96        return None;
97    }
98}
99
100/// Find the first `{...}` region that mentions a `"blocks"` key, balancing braces.
101fn extract_balanced_object(raw: &str) -> Option<String> {
102    let chars: Vec<char> = raw.chars().collect();
103    let start = chars.iter().position(|&c| c == '{')?;
104    for i in start..chars.len() {
105        if chars[i] != '{' {
106            continue;
107        }
108        if let Some(end) = match_closing_brace(&chars, i) {
109            let slice: String = chars[i..=end].iter().collect();
110            if slice.contains("\"blocks\"") {
111                return Some(slice);
112            }
113        }
114    }
115    None
116}
117
118/// Index of the `}` closing the `{` at `open`, respecting strings/escapes.
119fn match_closing_brace(text: &[char], open: usize) -> Option<usize> {
120    let mut depth: i64 = 0;
121    let mut in_string = false;
122    let mut escaped = false;
123    for (i, &ch) in text.iter().enumerate().skip(open) {
124        if in_string {
125            if escaped {
126                escaped = false;
127            } else if ch == '\\' {
128                escaped = true;
129            } else if ch == '"' {
130                in_string = false;
131            }
132            continue;
133        }
134        if ch == '"' {
135            in_string = true;
136        } else if ch == '{' {
137            depth += 1;
138        } else if ch == '}' {
139            depth -= 1;
140            if depth == 0 {
141                return Some(i);
142            }
143        }
144    }
145    None
146}
147
148fn normalize_envelope(obj: &serde_json::Map<String, Value>) -> Envelope {
149    let blocks = match obj.get("blocks") {
150        Some(Value::Array(arr)) => arr.clone(),
151        _ => Vec::new(),
152    };
153    let summary = match obj.get("summary") {
154        Some(Value::String(s)) => Some(s.clone()),
155        _ => None,
156    };
157    let verified = obj.get("verified").and_then(Value::as_bool);
158    Envelope {
159        blocks,
160        summary,
161        verified,
162    }
163}
164
165// ---------------------------------------------------------------------------
166// HTML rendering
167// ---------------------------------------------------------------------------
168
169const STYLES: &str = r#":root { color-scheme: light dark; }
170* { box-sizing: border-box; }
171body {
172  margin: 0; padding: 2rem;
173  font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
174  background: #f6f7f9; color: #1c1e21;
175}
176@media (prefers-color-scheme: dark) {
177  body { background: #16181c; color: #e6e8eb; }
178  .card, .panel { background: #22252b !important; border-color: #33373f !important; }
179  th { background: #2a2e35 !important; }
180  tr:nth-child(even) td { background: #1c1f24 !important; }
181}
182h1 { font-size: 1.4rem; margin: 0 0 1.25rem; }
183.title-row { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; margin: 0 0 1.25rem; }
184.title-row h1 { margin: 0; }
185.verified-pill {
186  display: inline-flex; align-items: center; gap: .3rem;
187  font-size: .78rem; font-weight: 600; padding: .2rem .55rem; border-radius: 999px;
188  background: #dafbe1; color: #1a7f37; border: 1px solid #aceebb;
189}
190@media (prefers-color-scheme: dark) {
191  .verified-pill { background: #12331d !important; color: #4ac26b !important; border-color: #2a5a38 !important; }
192  .definition code, .definition pre { background: #16181c !important; }
193}
194.definition pre {
195  margin: .35rem 0 .75rem; padding: .6rem .75rem; border-radius: 6px; background: #f0f2f5;
196  overflow-x: auto; font: 12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
197}
198.definition .meta { font-size: .85rem; }
199.definition .meta .k { opacity: .6; margin-right: .35rem; }
200.definition .note { font-size: .75rem; opacity: .6; margin-top: .5rem; font-style: italic; }
201h2 { font-size: 1rem; margin: 0 0 .75rem; font-weight: 600; opacity: .85; }
202.kpi-row { display: flex; flex-wrap: wrap; gap: 1rem; margin-bottom: 1.5rem; }
203.card {
204  background: #fff; border: 1px solid #e3e6ea; border-radius: 10px;
205  padding: 1rem 1.25rem; min-width: 160px; flex: 1 1 160px;
206}
207.card .label { font-size: .8rem; opacity: .7; margin-bottom: .35rem; }
208.card .value { font-size: 1.75rem; font-weight: 650; letter-spacing: -.01em; }
209.card .unit { font-size: .9rem; opacity: .6; margin-left: .25rem; font-weight: 400; }
210.card .delta { font-size: .8rem; margin-top: .35rem; }
211.delta.up { color: #1a7f37; } .delta.down { color: #cf222e; }
212.panel {
213  background: #fff; border: 1px solid #e3e6ea; border-radius: 10px;
214  padding: 1.25rem; margin-bottom: 1.5rem; overflow-x: auto;
215}
216table { border-collapse: collapse; width: 100%; font-size: .9rem; }
217th, td { text-align: left; padding: .5rem .75rem; border-bottom: 1px solid #e3e6ea; }
218th { background: #f0f2f5; font-weight: 600; }
219td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
220.summary { opacity: .8; max-width: 60ch; }
221.status-row { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
222.status-pill {
223  display: inline-flex; align-items: center; text-transform: uppercase; letter-spacing: .03em;
224  font-size: .72rem; font-weight: 700; padding: .2rem .55rem; border-radius: 999px;
225  background: #eaeef2; color: #414852; border: 1px solid #d5dae0;
226}
227.status-pill.ok { background: #dafbe1; color: #1a7f37; border-color: #aceebb; }
228.status-pill.warn { background: #fff3d4; color: #9a6700; border-color: #f2ddab; }
229.status-pill.bad { background: #ffd8d3; color: #cf222e; border-color: #f7bfba; }
230.status-label { font-weight: 600; }
231.status-sev { font-size: .75rem; opacity: .6; text-transform: uppercase; letter-spacing: .03em; }
232.status-detail { margin-top: .5rem; font-size: .9rem; opacity: .8; }
233@media (prefers-color-scheme: dark) {
234  .status-pill { background: #2a2e35 !important; color: #c8ccd2 !important; border-color: #3a3f47 !important; }
235  .status-pill.ok { background: #12331d !important; color: #4ac26b !important; border-color: #2a5a38 !important; }
236  .status-pill.warn { background: #3a2f10 !important; color: #d9a441 !important; border-color: #5a4a1e !important; }
237  .status-pill.bad { background: #3a1a1a !important; color: #f2837c !important; border-color: #5c2b2b !important; }
238}
239.warble-foot { margin-top: 2rem; font-size: .75rem; opacity: .45; }"#;
240
241/// Deterministic categorical palette (accessible on both themes).
242const PALETTE: [&str; 6] = [
243    "#4c78a8", "#f58518", "#54a24b", "#e45756", "#72b7b2", "#b279a2",
244];
245
246fn esc(value: &str) -> String {
247    value
248        .replace('&', "&amp;")
249        .replace('<', "&lt;")
250        .replace('>', "&gt;")
251        .replace('"', "&quot;")
252}
253
254/// Render a value as its "String(value ?? '')" equivalent then escape it, mirroring the TS `esc`
255/// helper applied to loosely-typed envelope fields.
256fn esc_value(value: &Value) -> String {
257    esc(&value_to_display_string(value))
258}
259
260fn value_to_display_string(value: &Value) -> String {
261    match value {
262        Value::Null => String::new(),
263        Value::String(s) => s.clone(),
264        Value::Bool(b) => b.to_string(),
265        Value::Number(n) => number_to_js_string(n),
266        other => other.to_string(),
267    }
268}
269
270/// Cell = string | number | boolean | null.
271fn is_numeric(value: &Value) -> bool {
272    match value.as_f64() {
273        Some(n) => n.is_finite(),
274        None => false,
275    }
276}
277
278/// `String(n)` for a JSON number, e.g. as used by `esc(block.value)` when value isn't numeric.
279fn number_to_js_string(n: &serde_json::Number) -> String {
280    if let Some(i) = n.as_i64() {
281        return i.to_string();
282    }
283    if let Some(u) = n.as_u64() {
284        return u.to_string();
285    }
286    n.as_f64().map(|f| f.to_string()).unwrap_or_default()
287}
288
289/// Locale-free number formatting so output bytes are stable across machines.
290fn fmt_number(n: f64) -> String {
291    if n.fract() == 0.0 && n.is_finite() {
292        return format!("{}", n as i64);
293    }
294    let rounded = (n * 100.0).round() / 100.0;
295    if rounded.fract() == 0.0 {
296        format!("{}", rounded as i64)
297    } else {
298        // Trim trailing zeros the way JS Number#toString would (e.g. 1103.5, not 1103.50).
299        let s = format!("{:.2}", rounded);
300        let s = s.trim_end_matches('0').trim_end_matches('.').to_string();
301        s
302    }
303}
304
305fn fmt_cell(value: &Value) -> String {
306    if value.is_null() {
307        return String::new();
308    }
309    if is_numeric(value) {
310        return fmt_number(value.as_f64().unwrap());
311    }
312    value_to_display_string(value)
313}
314
315fn block_type(block: &Value) -> &str {
316    block.get("type").and_then(Value::as_str).unwrap_or("")
317}
318
319/// Render a full envelope to a single self-contained HTML document. Pure and
320/// deterministic: no clock, no randomness, stable ordering — the same envelope
321/// always produces identical bytes.
322pub fn render_envelope_to_html(envelope: &Envelope, options: &RenderOptions) -> String {
323    let title = options.title.as_deref().unwrap_or("Dashboard");
324    let kpis: Vec<&Value> = envelope
325        .blocks
326        .iter()
327        .filter(|b| block_type(b) == "kpi_card")
328        .collect();
329    let others: Vec<&Value> = envelope
330        .blocks
331        .iter()
332        .filter(|b| block_type(b) != "kpi_card")
333        .collect();
334
335    // Title row carries the `✓ Verified` pill (G2) when the envelope asserts verification.
336    let verified_pill = if envelope.verified == Some(true) {
337        r#"<span class="verified-pill">&#10003; Verified</span>"#
338    } else {
339        ""
340    };
341    let mut parts: Vec<String> = vec![format!(
342        r#"<div class="title-row"><h1>{}</h1>{}</div>"#,
343        esc(title),
344        verified_pill
345    )];
346
347    if !kpis.is_empty() {
348        let cards: String = kpis.iter().map(|b| render_kpi_card(b)).collect();
349        parts.push(format!(r#"<div class="kpi-row">{}</div>"#, cards));
350    }
351    for block in &others {
352        parts.push(render_block(block));
353    }
354    if let Some(summary) = &envelope.summary {
355        if !summary.is_empty() {
356            parts.push(format!(
357                r#"<div class="panel"><h2>Summary</h2><p class="summary">{}</p></div>"#,
358                esc(summary)
359            ));
360        }
361    }
362    parts.push(
363        r#"<div class="warble-foot">Rendered by the Warble reference renderer (programmatic render flavor).</div>"#
364            .to_string(),
365    );
366
367    [
368        "<!doctype html>".to_string(),
369        r#"<html lang="en">"#.to_string(),
370        "<head>".to_string(),
371        r#"<meta charset="utf-8">"#.to_string(),
372        r#"<meta name="viewport" content="width=device-width, initial-scale=1">"#.to_string(),
373        format!("<title>{}</title>", esc(title)),
374        format!("<style>{}</style>", STYLES),
375        "</head>".to_string(),
376        "<body>".to_string(),
377        parts.join("\n"),
378        "</body>".to_string(),
379        "</html>".to_string(),
380        String::new(),
381    ]
382    .join("\n")
383}
384
385fn render_block(block: &Value) -> String {
386    match block_type(block) {
387        "table" => render_table(block),
388        "chart" => render_chart(block),
389        "narrative" => render_narrative(block),
390        "definition" => render_definition(block),
391        "status" => render_status(block),
392        "diff" => render_diff(block),
393        _ => render_unknown(block),
394    }
395}
396
397/// Render a `narrative` block — the stdlib text/markdown block (ir-schema §v0.3). Minimal by
398/// design: an optional `title` heading plus the `text` body, split into paragraphs on blank lines
399/// with single newlines kept as `<br>`. The text is escaped (never injected as raw markup); this is
400/// prose, not a rich-markdown renderer.
401fn render_narrative(block: &Value) -> String {
402    let title = block
403        .get("title")
404        .and_then(Value::as_str)
405        .filter(|t| !t.is_empty())
406        .map(|t| format!("<h2>{}</h2>", esc(t)))
407        .unwrap_or_else(|| "<h2>Narrative</h2>".to_string());
408    let text = block
409        .get("text")
410        .or_else(|| block.get("body"))
411        .or_else(|| block.get("markdown"))
412        .map(value_to_display_string)
413        .unwrap_or_default();
414    let paragraphs: String = text
415        .split("\n\n")
416        .map(str::trim)
417        .filter(|p| !p.is_empty())
418        .map(|p| format!("<p>{}</p>", esc(p).replace('\n', "<br>")))
419        .collect();
420    format!(r#"<div class="panel">{title}{paragraphs}</div>"#)
421}
422
423/// Render a `definition` block — the shallow definition card (G3). Surfaces *how this number was
424/// actually computed on this run*: the SQL the agent ran, the source tables it touched, and the
425/// filters it applied — all knowable at query time without MDL introspection. The panel is
426/// explicitly labelled shallow: unit / owner / formal-metric lineage comes from MDL introspection
427/// (Phase 2 ContextLoader) and is deliberately NOT claimed here.
428fn render_definition(block: &Value) -> String {
429    let sql = block
430        .get("sql")
431        .map(value_to_display_string)
432        .filter(|s| !s.is_empty());
433    let string_list = |key: &str| -> Vec<String> {
434        block
435            .get(key)
436            .and_then(Value::as_array)
437            .map(|arr| arr.iter().map(value_to_display_string).collect())
438            .unwrap_or_default()
439    };
440    let source_tables = string_list("source_tables");
441    let filters = string_list("filters");
442
443    let mut inner = String::from(r#"<h2>Definition — how this was computed</h2>"#);
444    if let Some(sql) = sql {
445        inner.push_str(&format!("<pre>{}</pre>", esc(&sql)));
446    }
447    if !source_tables.is_empty() {
448        inner.push_str(&format!(
449            r#"<div class="meta"><span class="k">Source tables</span>{}</div>"#,
450            esc(&source_tables.join(", "))
451        ));
452    }
453    if !filters.is_empty() {
454        inner.push_str(&format!(
455            r#"<div class="meta"><span class="k">Filters</span>{}</div>"#,
456            esc(&filters.join("; "))
457        ));
458    }
459    inner.push_str(
460        r#"<div class="note">Shallow provenance — the query behind this run. Unit, owner, and formal metric lineage arrive with MDL introspection (Phase 2).</div>"#,
461    );
462    format!(r#"<div class="panel definition">{inner}</div>"#)
463}
464
465/// Render a `status` block — the verdict facet of an **assertive** component (`monitor_freshness`).
466/// Surfaces a deterministic assert's verdict: a state pill (`ok`/`fresh` → green, `warn` → amber,
467/// `stale`/`critical`/`breach` → red) plus a label and a human `detail`. The state/severity classes
468/// drive color; unknown states fall back to a neutral pill. Pure and deterministic like every other
469/// block — no clock, stable output.
470fn render_status(block: &Value) -> String {
471    let state = block
472        .get("state")
473        .and_then(Value::as_str)
474        .unwrap_or("")
475        .to_ascii_lowercase();
476    let severity = block
477        .get("severity")
478        .and_then(Value::as_str)
479        .unwrap_or("")
480        .to_ascii_lowercase();
481    // Worst of state/severity decides the color: any critical/stale/breach → bad; warn → warn; else ok.
482    let signal = |s: &str| matches!(s, "stale" | "critical" | "breach" | "fail" | "down");
483    let warnish = |s: &str| matches!(s, "warn" | "warning" | "degraded" | "late");
484    let cls = if signal(&state) || signal(&severity) {
485        "bad"
486    } else if warnish(&state) || warnish(&severity) {
487        "warn"
488    } else if state.is_empty() {
489        "neutral"
490    } else {
491        "ok"
492    };
493    let pill_text = if state.is_empty() {
494        "status".to_string()
495    } else {
496        state.clone()
497    };
498    let label = block
499        .get("label")
500        .and_then(Value::as_str)
501        .filter(|s| !s.is_empty())
502        .map(|s| format!(r#"<span class="status-label">{}</span>"#, esc(s)))
503        .unwrap_or_default();
504    let severity_badge = if severity.is_empty() {
505        String::new()
506    } else {
507        format!(r#" <span class="status-sev">{}</span>"#, esc(&severity))
508    };
509    let detail = block
510        .get("detail")
511        .or_else(|| block.get("message"))
512        .map(value_to_display_string)
513        .filter(|s| !s.is_empty())
514        .map(|s| format!(r#"<div class="status-detail">{}</div>"#, esc(&s)))
515        .unwrap_or_default();
516    format!(
517        r#"<div class="panel status"><div class="status-row"><span class="status-pill {cls}">{}</span>{label}{severity_badge}</div>{detail}</div>"#,
518        esc(&pill_text)
519    )
520}
521
522/// Render a `diff` block — the dry-run proposal facet of a **mutating** component (+Mutating,
523/// Phase 4a). Surfaces the proposed change exactly as the agent's dry-run captured it: the target
524/// `path` and the raw `diff` text, HTML-escaped inside a `<pre>` (never re-parsed or re-rendered as
525/// markup). This is the artifact a human reviews before approving — Warble never applies it itself;
526/// apply + rollback are borrowed from git (capability `version_control`). Pure and deterministic
527/// like every other block.
528fn render_diff(block: &Value) -> String {
529    let path = block
530        .get("path")
531        .map(value_to_display_string)
532        .filter(|s| !s.is_empty());
533    let diff = block.get("diff").map(value_to_display_string);
534
535    let mut inner = String::from(r#"<h2>Proposed change (dry-run)</h2>"#);
536    if let Some(path) = path {
537        inner.push_str(&format!(
538            r#"<div class="meta"><span class="k">Path</span>{}</div>"#,
539            esc(&path)
540        ));
541    }
542    if let Some(diff) = diff {
543        inner.push_str(&format!("<pre>{}</pre>", esc(&diff)));
544    }
545    inner.push_str(
546        r#"<div class="note">Dry-run only — nothing has been applied. Apply is gated on a blast-radius check and human approval.</div>"#,
547    );
548    format!(r#"<div class="panel definition">{inner}</div>"#)
549}
550
551fn render_kpi_card(block: &Value) -> String {
552    let value_field = block.get("value").cloned().unwrap_or(Value::Null);
553    let value = if is_numeric(&value_field) {
554        fmt_number(value_field.as_f64().unwrap())
555    } else {
556        esc_value(&value_field)
557    };
558    let unit = match block.get("unit") {
559        Some(u) => format!(r#"<span class="unit">{}</span>"#, esc_value(u)),
560        None => String::new(),
561    };
562    let mut delta = String::new();
563    if let Some(d) = block.get("delta").and_then(Value::as_f64) {
564        if d.is_finite() {
565            let dir = if d >= 0.0 { "up" } else { "down" };
566            let sign = if d >= 0.0 { "\u{25B2}" } else { "\u{25BC}" };
567            delta = format!(
568                r#"<div class="delta {}">{} {}</div>"#,
569                dir,
570                sign,
571                fmt_number(d.abs())
572            );
573        }
574    }
575    let label = block.get("label").cloned().unwrap_or(Value::Null);
576    [
577        r#"<div class="card">"#.to_string(),
578        format!(r#"<div class="label">{}</div>"#, esc_value(&label)),
579        format!(r#"<div class="value">{}{}</div>"#, value, unit),
580        delta,
581        "</div>".to_string(),
582    ]
583    .join("")
584}
585
586fn render_table(block: &Value) -> String {
587    let columns: Vec<Value> = block
588        .get("columns")
589        .and_then(Value::as_array)
590        .cloned()
591        .unwrap_or_default();
592    let rows: Vec<Value> = block
593        .get("rows")
594        .and_then(Value::as_array)
595        .cloned()
596        .unwrap_or_default();
597
598    // A column is numeric if every non-empty cell under it is numeric.
599    let numeric_col: Vec<bool> = columns
600        .iter()
601        .enumerate()
602        .map(|(c, _)| {
603            !rows.is_empty()
604                && rows.iter().all(|row| {
605                    let cell = row
606                        .as_array()
607                        .and_then(|r| r.get(c))
608                        .cloned()
609                        .unwrap_or(Value::Null);
610                    cell.is_null()
611                        || matches!(&cell, Value::String(s) if s.is_empty())
612                        || is_numeric(&cell)
613                })
614        })
615        .collect();
616
617    let head: String = columns
618        .iter()
619        .enumerate()
620        .map(|(c, col)| {
621            format!(
622                r#"<th class="{}">{}</th>"#,
623                if numeric_col[c] { "num" } else { "" },
624                esc_value(col)
625            )
626        })
627        .collect();
628
629    let body: String = rows
630        .iter()
631        .map(|row| {
632            let cells: String = columns
633                .iter()
634                .enumerate()
635                .map(|(c, _)| {
636                    let cell = row
637                        .as_array()
638                        .and_then(|r| r.get(c))
639                        .cloned()
640                        .unwrap_or(Value::Null);
641                    format!(
642                        r#"<td class="{}">{}</td>"#,
643                        if numeric_col[c] { "num" } else { "" },
644                        esc(&fmt_cell(&cell))
645                    )
646                })
647                .collect();
648            format!("<tr>{}</tr>", cells)
649        })
650        .collect();
651
652    format!(
653        r#"<div class="panel"><h2>Table</h2><table><thead><tr>{}</tr></thead><tbody>{}</tbody></table></div>"#,
654        head, body
655    )
656}
657
658/// Normalized chart point: `{ x, values[] }`.
659struct ChartPoint {
660    x: String,
661    values: Vec<f64>,
662}
663
664fn normalize_chart_rows(block: &Value) -> Vec<ChartPoint> {
665    let series: Vec<String> = block
666        .get("series")
667        .and_then(Value::as_array)
668        .map(|arr| {
669            arr.iter()
670                .filter_map(|v| v.as_str().map(String::from))
671                .collect()
672        })
673        .unwrap_or_default();
674    let x_key = block.get("x").and_then(Value::as_str).unwrap_or("");
675    let rows: Vec<Value> = block
676        .get("rows")
677        .and_then(Value::as_array)
678        .cloned()
679        .unwrap_or_default();
680
681    rows.iter()
682        .map(|row| {
683            if let Some(arr) = row.as_array() {
684                let x = fmt_cell(&arr.first().cloned().unwrap_or(Value::Null));
685                let values: Vec<f64> = series
686                    .iter()
687                    .enumerate()
688                    .map(|(i, _)| to_number(&arr.get(i + 1).cloned().unwrap_or(Value::Null)))
689                    .collect();
690                ChartPoint { x, values }
691            } else {
692                let obj = row.as_object();
693                let x = fmt_cell(
694                    &obj.and_then(|o| o.get(x_key))
695                        .cloned()
696                        .unwrap_or(Value::Null),
697                );
698                let values: Vec<f64> = series
699                    .iter()
700                    .map(|name| {
701                        to_number(
702                            &obj.and_then(|o| o.get(name))
703                                .cloned()
704                                .unwrap_or(Value::Null),
705                        )
706                    })
707                    .collect();
708                ChartPoint { x, values }
709            }
710        })
711        .collect()
712}
713
714fn to_number(value: &Value) -> f64 {
715    if is_numeric(value) {
716        return value.as_f64().unwrap();
717    }
718    if let Value::String(s) = value {
719        if let Ok(n) = s.trim().parse::<f64>() {
720            if n.is_finite() {
721                return n;
722            }
723        }
724    }
725    0.0
726}
727
728const CHART_W: f64 = 640.0;
729const CHART_H: f64 = 260.0;
730const PAD_TOP: f64 = 16.0;
731const PAD_RIGHT: f64 = 16.0;
732const PAD_BOTTOM: f64 = 40.0;
733const PAD_LEFT: f64 = 48.0;
734
735fn render_chart(block: &Value) -> String {
736    let points = normalize_chart_rows(block);
737    let series: Vec<String> = block
738        .get("series")
739        .and_then(Value::as_array)
740        .map(|arr| {
741            arr.iter()
742                .filter_map(|v| v.as_str().map(String::from))
743                .collect()
744        })
745        .unwrap_or_default();
746    let chart_type = block
747        .get("chart_type")
748        .and_then(Value::as_str)
749        .unwrap_or("");
750
751    let svg = if points.is_empty() || series.is_empty() {
752        r#"<p class="summary">No chart data.</p>"#.to_string()
753    } else {
754        render_chart_svg(chart_type, &points, &series)
755    };
756
757    let legend = if series.len() > 1 {
758        let items: String = series
759            .iter()
760            .enumerate()
761            .map(|(i, name)| {
762                format!(
763                    r#"<span style="margin-right:1rem"><span style="display:inline-block;width:.7rem;height:.7rem;border-radius:2px;background:{};margin-right:.3rem"></span>{}</span>"#,
764                    PALETTE[i % PALETTE.len()],
765                    esc(name)
766                )
767            })
768            .collect();
769        format!(
770            r#"<div style="margin-top:.5rem;font-size:.8rem">{}</div>"#,
771            items
772        )
773    } else {
774        String::new()
775    };
776
777    let x_field = block.get("x").and_then(Value::as_str).unwrap_or("");
778    let heading = if !x_field.is_empty() {
779        format!("{} chart by {}", esc(chart_type), esc(x_field))
780    } else {
781        format!("{} chart", esc(chart_type))
782    };
783
784    format!(
785        r#"<div class="panel"><h2>{}</h2>{}{}</div>"#,
786        heading, svg, legend
787    )
788}
789
790fn render_chart_svg(chart_type: &str, points: &[ChartPoint], series: &[String]) -> String {
791    let plot_w = CHART_W - PAD_LEFT - PAD_RIGHT;
792    let plot_h = CHART_H - PAD_TOP - PAD_BOTTOM;
793    let all_values: Vec<f64> = points
794        .iter()
795        .flat_map(|p| p.values.iter().copied())
796        .collect();
797    let max_raw = all_values.iter().copied().fold(0.0_f64, f64::max);
798    let min_raw = all_values.iter().copied().fold(0.0_f64, f64::min);
799    let max = if max_raw == min_raw {
800        max_raw + 1.0
801    } else {
802        max_raw
803    };
804    let min = min_raw;
805    let y_of = |v: f64| PAD_TOP + plot_h - ((v - min) / (max - min)) * plot_h;
806    let band_w = plot_w / points.len() as f64;
807
808    if chart_type == "pie" {
809        return render_pie_svg(points);
810    }
811
812    let axes = [
813        format!(
814            r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="currentColor" opacity=".25"/>"#,
815            PAD_LEFT,
816            PAD_TOP,
817            PAD_LEFT,
818            PAD_TOP + plot_h
819        ),
820        format!(
821            r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="currentColor" opacity=".25"/>"#,
822            PAD_LEFT,
823            y_of(0.0),
824            PAD_LEFT + plot_w,
825            y_of(0.0)
826        ),
827        format!(
828            r#"<text x="{}" y="{}" font-size="10" text-anchor="end" fill="currentColor" opacity=".6">{}</text>"#,
829            PAD_LEFT - 6.0,
830            y_of(max),
831            esc(&fmt_number(max))
832        ),
833        format!(
834            r#"<text x="{}" y="{}" font-size="10" text-anchor="end" fill="currentColor" opacity=".6">{}</text>"#,
835            PAD_LEFT - 6.0,
836            y_of(min) + 3.0,
837            esc(&fmt_number(min))
838        ),
839    ]
840    .join("");
841
842    let x_labels: String = points
843        .iter()
844        .enumerate()
845        .map(|(i, p)| {
846            let cx = PAD_LEFT + band_w * i as f64 + band_w / 2.0;
847            format!(
848                r#"<text x="{}" y="{}" font-size="10" text-anchor="middle" fill="currentColor" opacity=".6">{}</text>"#,
849                cx,
850                PAD_TOP + plot_h + 14.0,
851                esc(&p.x)
852            )
853        })
854        .collect();
855
856    let marks = if chart_type == "bar" {
857        let group_w = band_w * 0.7;
858        let bar_w = group_w / series.len() as f64;
859        points
860            .iter()
861            .enumerate()
862            .map(|(i, p)| {
863                p.values
864                    .iter()
865                    .enumerate()
866                    .map(|(s, &v)| {
867                        let x = PAD_LEFT
868                            + band_w * i as f64
869                            + (band_w - group_w) / 2.0
870                            + bar_w * s as f64;
871                        let y = y_of(v).min(y_of(0.0));
872                        let h = (y_of(v) - y_of(0.0)).abs();
873                        format!(
874                            r#"<rect x="{}" y="{}" width="{}" height="{}" fill="{}"/>"#,
875                            round(x),
876                            round(y),
877                            round(bar_w - 1.0),
878                            round(h),
879                            PALETTE[s % PALETTE.len()]
880                        )
881                    })
882                    .collect::<String>()
883            })
884            .collect::<String>()
885    } else {
886        // line / area / scatter share the point geometry
887        series
888            .iter()
889            .enumerate()
890            .map(|(s, _)| {
891                let coords: Vec<(f64, f64)> = points
892                    .iter()
893                    .enumerate()
894                    .map(|(i, p)| {
895                        let x = PAD_LEFT + band_w * i as f64 + band_w / 2.0;
896                        let y = y_of(*p.values.get(s).unwrap_or(&0.0));
897                        (x, y)
898                    })
899                    .collect();
900                let color = PALETTE[s % PALETTE.len()];
901                let path = coords
902                    .iter()
903                    .enumerate()
904                    .map(|(i, (x, y))| {
905                        format!(
906                            "{}{},{}",
907                            if i == 0 { "M" } else { "L" },
908                            round(*x),
909                            round(*y)
910                        )
911                    })
912                    .collect::<Vec<_>>()
913                    .join(" ");
914                let dots: String = coords
915                    .iter()
916                    .map(|(x, y)| {
917                        format!(
918                            r#"<circle cx="{}" cy="{}" r="2.5" fill="{}"/>"#,
919                            round(*x),
920                            round(*y),
921                            color
922                        )
923                    })
924                    .collect();
925                if chart_type == "scatter" {
926                    return dots;
927                }
928                let area = if chart_type == "area" {
929                    let (last_x, _) = coords[coords.len() - 1];
930                    let (first_x, _) = coords[0];
931                    format!(
932                        r#"<path d="{} L{},{} L{},{} Z" fill="{}" opacity=".15"/>"#,
933                        path,
934                        round(last_x),
935                        round(y_of(0.0)),
936                        round(first_x),
937                        round(y_of(0.0)),
938                        color
939                    )
940                } else {
941                    String::new()
942                };
943                format!(
944                    r#"{}<path d="{}" fill="none" stroke="{}" stroke-width="2"/>{}"#,
945                    area, path, color, dots
946                )
947            })
948            .collect::<String>()
949    };
950
951    format!(
952        r#"<svg viewBox="0 0 {} {}" width="100%" role="img" style="max-width:{}px">{}{}{}</svg>"#,
953        CHART_W, CHART_H, CHART_W, axes, x_labels, marks
954    )
955}
956
957fn render_pie_svg(points: &[ChartPoint]) -> String {
958    let total: f64 = points
959        .iter()
960        .map(|p| *p.values.first().unwrap_or(&0.0))
961        .sum();
962    let cx = CHART_H / 2.0;
963    let cy = CHART_H / 2.0;
964    let r = CHART_H / 2.0 - 16.0;
965    if total <= 0.0 {
966        return r#"<p class="summary">No chart data.</p>"#.to_string();
967    }
968    let mut angle = -std::f64::consts::FRAC_PI_2;
969    let slices: String = points
970        .iter()
971        .enumerate()
972        .map(|(i, p)| {
973            let v = *p.values.first().unwrap_or(&0.0);
974            let frac = v / total;
975            let next = angle + frac * 2.0 * std::f64::consts::PI;
976            let large = if frac > 0.5 { 1 } else { 0 };
977            let x1 = cx + r * angle.cos();
978            let y1 = cy + r * angle.sin();
979            let x2 = cx + r * next.cos();
980            let y2 = cy + r * next.sin();
981            let path = format!(
982                r#"<path d="M{},{} L{},{} A{},{} 0 {} 1 {},{} Z" fill="{}"/>"#,
983                round(cx),
984                round(cy),
985                round(x1),
986                round(y1),
987                r,
988                r,
989                large,
990                round(x2),
991                round(y2),
992                PALETTE[i % PALETTE.len()]
993            );
994            angle = next;
995            path
996        })
997        .collect();
998    let legend: String = points
999        .iter()
1000        .enumerate()
1001        .map(|(i, p)| {
1002            format!(
1003                r#"<div style="font-size:.8rem"><span style="display:inline-block;width:.7rem;height:.7rem;border-radius:2px;background:{};margin-right:.4rem"></span>{} — {}</div>"#,
1004                PALETTE[i % PALETTE.len()],
1005                esc(&p.x),
1006                esc(&fmt_number(*p.values.first().unwrap_or(&0.0)))
1007            )
1008        })
1009        .collect();
1010    format!(
1011        r#"<div style="display:flex;gap:1.5rem;align-items:center;flex-wrap:wrap"><svg viewBox="0 0 {} {}" width="{}" role="img">{}</svg><div>{}</div></div>"#,
1012        CHART_H, CHART_H, CHART_H, slices, legend
1013    )
1014}
1015
1016/// Round to 2dp for compact, deterministic SVG coordinates.
1017fn round(n: f64) -> f64 {
1018    (n * 100.0).round() / 100.0
1019}
1020
1021fn render_unknown(block: &Value) -> String {
1022    let json = esc(&serde_json::to_string_pretty(block).unwrap_or_default());
1023    let type_label = block
1024        .get("type")
1025        .cloned()
1026        .unwrap_or(Value::String(String::new()));
1027    format!(
1028        r#"<div class="panel"><h2>{} (no reference renderer)</h2><pre style="margin:0;white-space:pre-wrap">{}</pre></div>"#,
1029        esc_value(&type_label),
1030        json
1031    )
1032}