use std::fmt::Write;
use super::model::{Edge, Graph, Kind, Node};
use super::{ColorAxis, StatusClass, VerifyClass};
pub(crate) fn render(g: &Graph) -> String {
let mut out = String::new();
out.push_str("```mermaid\n");
out.push_str("flowchart TD\n");
push_class_defs(&mut out, g.axis);
out.push('\n');
let id_for_node = node_ids(g);
let intents: Vec<&Node> = g.nodes.iter().filter(|n| n.kind == Kind::Intent).collect();
let assumes: Vec<&Node> = g.nodes.iter().filter(|n| n.kind == Kind::Assume).collect();
if !intents.is_empty() {
out.push_str(" %% Intent nodes (rectangles)\n");
for n in &intents {
push_node_line(&mut out, n, &id_for_node[n.id.as_str()], g.axis);
}
if !assumes.is_empty() {
out.push('\n');
}
}
if !assumes.is_empty() {
out.push_str(" %% Assume nodes (hexagons)\n");
for n in &assumes {
push_node_line(&mut out, n, &id_for_node[n.id.as_str()], g.axis);
}
}
if !g.edges.is_empty() {
out.push('\n');
out.push_str(" %% Parent edges: child --> parent\n");
for e in &g.edges {
push_edge_line(&mut out, e, &id_for_node);
}
}
if g.axis == ColorAxis::Verify {
let critical: Vec<&Node> = g.nodes.iter().filter(|n| n.is_critical).collect();
if !critical.is_empty() {
out.push('\n');
out.push_str(" %% Critical-status border\n");
for n in &critical {
writeln!(out, " class {} critical", id_for_node[n.id.as_str()])
.expect("string write never fails");
}
}
}
out.push_str("```\n");
out
}
fn push_class_defs(out: &mut String, axis: ColorAxis) {
match axis {
ColorAxis::Verify => {
out.push_str(" classDef vFalse fill:#e5e5e5,stroke:#999\n");
out.push_str(" classDef vNeural fill:#fef3c7,stroke:#b45309\n");
out.push_str(" classDef vTest fill:#dbeafe,stroke:#1d4ed8\n");
out.push_str(" classDef vFull fill:#bbf7d0,stroke:#15803d\n");
out.push_str(" classDef critical stroke:#dc2626,stroke-width:3px\n");
}
ColorAxis::Status => {
out.push_str(" classDef sVerified fill:#bbf7d0,stroke:#15803d\n");
out.push_str(" classDef sTested fill:#dbeafe,stroke:#1d4ed8\n");
out.push_str(" classDef sNeural fill:#fef3c7,stroke:#b45309\n");
out.push_str(" classDef sStale fill:#fed7aa,stroke:#c2410c\n");
out.push_str(" classDef sOrphan fill:#e9d5ff,stroke:#7e22ce\n");
out.push_str(
" classDef sForged fill:#fecaca,stroke:#dc2626,stroke-width:3px\n",
);
out.push_str(" classDef sUnknown fill:#e5e5e5,stroke:#9ca3af\n");
out.push_str(" classDef sPendingDeepen fill:#e5e5e5,stroke:#9ca3af\n");
out.push_str(
" classDef sCounterexample fill:#fecaca,stroke:#dc2626,stroke-width:3px\n",
);
out.push_str(
" classDef sInconclusive fill:#fed7aa,stroke:#c2410c,stroke-width:3px\n",
);
}
}
}
fn push_node_line(out: &mut String, n: &Node, node_id: &str, axis: ColorAxis) {
let (open, close) = match n.kind {
Kind::Intent => ("[\"", "\"]"),
Kind::Assume => ("{{\"", "\"}}"),
};
let class = match axis {
ColorAxis::Verify => match n.verify_class {
VerifyClass::False => "vFalse",
VerifyClass::Neural => "vNeural",
VerifyClass::Test => "vTest",
VerifyClass::Full => "vFull",
},
ColorAxis::Status => match n.status_class {
StatusClass::Verified => "sVerified",
StatusClass::Tested => "sTested",
StatusClass::Neural => "sNeural",
StatusClass::Stale => "sStale",
StatusClass::Orphan => "sOrphan",
StatusClass::Forged => "sForged",
StatusClass::Unknown => "sUnknown",
StatusClass::PendingDeepen => "sPendingDeepen",
StatusClass::Counterexample => "sCounterexample",
StatusClass::Inconclusive => "sInconclusive",
},
};
let label_id = escape_label(n.id.as_str());
let label_suffix = escape_label(&n.label_kind_suffix);
writeln!(
out,
" {node_id}{open}{label_id}<br/>{label_suffix}{close}:::{class}"
)
.expect("string write never fails");
}
fn push_edge_line(
out: &mut String,
e: &Edge,
id_for_node: &std::collections::HashMap<String, String>,
) {
let from = &id_for_node[e.from.as_str()];
let to = &id_for_node[e.to.as_str()];
writeln!(out, " {from} --> {to}").expect("string write never fails");
}
fn escape_label(s: &str) -> String {
s.replace('"', """)
.replace('<', "<")
.replace('>', ">")
}
fn node_ids(g: &Graph) -> std::collections::HashMap<String, String> {
let mut out = std::collections::HashMap::with_capacity(g.nodes.len());
for n in &g.nodes {
let mermaid_id = sanitize_for_mermaid(n.id.as_str());
out.insert(n.id.as_str().to_string(), mermaid_id);
}
out
}
fn sanitize_for_mermaid(id: &str) -> String {
let mut out = String::with_capacity(id.len() + 2);
for c in id.chars() {
if c == ':' {
out.push_str("__");
} else if c.is_ascii_alphanumeric() || c == '_' {
out.push(c);
} else {
out.push('_');
}
}
if out.chars().next().is_some_and(|c| c.is_ascii_digit()) {
out.insert(0, 'n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::graph::model::{Edge, Graph, Kind, Node};
use crate::commands::graph::VerifyClass;
use aristo_core::index::AnnotationId;
fn id(s: &str) -> AnnotationId {
AnnotationId::parse(s).unwrap()
}
fn intent_node(s: &str, vc: VerifyClass, critical: bool) -> Node {
Node {
id: id(s),
kind: Kind::Intent,
verify_class: vc,
status_class: StatusClass::Unknown,
label_kind_suffix: format!(
"(intent, verify={})",
match vc {
VerifyClass::False => "false",
VerifyClass::Neural => "neural",
VerifyClass::Test => "test",
VerifyClass::Full => "full",
}
),
is_critical: critical,
}
}
fn assume_node(s: &str) -> Node {
Node {
id: id(s),
kind: Kind::Assume,
verify_class: VerifyClass::False,
status_class: StatusClass::Unknown,
label_kind_suffix: "(assume)".to_string(),
is_critical: false,
}
}
#[test]
fn render_empty_graph_emits_skeleton_only() {
let g = Graph {
nodes: vec![],
edges: vec![],
axis: ColorAxis::Verify,
};
let out = render(&g);
assert!(out.starts_with("```mermaid\nflowchart TD\n"));
assert!(out.contains("classDef vFalse"));
assert!(out.contains("classDef critical"));
assert!(out.ends_with("```\n"));
assert!(!out.contains("%% Intent nodes"));
assert!(!out.contains("%% Assume nodes"));
assert!(!out.contains("%% Parent edges"));
}
#[test]
fn render_intent_uses_rectangle_syntax() {
let g = Graph {
nodes: vec![intent_node("foo", VerifyClass::Neural, false)],
edges: vec![],
axis: ColorAxis::Verify,
};
let out = render(&g);
assert!(out.contains("foo[\"foo<br/>(intent, verify=neural)\"]:::vNeural"));
}
#[test]
fn render_assume_uses_hexagon_syntax() {
let g = Graph {
nodes: vec![assume_node("bar")],
edges: vec![],
axis: ColorAxis::Verify,
};
let out = render(&g);
assert!(out.contains("bar{{\"bar<br/>(assume)\"}}:::vFalse"));
}
#[test]
fn render_critical_node_gets_class_critical_line() {
let g = Graph {
nodes: vec![intent_node("foo", VerifyClass::Full, true)],
edges: vec![],
axis: ColorAxis::Verify,
};
let out = render(&g);
assert!(out.contains("class foo critical"));
assert!(out.contains("%% Critical-status border"));
}
#[test]
fn render_edge_uses_arrow_with_sanitized_ids() {
let g = Graph {
nodes: vec![
intent_node("parent", VerifyClass::Full, false),
intent_node("child", VerifyClass::Neural, false),
],
edges: vec![Edge {
from: id("child"),
to: id("parent"),
}],
axis: ColorAxis::Verify,
};
let out = render(&g);
assert!(out.contains("child --> parent"));
}
#[test]
fn render_aristos_prefixed_id_gets_sanitized() {
let g = Graph {
nodes: vec![intent_node("aristos:foo", VerifyClass::Full, false)],
edges: vec![],
axis: ColorAxis::Verify,
};
let out = render(&g);
assert!(out.contains("aristos__foo[\"aristos:foo<br/>"));
}
#[test]
fn sanitize_replaces_colon_with_double_underscore() {
assert_eq!(sanitize_for_mermaid("aristos:foo"), "aristos__foo");
}
#[test]
fn sanitize_prepends_n_when_id_starts_with_digit() {
assert_eq!(sanitize_for_mermaid("9lives"), "n9lives");
}
#[test]
fn sanitize_preserves_alphanumeric_and_underscore() {
assert_eq!(sanitize_for_mermaid("foo_bar_42"), "foo_bar_42");
}
#[test]
fn escape_label_handles_quote_lt_gt() {
assert_eq!(escape_label("a\"b<c>d"), "a"b<c>d");
}
#[test]
fn intent_and_assume_groups_render_with_comments() {
let g = Graph {
nodes: vec![
intent_node("foo", VerifyClass::Full, false),
assume_node("bar"),
],
edges: vec![],
axis: ColorAxis::Verify,
};
let out = render(&g);
assert!(out.contains("%% Intent nodes (rectangles)"));
assert!(out.contains("%% Assume nodes (hexagons)"));
let intent_pos = out.find("%% Intent nodes").unwrap();
let assume_pos = out.find("%% Assume nodes").unwrap();
assert!(intent_pos < assume_pos);
}
}