1use scc_core::{kinds, predicates};
9use std::collections::{BTreeMap, BTreeSet};
10
11#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
13pub struct DiagramModel {
15 pub nodes: Vec<DiagramNode>,
16 pub edges: Vec<DiagramEdge>,
17 pub flows: Vec<(String, Vec<String>)>,
19}
20
21#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
23pub struct DiagramNode {
25 pub id: String,
26 pub label: String,
27 pub kind: String,
28}
29
30#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
32pub struct DiagramEdge {
34 pub from: String,
35 pub to: String,
36 pub label: String,
37}
38
39pub const MAX_DIAGRAM_NODES: usize = 100;
41pub const MAX_EDGES_PER_NODE: usize = 6;
43
44pub fn build_diagram_model(store: &scc_store::Store) -> crate::Result<DiagramModel> {
49 let mut nodes: Vec<DiagramNode> = store
50 .all_entities()?
51 .into_iter()
52 .filter(|e| {
53 matches!(
54 e.kind.as_str(),
55 kinds::COMPONENT
56 | kinds::SERVICE
57 | kinds::DATA_STORE
58 | kinds::DEPLOYMENT_UNIT
59 | kinds::EXTERNAL_API
60 )
61 })
62 .map(|e| DiagramNode {
63 id: e.id.clone(),
64 label: e.name.clone(),
65 kind: e.kind.clone(),
66 })
67 .collect();
68 nodes.sort_by(|a, b| a.label.cmp(&b.label).then(a.id.cmp(&b.id)));
69 nodes.truncate(MAX_DIAGRAM_NODES);
70 let known: BTreeSet<&str> = nodes.iter().map(|n| n.id.as_str()).collect();
71
72 let mut edges: Vec<DiagramEdge> = store
73 .all_relationships()?
74 .into_iter()
75 .filter(|r| {
76 matches!(
77 r.predicate.as_str(),
78 predicates::DEPENDS_ON
79 | predicates::CALLS
80 | predicates::HANDLES
81 | predicates::ROUTES_TO
82 | predicates::CONTAINS
83 | predicates::PUBLISHES
84 | predicates::CONSUMES
85 | predicates::READS
86 | predicates::WRITES
87 ) && known.contains(r.subject.as_str())
88 && known.contains(r.object.as_str())
89 })
90 .map(|r| DiagramEdge {
91 from: r.subject.clone(),
92 to: r.object.clone(),
93 label: r.predicate.clone(),
94 })
95 .collect();
96 edges.sort_by(|a, b| {
97 (a.from.clone(), a.to.clone(), a.label.clone())
98 .cmp(&(b.from.clone(), b.to.clone(), b.label.clone()))
99 });
100 edges.dedup_by(|a, b| a.from == b.from && a.to == b.to && a.label == b.label);
101 let mut seen: BTreeMap<String, usize> = BTreeMap::new();
103 let mut out = Vec::new();
104 for e in edges {
105 let n = seen.entry(e.from.clone()).or_insert(0);
106 if *n < MAX_EDGES_PER_NODE {
107 *n += 1;
108 out.push(e);
109 }
110 }
111
112 let mut flows: Vec<(String, Vec<String>)> = store
113 .flows()?
114 .into_iter()
115 .map(|f| {
116 let mut steps = f.steps;
117 steps.sort_by_key(|s| s.order);
118 let parts: Vec<String> = steps
119 .into_iter()
120 .map(|s| format!("{}:{}", s.actor, s.operation))
121 .collect();
122 (f.name, parts)
123 })
124 .collect();
125 flows.sort_by(|a, b| a.0.cmp(&b.0));
126 Ok(DiagramModel {
127 nodes,
128 edges: out,
129 flows,
130 })
131}
132
133fn mermaid_id(id: &str) -> String {
136 let mut out = String::with_capacity(id.len());
137 for c in id.chars() {
138 if c.is_ascii_alphanumeric() || c == '_' {
139 out.push(c);
140 } else {
141 out.push('_');
142 }
143 }
144 if out.chars().next().is_some_and(|c| c.is_ascii_digit()) {
145 out.insert(0, 'n');
146 }
147 out
148}
149
150pub fn render_mermaid(model: &DiagramModel) -> String {
153 let mut s = String::from("flowchart LR\n");
154 for n in &model.nodes {
155 let label = n.label.replace('"', "");
156 s.push_str(&format!(
157 " {}[\"{} ({})\"]\n",
158 mermaid_id(&n.id),
159 label,
160 n.kind
161 ));
162 }
163 for e in &model.edges {
164 s.push_str(&format!(
165 " {} -->|{}| {}\n",
166 mermaid_id(&e.from),
167 e.label,
168 mermaid_id(&e.to)
169 ));
170 }
171 for (i, (name, parts)) in model.flows.iter().enumerate() {
172 let fname = name.replace('"', "");
173 s.push_str(&format!(" subgraph flow{i}[\"flow: {fname}\"]\n"));
174 s.push_str(" direction TB\n");
175 for (j, p) in parts.iter().enumerate() {
176 let p = p.replace('"', "");
177 s.push_str(&format!(" f{i}s{j}[\"{p}\"]\n"));
178 }
179 s.push_str(" end\n");
180 }
181 s
182}
183
184pub fn render_svg(model: &DiagramModel) -> String {
188 const COLS: usize = 4;
189 const BOX_W: usize = 300;
190 const BOX_H: usize = 64;
191 const GAP_X: usize = 60;
192 const GAP_Y: usize = 48;
193 const PAD: usize = 40;
194 let rows = model.nodes.len().div_ceil(COLS).max(1);
195 let w = PAD * 2 + COLS * BOX_W + (COLS - 1) * GAP_X;
196 let flow_h = model.flows.len() * 56;
197 let h = PAD * 2 + rows * BOX_H + rows.saturating_sub(1) * GAP_Y + flow_h + 40;
198 let pos = |i: usize| {
199 let c = i % COLS;
200 let r = i / COLS;
201 (PAD + c * (BOX_W + GAP_X), PAD + r * (BOX_H + GAP_Y))
202 };
203 let idx: BTreeMap<&str, usize> = model
204 .nodes
205 .iter()
206 .enumerate()
207 .map(|(i, n)| (n.id.as_str(), i))
208 .collect();
209 let mut s = format!(
210 "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w}\" height=\"{h}\" font-family=\"sans-serif\">\n"
211 );
212 for e in &model.edges {
213 if let (Some(&a), Some(&b)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) {
214 let (x1, y1) = pos(a);
215 let (x2, y2) = pos(b);
216 s.push_str(&format!(
217 " <line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" stroke=\"#8b949e\" stroke-width=\"1.5\"><title>{}</title></line>\n",
218 x1 + BOX_W / 2,
219 y1 + BOX_H / 2,
220 x2 + BOX_W / 2,
221 y2 + BOX_H / 2,
222 esc(&e.label)
223 ));
224 }
225 }
226 for (i, n) in model.nodes.iter().enumerate() {
227 let (x, y) = pos(i);
228 s.push_str(&format!(
229 " <rect x=\"{x}\" y=\"{y}\" width=\"{BOX_W}\" height=\"{BOX_H}\" rx=\"8\" fill=\"#131a24\" stroke=\"#2a3442\"/>\n"
230 ));
231 s.push_str(&format!(
232 " <text x=\"{}\" y=\"{}\" fill=\"#f0f3f6\" font-size=\"15\">{}</text>\n",
233 x + 12,
234 y + 26,
235 esc(&truncate_label(&n.label, 34))
236 ));
237 s.push_str(&format!(
238 " <text x=\"{}\" y=\"{}\" fill=\"#8b949e\" font-size=\"12\">{}</text>\n",
239 x + 12,
240 y + 46,
241 esc(&n.kind)
242 ));
243 }
244 let mut fy = PAD + rows * BOX_H + rows.saturating_sub(1) * GAP_Y + 28;
245 for (name, parts) in &model.flows {
246 s.push_str(&format!(
247 " <text x=\"{PAD}\" y=\"{fy}\" fill=\"#DEA584\" font-size=\"14\">flow: {}</text>\n",
248 esc(&truncate_label(name, 60))
249 ));
250 fy += 20;
251 let chain = parts
252 .iter()
253 .take(8)
254 .map(|p| truncate_label(p, 30))
255 .collect::<Vec<_>>()
256 .join(" -> ");
257 let more = if parts.len() > 8 {
258 format!(" (+{} more)", parts.len() - 8)
259 } else {
260 String::new()
261 };
262 s.push_str(&format!(
263 " <text x=\"{PAD}\" y=\"{fy}\" fill=\"#8b949e\" font-size=\"12\">{}{}</text>\n",
264 esc(&chain),
265 esc(&more)
266 ));
267 fy += 36;
268 }
269 s.push_str("</svg>\n");
270 s
271}
272
273fn esc(s: &str) -> String {
275 s.replace('&', "&")
276 .replace('<', "<")
277 .replace('>', ">")
278 .replace('"', """)
279}
280
281pub fn truncate_label(s: &str, max: usize) -> String {
283 if s.len() <= max {
284 return s.to_string();
285 }
286 format!("{}...", &s[..max.saturating_sub(3)])
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 #[test]
294 fn mermaid_ids_are_safe_and_deterministic() {
296 assert_eq!(mermaid_id("repo://a/b c"), "repo___a_b_c");
297 assert_eq!(mermaid_id("repo://a/b c"), mermaid_id("repo://a/b c"));
298 assert!(
299 render_mermaid(&DiagramModel {
300 nodes: vec![],
301 edges: vec![],
302 flows: vec![]
303 })
304 .starts_with("flowchart LR")
305 );
306 }
307}