use scc_core::{kinds, predicates};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DiagramModel {
pub nodes: Vec<DiagramNode>,
pub edges: Vec<DiagramEdge>,
pub flows: Vec<(String, Vec<String>)>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DiagramNode {
pub id: String,
pub label: String,
pub kind: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DiagramEdge {
pub from: String,
pub to: String,
pub label: String,
}
pub const MAX_DIAGRAM_NODES: usize = 100;
pub const MAX_EDGES_PER_NODE: usize = 6;
pub fn build_diagram_model(store: &scc_store::Store) -> crate::Result<DiagramModel> {
let mut nodes: Vec<DiagramNode> = store
.all_entities()?
.into_iter()
.filter(|e| {
matches!(
e.kind.as_str(),
kinds::COMPONENT
| kinds::SERVICE
| kinds::DATA_STORE
| kinds::DEPLOYMENT_UNIT
| kinds::EXTERNAL_API
)
})
.map(|e| DiagramNode {
id: e.id.clone(),
label: e.name.clone(),
kind: e.kind.clone(),
})
.collect();
nodes.sort_by(|a, b| a.label.cmp(&b.label).then(a.id.cmp(&b.id)));
nodes.truncate(MAX_DIAGRAM_NODES);
let known: BTreeSet<&str> = nodes.iter().map(|n| n.id.as_str()).collect();
let mut edges: Vec<DiagramEdge> = store
.all_relationships()?
.into_iter()
.filter(|r| {
matches!(
r.predicate.as_str(),
predicates::DEPENDS_ON
| predicates::CALLS
| predicates::HANDLES
| predicates::ROUTES_TO
| predicates::CONTAINS
| predicates::PUBLISHES
| predicates::CONSUMES
| predicates::READS
| predicates::WRITES
) && known.contains(r.subject.as_str())
&& known.contains(r.object.as_str())
})
.map(|r| DiagramEdge {
from: r.subject.clone(),
to: r.object.clone(),
label: r.predicate.clone(),
})
.collect();
edges.sort_by(|a, b| {
(a.from.clone(), a.to.clone(), a.label.clone())
.cmp(&(b.from.clone(), b.to.clone(), b.label.clone()))
});
edges.dedup_by(|a, b| a.from == b.from && a.to == b.to && a.label == b.label);
let mut seen: BTreeMap<String, usize> = BTreeMap::new();
let mut out = Vec::new();
for e in edges {
let n = seen.entry(e.from.clone()).or_insert(0);
if *n < MAX_EDGES_PER_NODE {
*n += 1;
out.push(e);
}
}
let mut flows: Vec<(String, Vec<String>)> = store
.flows()?
.into_iter()
.map(|f| {
let mut steps = f.steps;
steps.sort_by_key(|s| s.order);
let parts: Vec<String> = steps
.into_iter()
.map(|s| format!("{}:{}", s.actor, s.operation))
.collect();
(f.name, parts)
})
.collect();
flows.sort_by(|a, b| a.0.cmp(&b.0));
Ok(DiagramModel {
nodes,
edges: out,
flows,
})
}
fn mermaid_id(id: &str) -> String {
let mut out = String::with_capacity(id.len());
for c in id.chars() {
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
}
pub fn render_mermaid(model: &DiagramModel) -> String {
let mut s = String::from("flowchart LR\n");
for n in &model.nodes {
let label = n.label.replace('"', "");
s.push_str(&format!(
" {}[\"{} ({})\"]\n",
mermaid_id(&n.id),
label,
n.kind
));
}
for e in &model.edges {
s.push_str(&format!(
" {} -->|{}| {}\n",
mermaid_id(&e.from),
e.label,
mermaid_id(&e.to)
));
}
for (i, (name, parts)) in model.flows.iter().enumerate() {
let fname = name.replace('"', "");
s.push_str(&format!(" subgraph flow{i}[\"flow: {fname}\"]\n"));
s.push_str(" direction TB\n");
for (j, p) in parts.iter().enumerate() {
let p = p.replace('"', "");
s.push_str(&format!(" f{i}s{j}[\"{p}\"]\n"));
}
s.push_str(" end\n");
}
s
}
pub fn render_svg(model: &DiagramModel) -> String {
const COLS: usize = 4;
const BOX_W: usize = 300;
const BOX_H: usize = 64;
const GAP_X: usize = 60;
const GAP_Y: usize = 48;
const PAD: usize = 40;
let rows = model.nodes.len().div_ceil(COLS).max(1);
let w = PAD * 2 + COLS * BOX_W + (COLS - 1) * GAP_X;
let flow_h = model.flows.len() * 56;
let h = PAD * 2 + rows * BOX_H + rows.saturating_sub(1) * GAP_Y + flow_h + 40;
let pos = |i: usize| {
let c = i % COLS;
let r = i / COLS;
(PAD + c * (BOX_W + GAP_X), PAD + r * (BOX_H + GAP_Y))
};
let idx: BTreeMap<&str, usize> = model
.nodes
.iter()
.enumerate()
.map(|(i, n)| (n.id.as_str(), i))
.collect();
let mut s = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w}\" height=\"{h}\" font-family=\"sans-serif\">\n"
);
for e in &model.edges {
if let (Some(&a), Some(&b)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) {
let (x1, y1) = pos(a);
let (x2, y2) = pos(b);
s.push_str(&format!(
" <line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" stroke=\"#8b949e\" stroke-width=\"1.5\"><title>{}</title></line>\n",
x1 + BOX_W / 2,
y1 + BOX_H / 2,
x2 + BOX_W / 2,
y2 + BOX_H / 2,
esc(&e.label)
));
}
}
for (i, n) in model.nodes.iter().enumerate() {
let (x, y) = pos(i);
s.push_str(&format!(
" <rect x=\"{x}\" y=\"{y}\" width=\"{BOX_W}\" height=\"{BOX_H}\" rx=\"8\" fill=\"#131a24\" stroke=\"#2a3442\"/>\n"
));
s.push_str(&format!(
" <text x=\"{}\" y=\"{}\" fill=\"#f0f3f6\" font-size=\"15\">{}</text>\n",
x + 12,
y + 26,
esc(&truncate_label(&n.label, 34))
));
s.push_str(&format!(
" <text x=\"{}\" y=\"{}\" fill=\"#8b949e\" font-size=\"12\">{}</text>\n",
x + 12,
y + 46,
esc(&n.kind)
));
}
let mut fy = PAD + rows * BOX_H + rows.saturating_sub(1) * GAP_Y + 28;
for (name, parts) in &model.flows {
s.push_str(&format!(
" <text x=\"{PAD}\" y=\"{fy}\" fill=\"#DEA584\" font-size=\"14\">flow: {}</text>\n",
esc(&truncate_label(name, 60))
));
fy += 20;
let chain = parts
.iter()
.take(8)
.map(|p| truncate_label(p, 30))
.collect::<Vec<_>>()
.join(" -> ");
let more = if parts.len() > 8 {
format!(" (+{} more)", parts.len() - 8)
} else {
String::new()
};
s.push_str(&format!(
" <text x=\"{PAD}\" y=\"{fy}\" fill=\"#8b949e\" font-size=\"12\">{}{}</text>\n",
esc(&chain),
esc(&more)
));
fy += 36;
}
s.push_str("</svg>\n");
s
}
fn esc(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
pub fn truncate_label(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
format!("{}...", &s[..max.saturating_sub(3)])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mermaid_ids_are_safe_and_deterministic() {
assert_eq!(mermaid_id("repo://a/b c"), "repo___a_b_c");
assert_eq!(mermaid_id("repo://a/b c"), mermaid_id("repo://a/b c"));
assert!(
render_mermaid(&DiagramModel {
nodes: vec![],
edges: vec![],
flows: vec![]
})
.starts_with("flowchart LR")
);
}
}