Skip to main content

scc_engine/
diagram.rs

1//! Architecture diagram model + deterministic Mermaid/SVG rendering.
2//!
3//! Pure Store -> model -> text. Lives in the engine so every transport
4//! (CLI, RPC, HTTP, SDKs, FFI) renders the same diagram from the same code;
5//! the CLI only parses args and prints. Map/snap/viewer HTML pages stay
6//! CLI-local (terminal/HTTP-viewer UX, not model behavior).
7
8use scc_core::{kinds, predicates};
9use std::collections::{BTreeMap, BTreeSet};
10
11/// L1 architecture nodes plus capped edges, sorted for byte-determinism.
12#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
13// trace:v1 id=impl.crates-scc-cli-src-viewer.diagram-model work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
14pub struct DiagramModel {
15    pub nodes: Vec<DiagramNode>,
16    pub edges: Vec<DiagramEdge>,
17    /// Flow name plus participant labels in step order.
18    pub flows: Vec<(String, Vec<String>)>,
19}
20
21/// One architecture-layer entity in the diagram.
22#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
23// trace:v1 id=impl.crates-scc-cli-src-viewer.diagram-node work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
24pub struct DiagramNode {
25    pub id: String,
26    pub label: String,
27    pub kind: String,
28}
29
30/// One architectural relationship between diagram nodes.
31#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
32// trace:v1 id=impl.crates-scc-cli-src-viewer.diagram-edge work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
33pub struct DiagramEdge {
34    pub from: String,
35    pub to: String,
36    pub label: String,
37}
38
39// Readability caps (spec: naive grid degrades past ~100 L1 nodes).
40pub const MAX_DIAGRAM_NODES: usize = 100;
41// Max edges kept per source node (same limit group as above).
42pub const MAX_EDGES_PER_NODE: usize = 6;
43
44/// Build the diagram model from the live store. Same L1 filter as
45/// `export_ccg`; edges limited to architectural predicates between known
46/// nodes, capped per node after sorting so output is deterministic.
47// trace:v1 id=impl.crates-scc-cli-src-viewer.build-diagram-model work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
48pub 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    // `edges` is sorted, so the first N per source win deterministically.
102    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
133/// Sanitize an entity id into a Mermaid-safe node id.
134// trace:v1 id=impl.crates-scc-cli-src-viewer.mermaid-id work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
135fn 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
150/// Mermaid flowchart: gitdiagram-shaped output, SCC-derived content.
151// trace:v1 id=impl.crates-scc-cli-src-viewer.render-mermaid work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
152pub 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
184/// Dependency-free SVG: naive grid layout (spec-acknowledged limit),
185/// boxes plus lines plus labels, no JS.
186// trace:v1 id=impl.crates-scc-cli-src-viewer.render-svg work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
187pub 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
273// trace:v1 id=impl.crates-scc-cli-src-viewer.html-esc work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
274fn esc(s: &str) -> String {
275    s.replace('&', "&amp;")
276        .replace('<', "&lt;")
277        .replace('>', "&gt;")
278        .replace('"', "&quot;")
279}
280
281// trace:v1 id=impl.crates-scc-cli-src-viewer.truncate-label work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
282pub 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    // trace:exempt reason=unit-test
295    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}