src/mimir_lens/verdict_svg.rs
//! **Red-marked CPG render — the verdict SVG (baddie-detection b5, first cut).**
//!
//! The brief's "first cut = a skade-style SVG of the refuse set": a static,
//! dependency-free SVG that paints every actionable node by its worst verdict —
//! **refuse 🔴 / hold 🟠 / act 🟢** — reusing the shared verdict palette the Trust and
//! ReachableVuln lenses both speak. Any lens that produces `(label, verdict,
//! weakest_hop)` triples feeds this renderer; today Trust supplies REAL verdicts and
//! ReachableVuln supplies them the moment `vuln_findings` is fed.
//!
//! Pure string SVG — no typst, no `docs-export`, no JS — so it renders in a terminal
//! preview, a markdown viewer, or the dwarves UI identically. A layered/zoomable graph
//! is the later cut; this first cut is the ranked refuse→hold card grid that names the
//! culprit + its weakest hop, which is the actionable 90 %.
use serde::{Deserialize, Serialize};
/// One node to paint: its label, the verdict that colours it, and the weakest hop
/// (the reason the verdict is capped — the culprit to fix).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct VerdictNode {
/// The node identity, e.g. `korp::resolve_token` or `time@0.1.45`.
pub label: String,
/// `"refuse"` | `"hold"` | `"act"` — the shared trichotomy.
pub verdict: String,
/// The weakest hop / culprit that caps the verdict (may be empty).
pub weakest_hop: String,
}
/// Palette (skade-style): refuse red, hold amber, act green, on a dark canvas.
fn fill_of(verdict: &str) -> &'static str {
match verdict {
"refuse" => "#d1344e",
"hold" => "#e0973a",
_ => "#3aa76d",
}
}
/// Minimal XML-escape for text nodes/attributes (the labels are code identifiers +
/// crate names, but a `<`/`&` in a reason string must not break the document).
fn esc(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
/// Truncate a label to `n` chars with an ellipsis so a long fqn stays inside its card.
fn clip(s: &str, n: usize) -> String {
if s.chars().count() <= n {
s.to_string()
} else {
let mut out: String = s.chars().take(n.saturating_sub(1)).collect();
out.push('…');
out
}
}
/// **Render a skade-style refuse-set SVG.** `nodes` are painted in the order given
/// (callers rank refuse-before-hold); at most `cap` cards are drawn and the overflow
/// is noted in the header. Returns a standalone `<svg>…</svg>` string.
pub fn render_refuse_map(title: &str, nodes: &[VerdictNode], cap: usize) -> String {
// ── layout geometry ──────────────────────────────────────────────────────────
let cols = 3usize;
let card_w = 300i64;
let card_h = 64i64;
let gap = 14i64;
let pad = 24i64;
let header_h = 92i64;
let shown = nodes.len().min(cap);
let overflow = nodes.len().saturating_sub(shown);
let rows = shown.div_ceil(cols).max(1);
let width = pad * 2 + card_w * cols as i64 + gap * (cols as i64 - 1);
let height = header_h + pad + rows as i64 * (card_h + gap);
let refuse = nodes.iter().filter(|n| n.verdict == "refuse").count();
let hold = nodes.iter().filter(|n| n.verdict == "hold").count();
let mut s = String::new();
s.push_str(&format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}" font-family="ui-monospace, Menlo, monospace">"##
));
s.push_str(&format!(
r##"<rect width="{width}" height="{height}" fill="#14161b"/>"##
));
// ── header: title + counts + legend ──────────────────────────────────────────
s.push_str(&format!(
r##"<text x="{pad}" y="40" fill="#e6e6e6" font-size="22" font-weight="700">{}</text>"##,
esc(title)
));
s.push_str(&format!(
r##"<text x="{pad}" y="66" fill="#9aa0aa" font-size="14">{refuse} refuse · {hold} hold{}</text>"##,
if overflow > 0 { format!(" · +{overflow} more") } else { String::new() }
));
// legend swatches
let mut lx = width - pad - 220;
for (v, name) in [("refuse", "refuse"), ("hold", "hold"), ("act", "act")] {
s.push_str(&format!(
r##"<rect x="{lx}" y="26" width="14" height="14" rx="3" fill="{}"/><text x="{}" y="38" fill="#9aa0aa" font-size="13">{name}</text>"##,
fill_of(v),
lx + 20
));
lx += 72;
}
// ── the cards ─────────────────────────────────────────────────────────────────
for (i, node) in nodes.iter().take(shown).enumerate() {
let col = (i % cols) as i64;
let row = (i / cols) as i64;
let x = pad + col * (card_w + gap);
let y = header_h + pad + row * (card_h + gap);
let fill = fill_of(&node.verdict);
s.push_str(&format!(
r##"<rect x="{x}" y="{y}" width="{card_w}" height="{card_h}" rx="8" fill="{fill}" fill-opacity="0.18" stroke="{fill}" stroke-width="1.5"/>"##
));
// a verdict dot
s.push_str(&format!(
r##"<circle cx="{}" cy="{}" r="5" fill="{fill}"/>"##,
x + 16,
y + 22
));
s.push_str(&format!(
r##"<text x="{}" y="{}" fill="#e6e6e6" font-size="14" font-weight="600">{}</text>"##,
x + 30,
y + 26,
esc(&clip(&node.label, 30))
));
if !node.weakest_hop.is_empty() {
s.push_str(&format!(
r##"<text x="{}" y="{}" fill="#9aa0aa" font-size="12">↯ {}</text>"##,
x + 30,
y + 47,
esc(&clip(&node.weakest_hop, 34))
));
}
}
s.push_str("</svg>");
s
}
#[cfg(test)]
mod tests {
use super::*;
fn n(label: &str, verdict: &str, hop: &str) -> VerdictNode {
VerdictNode {
label: label.into(),
verdict: verdict.into(),
weakest_hop: hop.into(),
}
}
/// **Red-when-reverted core.** A refuse node is painted with the refuse red and a
/// hold node with the hold amber — the whole point of a "red-marked" graph. Swap
/// the palette and these colour asserts break. The SVG is well-formed and names
/// the culprit + its weakest hop.
#[test]
fn refuse_is_red_hold_is_amber_and_labels_render() {
let nodes = &[
n(
"nornir::spawn_shell",
"refuse",
"impure hop — shell_out (Command::new)",
),
n("korp::load_cfg", "hold", "untested"),
];
let svg = render_refuse_map("Trust — refuse set", nodes, 50);
assert!(svg.starts_with("<svg") && svg.ends_with("</svg>"));
// the refuse red + hold amber are both present
assert!(svg.contains("#d1344e"), "refuse red must be painted");
assert!(svg.contains("#e0973a"), "hold amber must be painted");
// culprit + weakest hop rendered
assert!(svg.contains("nornir::spawn_shell"));
assert!(
svg.contains("shell_out"),
"the weakest hop names the culprit"
);
// header counts
assert!(svg.contains("1 refuse · 1 hold"));
}
/// XML-unsafe characters in a reason string must be escaped, not break the doc.
#[test]
fn labels_are_xml_escaped() {
let nodes = &[n("a::f", "refuse", "cmp <T> & raw <ptr>")];
let svg = render_refuse_map("t", nodes, 50);
assert!(svg.contains("<T> & raw <ptr>"), "{svg}");
assert!(
!svg.contains("<ptr>"),
"raw angle brackets must not survive"
);
}
/// The `cap` bounds the cards drawn and the overflow is honestly reported (never a
/// silent truncation).
#[test]
fn cap_bounds_cards_and_reports_overflow() {
let nodes: Vec<VerdictNode> = (0..10)
.map(|i| n(&format!("r::f{i}"), "refuse", ""))
.collect();
let svg = render_refuse_map("t", &nodes, 4);
assert!(svg.contains("+6 more"), "overflow reported: {svg}");
assert!(svg.contains("r::f0") && svg.contains("r::f3"));
assert!(!svg.contains("r::f4"), "capped cards are not drawn");
}
}