use std::fmt::Write as _;
use crate::path::RelPath;
#[derive(Debug, Clone)]
pub(crate) struct GraphViewNode {
pub(crate) id: u32,
pub(crate) name: String,
pub(crate) kind: String,
pub(crate) path: Option<RelPath>,
pub(crate) start_row: Option<u32>,
pub(crate) start_col: Option<u32>,
pub(crate) community: u32,
pub(crate) community_label: String,
pub(crate) centrality: u64,
}
#[derive(Debug, Clone)]
pub(crate) struct GraphViewEdge {
pub(crate) from: u32,
pub(crate) to: u32,
pub(crate) kind: String,
pub(crate) provenance: String,
pub(crate) confidence: f32,
pub(crate) weight: u32,
}
#[derive(Debug, Default)]
pub(crate) struct GraphView {
pub(crate) nodes: Vec<GraphViewNode>,
pub(crate) edges: Vec<GraphViewEdge>,
pub(crate) truncated: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum GraphFormat {
NodeLink,
Dot,
Mermaid,
GraphMl,
Cypher,
Html,
Svg,
}
impl GraphFormat {
pub(crate) fn parse(s: &str) -> Option<GraphFormat> {
match s {
"node_link" | "nodelink" | "json" => Some(GraphFormat::NodeLink),
"dot" | "graphviz" => Some(GraphFormat::Dot),
"mermaid" => Some(GraphFormat::Mermaid),
"graphml" => Some(GraphFormat::GraphMl),
"cypher" => Some(GraphFormat::Cypher),
"html" | "interactive" => Some(GraphFormat::Html),
"svg" => Some(GraphFormat::Svg),
_ => None,
}
}
pub(crate) fn as_str(self) -> &'static str {
match self {
GraphFormat::NodeLink => "node_link",
GraphFormat::Dot => "dot",
GraphFormat::Mermaid => "mermaid",
GraphFormat::GraphMl => "graphml",
GraphFormat::Cypher => "cypher",
GraphFormat::Html => "html",
GraphFormat::Svg => "svg",
}
}
pub(crate) fn extension(self) -> &'static str {
match self {
GraphFormat::NodeLink => "json",
GraphFormat::Dot => "dot",
GraphFormat::Mermaid => "mmd",
GraphFormat::GraphMl => "graphml",
GraphFormat::Cypher => "cypher",
GraphFormat::Html => "html",
GraphFormat::Svg => "svg",
}
}
}
pub(crate) fn render(view: &GraphView, format: GraphFormat) -> String {
match format {
GraphFormat::NodeLink => to_node_link(view),
GraphFormat::Dot => to_dot(view),
GraphFormat::Mermaid => to_mermaid(view),
GraphFormat::GraphMl => to_graphml(view),
GraphFormat::Cypher => to_cypher(view),
GraphFormat::Html => super::graph_html::to_html(view),
GraphFormat::Svg => super::graph_svg::to_svg(view),
}
}
fn path_str(node: &GraphViewNode) -> Option<&str> {
node.path.as_ref().and_then(|p| p.as_str())
}
pub(super) fn to_node_link(view: &GraphView) -> String {
let nodes: Vec<serde_json::Value> = view
.nodes
.iter()
.map(|n| {
serde_json::json!({
"id": n.id,
"label": n.name,
"kind": n.kind,
"path": path_str(n),
"start_row": n.start_row,
"start_col": n.start_col,
"community": n.community,
"community_label": n.community_label,
"centrality": n.centrality,
})
})
.collect();
let links: Vec<serde_json::Value> = view
.edges
.iter()
.map(|e| {
serde_json::json!({
"source": e.from,
"target": e.to,
"kind": e.kind,
"provenance": e.provenance,
"confidence": e.confidence,
"weight": e.weight,
})
})
.collect();
let doc = serde_json::json!({
"directed": true,
"multigraph": true,
"truncated": view.truncated,
"nodes": nodes,
"links": links,
});
serde_json::to_string_pretty(&doc).unwrap_or_else(|e| {
tracing::warn!(error = %e, "graph-view node-link serialization failed; emitting empty graph");
"{}".to_string()
})
}
fn strip_control(s: &str, keep: &[char]) -> String {
s.chars().filter(|c| !c.is_control() || keep.contains(c)).collect()
}
fn dot_escape(s: &str) -> String {
strip_control(&s.replace(['\n', '\r'], " "), &[])
.replace('\\', "\\\\")
.replace('"', "\\\"")
}
fn to_dot(view: &GraphView) -> String {
let mut out = String::new();
out.push_str("digraph basemind {\n rankdir=LR;\n node [shape=box];\n");
for n in &view.nodes {
let label = match path_str(n) {
Some(p) => format!("{}\\n{}", dot_escape(&n.name), dot_escape(p)),
None => dot_escape(&n.name),
};
let _ = writeln!(
out,
" n{} [label=\"{}\", tooltip=\"{}\"];",
n.id,
label,
dot_escape(&n.community_label)
);
}
for e in &view.edges {
let _ = writeln!(
out,
" n{} -> n{} [label=\"{}\", penwidth={:.2}];",
e.from,
e.to,
dot_escape(&e.kind),
1.0 + e.confidence,
);
}
out.push_str("}\n");
out
}
fn mermaid_escape(s: &str) -> String {
strip_control(&s.replace(['\n', '\r'], " "), &[]).replace('"', """)
}
fn to_mermaid(view: &GraphView) -> String {
let mut out = String::from("graph LR\n");
for n in &view.nodes {
let _ = writeln!(out, " n{}[\"{}\"]", n.id, mermaid_escape(&n.name));
}
for e in &view.edges {
let _ = writeln!(out, " n{} -->|{}| n{}", e.from, mermaid_escape(&e.kind), e.to);
}
out
}
pub(super) fn xml_escape(s: &str) -> String {
strip_control(s, &['\t', '\n', '\r'])
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn to_graphml(view: &GraphView) -> String {
let mut out = String::new();
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
out.push_str("<graphml xmlns=\"http://graphml.graphdrawing.org/xmlns\">\n");
for (id, ty, name) in [
("d_label", "string", "label"),
("d_kind", "string", "kind"),
("d_path", "string", "path"),
("d_community", "long", "community"),
("d_community_label", "string", "community_label"),
("d_centrality", "long", "centrality"),
] {
let _ = writeln!(
out,
" <key id=\"{id}\" for=\"node\" attr.name=\"{name}\" attr.type=\"{ty}\"/>"
);
}
for (id, ty, name) in [
("e_kind", "string", "kind"),
("e_provenance", "string", "provenance"),
("e_confidence", "double", "confidence"),
("e_weight", "long", "weight"),
] {
let _ = writeln!(
out,
" <key id=\"{id}\" for=\"edge\" attr.name=\"{name}\" attr.type=\"{ty}\"/>"
);
}
out.push_str(" <graph edgedefault=\"directed\">\n");
for n in &view.nodes {
let _ = writeln!(out, " <node id=\"n{}\">", n.id);
let _ = writeln!(out, " <data key=\"d_label\">{}</data>", xml_escape(&n.name));
let _ = writeln!(out, " <data key=\"d_kind\">{}</data>", xml_escape(&n.kind));
if let Some(p) = path_str(n) {
let _ = writeln!(out, " <data key=\"d_path\">{}</data>", xml_escape(p));
}
let _ = writeln!(out, " <data key=\"d_community\">{}</data>", n.community);
let _ = writeln!(
out,
" <data key=\"d_community_label\">{}</data>",
xml_escape(&n.community_label)
);
let _ = writeln!(out, " <data key=\"d_centrality\">{}</data>", n.centrality);
out.push_str(" </node>\n");
}
for (i, e) in view.edges.iter().enumerate() {
let _ = writeln!(
out,
" <edge id=\"e{i}\" source=\"n{}\" target=\"n{}\">",
e.from, e.to
);
let _ = writeln!(out, " <data key=\"e_kind\">{}</data>", xml_escape(&e.kind));
let _ = writeln!(
out,
" <data key=\"e_provenance\">{}</data>",
xml_escape(&e.provenance)
);
let _ = writeln!(out, " <data key=\"e_confidence\">{:.3}</data>", e.confidence);
let _ = writeln!(out, " <data key=\"e_weight\">{}</data>", e.weight);
out.push_str(" </edge>\n");
}
out.push_str(" </graph>\n</graphml>\n");
out
}
fn cypher_escape(s: &str) -> String {
strip_control(s, &[]).replace('\\', "\\\\").replace('\'', "\\'")
}
fn cypher_rel(kind: &str) -> String {
let rel: String = kind
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect::<String>()
.to_ascii_uppercase();
if rel.is_empty() { "REL".to_string() } else { rel }
}
fn to_cypher(view: &GraphView) -> String {
let mut out = String::new();
for n in &view.nodes {
let path = path_str(n).unwrap_or("");
let _ = writeln!(
out,
"CREATE (n{}:Symbol {{name:'{}', kind:'{}', path:'{}', community:{}, community_label:'{}', centrality:{}}})",
n.id,
cypher_escape(&n.name),
cypher_escape(&n.kind),
cypher_escape(path),
n.community,
cypher_escape(&n.community_label),
n.centrality
);
}
for e in &view.edges {
let _ = writeln!(
out,
"CREATE (n{})-[:{} {{provenance:'{}', confidence:{:.3}, weight:{}}}]->(n{})",
e.from,
cypher_rel(&e.kind),
cypher_escape(&e.provenance),
e.confidence,
e.weight,
e.to
);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> GraphView {
GraphView {
nodes: vec![
GraphViewNode {
id: 0,
name: "wr<a>&p".into(), kind: "function".into(),
path: Some(RelPath::from("src/core.rs")),
start_row: Some(1),
start_col: Some(0),
community: 0,
community_label: "src · engine".into(),
centrality: 20,
},
GraphViewNode {
id: 1,
name: "he\"l'p\\er".into(), kind: "function".into(),
path: Some(RelPath::from("src/core.rs")),
start_row: Some(2),
start_col: Some(0),
community: 0,
community_label: "src · engine".into(),
centrality: 10,
},
],
edges: vec![GraphViewEdge {
from: 1,
to: 0,
kind: "calls".into(),
provenance: "extracted".into(),
confidence: 1.0,
weight: 1,
}],
truncated: false,
}
}
#[test]
fn format_parse_accepts_synonyms() {
assert_eq!(GraphFormat::parse("json"), Some(GraphFormat::NodeLink));
assert_eq!(GraphFormat::parse("graphviz"), Some(GraphFormat::Dot));
assert_eq!(GraphFormat::parse("mermaid"), Some(GraphFormat::Mermaid));
assert_eq!(GraphFormat::parse("graphml"), Some(GraphFormat::GraphMl));
assert_eq!(GraphFormat::parse("cypher"), Some(GraphFormat::Cypher));
assert_eq!(GraphFormat::parse("html"), Some(GraphFormat::Html));
assert_eq!(GraphFormat::parse("interactive"), Some(GraphFormat::Html));
assert_eq!(GraphFormat::parse("svg"), Some(GraphFormat::Svg));
assert_eq!(GraphFormat::parse("bogus"), None);
}
#[test]
fn node_link_is_valid_json_with_nodes_and_links() {
let out = render(&sample(), GraphFormat::NodeLink);
let v: serde_json::Value = serde_json::from_str(&out).expect("valid json");
assert_eq!(v["directed"], serde_json::json!(true));
assert_eq!(v["nodes"].as_array().unwrap().len(), 2);
assert_eq!(v["links"].as_array().unwrap().len(), 1);
assert_eq!(v["links"][0]["source"], serde_json::json!(1));
assert_eq!(v["links"][0]["target"], serde_json::json!(0));
assert_eq!(v["nodes"][0]["label"], serde_json::json!("wr<a>&p"));
assert_eq!(v["nodes"][1]["label"], serde_json::json!("he\"l'p\\er"));
}
#[test]
fn dot_escapes_quotes_and_wires_edges() {
let out = render(&sample(), GraphFormat::Dot);
assert!(out.starts_with("digraph basemind {"));
assert!(out.contains("n1 -> n0"));
assert!(out.contains("he\\\"l'p\\\\er"), "dot label escaping: {out}");
}
#[test]
fn mermaid_escapes_quotes() {
let out = render(&sample(), GraphFormat::Mermaid);
assert!(out.starts_with("graph LR"));
assert!(out.contains("n1 -->|calls| n0"));
assert!(out.contains("he"l'p\\er"), "mermaid quote escaping: {out}");
}
#[test]
fn graphml_is_escaped_xml() {
let out = render(&sample(), GraphFormat::GraphMl);
assert!(out.contains("<graphml"));
assert!(out.contains("wr<a>&p"), "xml body escaping: {out}");
assert!(out.contains("he"l'p\\er"), "xml quote/apos escaping: {out}");
assert!(out.contains("source=\"n1\" target=\"n0\""));
}
#[test]
fn cypher_escapes_and_maps_rel_type() {
let out = render(&sample(), GraphFormat::Cypher);
assert!(out.contains("CREATE (n0:Symbol"));
assert!(out.contains("-[:CALLS "), "kind maps to uppercase rel type: {out}");
assert!(out.contains("he\"l\\'p\\\\er"), "cypher literal escaping: {out}");
}
#[test]
fn cypher_rel_rejects_unsafe_kind() {
assert_eq!(cypher_rel("calls"), "CALLS");
assert_eq!(cypher_rel("x]->(m) DETACH DELETE n //"), "XMDETACHDELETEN");
assert_eq!(cypher_rel("!!!"), "REL");
}
#[test]
fn xml_escape_drops_forbidden_control_chars() {
let out = xml_escape("a\u{01}b");
assert_eq!(out, "ab");
assert_eq!(xml_escape("a\tb\nc"), "a\tb\nc");
}
#[test]
fn renderers_strip_terminal_escape_sequences() {
let hostile = "ev\u{1b}]0;pwned\u{07}il\u{1b}[2J";
for escaped in [
dot_escape(hostile),
mermaid_escape(hostile),
cypher_escape(hostile),
xml_escape(hostile),
] {
assert!(
!escaped.contains('\u{1b}') && !escaped.contains('\u{07}'),
"control byte survived escaping: {escaped:?}"
);
}
let mut view = sample();
view.nodes[0].name = hostile.into();
for fmt in [
GraphFormat::Dot,
GraphFormat::Mermaid,
GraphFormat::Cypher,
GraphFormat::GraphMl,
GraphFormat::Svg,
] {
let out = render(&view, fmt);
assert!(
!out.contains('\u{1b}') && !out.contains('\u{07}'),
"{fmt:?} leaked a raw control byte"
);
}
}
#[test]
fn renderers_are_deterministic() {
let v = sample();
for f in [
GraphFormat::NodeLink,
GraphFormat::Dot,
GraphFormat::Mermaid,
GraphFormat::GraphMl,
GraphFormat::Cypher,
GraphFormat::Html,
GraphFormat::Svg,
] {
assert_eq!(render(&v, f), render(&v, f), "{f:?}");
}
}
}