Skip to main content

graphrefly/
render.rs

1//! Pure renderers over `DescribeSnapshot` (D39/D40).
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use crate::graph::{DescribeEdge, DescribeNode, DescribeSnapshot, DescribeValue};
6
7#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
8/// `DiagramDirection` variants.
9pub enum DiagramDirection {
10    /// `Td` variant.
11    Td,
12    #[default]
13    /// `Lr` variant.
14    Lr,
15    /// `Bt` variant.
16    Bt,
17    /// `Rl` variant.
18    Rl,
19}
20
21impl DiagramDirection {
22    fn mermaid(self) -> &'static str {
23        match self {
24            Self::Td => "TD",
25            Self::Lr => "LR",
26            Self::Bt => "BT",
27            Self::Rl => "RL",
28        }
29    }
30
31    fn d2(self) -> &'static str {
32        match self {
33            Self::Td => "down",
34            Self::Lr => "right",
35            Self::Bt => "up",
36            Self::Rl => "left",
37        }
38    }
39}
40
41/// Creates or computes `describe_to_mermaid`.
42pub fn describe_to_mermaid(snapshot: &DescribeSnapshot) -> String {
43    describe_to_mermaid_with_direction(snapshot, DiagramDirection::default())
44}
45
46/// Creates or computes `describe_to_mermaid_with_direction`.
47pub fn describe_to_mermaid_with_direction(
48    snapshot: &DescribeSnapshot,
49    direction: DiagramDirection,
50) -> String {
51    let (nodes, edges) = flatten(snapshot);
52    let nodes = sorted_nodes(nodes);
53    let mut ids = BTreeMap::new();
54    for (i, node) in nodes.iter().enumerate() {
55        ids.insert(node.id.clone(), format!("n{i}"));
56    }
57    let mut lines = vec![format!("flowchart {}", direction.mermaid())];
58    for node in &nodes {
59        lines.push(format!(
60            "  {}[\"{}\"]",
61            ids.get(&node.id).expect("node id present"),
62            escape_quoted(&node.id)
63        ));
64    }
65    for edge in sorted_edges(edges) {
66        if let (Some(from), Some(to)) = (ids.get(&edge.from), ids.get(&edge.to)) {
67            lines.push(format!("  {from} --> {to}"));
68        }
69    }
70    lines.join("\n")
71}
72
73/// Creates or computes `mermaid_live_url`.
74pub fn mermaid_live_url(source: &str) -> String {
75    let payload = format!(
76        "{{\"autoSync\":true,\"code\":\"{}\",\"mermaid\":{{\"theme\":\"default\"}}}}",
77        json_escape(source)
78    );
79    format!(
80        "https://mermaid.live/edit#base64:{}",
81        base64_url_encode(payload.as_bytes())
82    )
83}
84
85/// Creates or computes `describe_to_mermaid_url`.
86pub fn describe_to_mermaid_url(snapshot: &DescribeSnapshot) -> String {
87    mermaid_live_url(&describe_to_mermaid(snapshot))
88}
89
90/// Creates or computes `describe_to_d2`.
91pub fn describe_to_d2(snapshot: &DescribeSnapshot) -> String {
92    describe_to_d2_with_direction(snapshot, DiagramDirection::default())
93}
94
95/// Creates or computes `describe_to_d2_with_direction`.
96pub fn describe_to_d2_with_direction(
97    snapshot: &DescribeSnapshot,
98    direction: DiagramDirection,
99) -> String {
100    let (nodes, edges) = flatten(snapshot);
101    let nodes = sorted_nodes(nodes);
102    let mut ids = BTreeMap::new();
103    for (i, node) in nodes.iter().enumerate() {
104        ids.insert(node.id.clone(), format!("n{i}"));
105    }
106    let mut lines = vec![format!("direction: {}", direction.d2())];
107    for node in &nodes {
108        lines.push(format!(
109            "{}: \"{}\"",
110            ids.get(&node.id).expect("node id present"),
111            escape_quoted(&node.id)
112        ));
113    }
114    for edge in sorted_edges(edges) {
115        if let (Some(from), Some(to)) = (ids.get(&edge.from), ids.get(&edge.to)) {
116            lines.push(format!("{from} -> {to}"));
117        }
118    }
119    lines.join("\n")
120}
121
122/// Creates or computes `describe_to_pretty`.
123pub fn describe_to_pretty(snapshot: &DescribeSnapshot) -> String {
124    let (nodes, edges) = flatten(snapshot);
125    let mut lines = vec![
126        format!(
127            "Graph {}",
128            snapshot.name.as_deref().unwrap_or("(anonymous)")
129        ),
130        "Nodes:".to_owned(),
131    ];
132    for node in sorted_nodes(nodes) {
133        lines.push(format!(
134            "- {} ({}/{:?}): {}",
135            node.id,
136            node.factory,
137            node.status,
138            format_value(&node)
139        ));
140    }
141    lines.push("Edges:".to_owned());
142    for edge in sorted_edges(edges) {
143        lines.push(format!("- {} -> {}", edge.from, edge.to));
144    }
145    lines.join("\n")
146}
147
148/// Creates or computes `describe_to_ascii`.
149pub fn describe_to_ascii(snapshot: &DescribeSnapshot, include_values: bool) -> String {
150    let (nodes, edges) = flatten(snapshot);
151    let mut outgoing: BTreeMap<String, Vec<String>> = BTreeMap::new();
152    for edge in sorted_edges(edges) {
153        outgoing.entry(edge.from).or_default().push(edge.to);
154    }
155    let mut lines = vec![format!(
156        "Graph {}",
157        snapshot.name.as_deref().unwrap_or("(anonymous)")
158    )];
159    for node in sorted_nodes(nodes) {
160        let value = if include_values {
161            format!(" {}", format_value(&node))
162        } else {
163            String::new()
164        };
165        let to = outgoing
166            .get(&node.id)
167            .map(|targets| targets.join(", "))
168            .unwrap_or_else(|| "-".to_owned());
169        lines.push(format!(
170            "{} [{}/{:?}{}] -> {}",
171            node.id, node.factory, node.status, value, to
172        ));
173    }
174    lines.join("\n")
175}
176
177/// Creates or computes `describe_to_json`.
178pub fn describe_to_json(snapshot: &DescribeSnapshot) -> String {
179    let (nodes, edges) = flatten(snapshot);
180    let mut out = String::new();
181    out.push_str("{\n");
182    if let Some(name) = &snapshot.name {
183        out.push_str(&format!("  \"name\": \"{}\",\n", json_escape(name)));
184    }
185    out.push_str("  \"nodes\": [\n");
186    let nodes = sorted_nodes(nodes);
187    for (i, node) in nodes.iter().enumerate() {
188        out.push_str("    ");
189        out.push_str(&node_json(node));
190        if i + 1 != nodes.len() {
191            out.push(',');
192        }
193        out.push('\n');
194    }
195    out.push_str("  ],\n  \"edges\": [\n");
196    let edges = sorted_edges(edges);
197    for (i, edge) in edges.iter().enumerate() {
198        out.push_str(&format!(
199            "    {{\"from\":\"{}\",\"to\":\"{}\"}}",
200            json_escape(&edge.from),
201            json_escape(&edge.to)
202        ));
203        if i + 1 != edges.len() {
204            out.push(',');
205        }
206        out.push('\n');
207    }
208    out.push_str("  ]\n}");
209    out
210}
211
212fn flatten(snapshot: &DescribeSnapshot) -> (Vec<DescribeNode>, Vec<DescribeEdge>) {
213    let mut nodes = snapshot.nodes.clone();
214    let mut edges = snapshot.edges.clone();
215    for child in snapshot.subgraphs.iter().flatten() {
216        let (mut child_nodes, mut child_edges) = flatten(child);
217        nodes.append(&mut child_nodes);
218        edges.append(&mut child_edges);
219    }
220    (nodes, edges)
221}
222
223fn sorted_nodes(mut nodes: Vec<DescribeNode>) -> Vec<DescribeNode> {
224    nodes.sort_by(|a, b| a.id.cmp(&b.id));
225    nodes
226}
227
228fn sorted_edges(edges: Vec<DescribeEdge>) -> Vec<DescribeEdge> {
229    let mut seen = BTreeSet::new();
230    let mut out = Vec::new();
231    for edge in edges {
232        if seen.insert((edge.from.clone(), edge.to.clone())) {
233            out.push(edge);
234        }
235    }
236    out.sort_by(|a, b| a.from.cmp(&b.from).then_with(|| a.to.cmp(&b.to)));
237    out
238}
239
240fn node_json(node: &DescribeNode) -> String {
241    let mut fields = vec![
242        format!("\"deps\":{}", string_array_json(&node.deps)),
243        format!("\"factory\":\"{}\"", json_escape(&node.factory)),
244        format!("\"id\":\"{}\"", json_escape(&node.id)),
245    ];
246    if let Some(meta) = &node.meta {
247        let body = meta
248            .iter()
249            .map(|(k, v)| format!("\"{}\":\"{}\"", json_escape(k), json_escape(v)))
250            .collect::<Vec<_>>()
251            .join(",");
252        fields.push(format!("\"meta\":{{{body}}}"));
253    }
254    if let Some(name) = &node.name {
255        fields.push(format!("\"name\":\"{}\"", json_escape(name)));
256    }
257    fields.push(format!("\"status\":\"{:?}\"", node.status));
258    if let Some(value) = &node.value {
259        fields.push(format!("\"value\":{}", value_json(value)));
260    }
261    format!("{{{}}}", fields.join(","))
262}
263
264fn string_array_json(values: &[String]) -> String {
265    format!(
266        "[{}]",
267        values
268            .iter()
269            .map(|value| format!("\"{}\"", json_escape(value)))
270            .collect::<Vec<_>>()
271            .join(",")
272    )
273}
274
275fn value_json(value: &DescribeValue) -> String {
276    match value {
277        DescribeValue::Bool(v) => v.to_string(),
278        DescribeValue::I64(v) => v.to_string(),
279        DescribeValue::U64(v) => v.to_string(),
280        DescribeValue::F64(v) if v.is_finite() => v.to_string(),
281        DescribeValue::F64(_) => "\"[non-finite]\"".to_owned(),
282        DescribeValue::String(v) => format!("\"{}\"", json_escape(v)),
283        DescribeValue::Opaque => "\"[Opaque]\"".to_owned(),
284    }
285}
286
287fn format_value(node: &DescribeNode) -> String {
288    match &node.value {
289        None => "<SENTINEL>".to_owned(),
290        Some(DescribeValue::Bool(v)) => v.to_string(),
291        Some(DescribeValue::I64(v)) => v.to_string(),
292        Some(DescribeValue::U64(v)) => v.to_string(),
293        Some(DescribeValue::F64(v)) => v.to_string(),
294        Some(DescribeValue::String(v)) => format!("\"{}\"", json_escape(v)),
295        Some(DescribeValue::Opaque) => "[Opaque]".to_owned(),
296    }
297}
298
299fn escape_quoted(value: &str) -> String {
300    json_escape(value)
301}
302
303fn json_escape(value: &str) -> String {
304    let mut out = String::new();
305    for ch in value.chars() {
306        match ch {
307            '"' => out.push_str("\\\""),
308            '\\' => out.push_str("\\\\"),
309            '\n' => out.push_str("\\n"),
310            '\r' => out.push_str("\\r"),
311            '\t' => out.push_str("\\t"),
312            c if c.is_control() => out.push_str(&format!("\\u{:04x}", c as u32)),
313            c => out.push(c),
314        }
315    }
316    out
317}
318
319const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
320
321fn base64_url_encode(bytes: &[u8]) -> String {
322    let mut out = String::new();
323    let mut i = 0;
324    while i < bytes.len() {
325        let a = bytes[i];
326        let b = bytes.get(i + 1).copied();
327        let c = bytes.get(i + 2).copied();
328        out.push(B64[(a >> 2) as usize] as char);
329        out.push(B64[(((a & 0x03) << 4) | (b.unwrap_or(0) >> 4)) as usize] as char);
330        if let Some(b) = b {
331            out.push(B64[(((b & 0x0f) << 2) | (c.unwrap_or(0) >> 6)) as usize] as char);
332        }
333        if let Some(c) = c {
334            out.push(B64[(c & 0x3f) as usize] as char);
335        }
336        i += 3;
337    }
338    out.replace('+', "-").replace('/', "_")
339}