1use std::collections::{BTreeMap, BTreeSet};
4
5use crate::graph::{DescribeEdge, DescribeNode, DescribeSnapshot, DescribeValue};
6
7#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
8pub enum DiagramDirection {
10 Td,
12 #[default]
13 Lr,
15 Bt,
17 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
41pub fn describe_to_mermaid(snapshot: &DescribeSnapshot) -> String {
43 describe_to_mermaid_with_direction(snapshot, DiagramDirection::default())
44}
45
46pub 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
73pub 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
85pub fn describe_to_mermaid_url(snapshot: &DescribeSnapshot) -> String {
87 mermaid_live_url(&describe_to_mermaid(snapshot))
88}
89
90pub fn describe_to_d2(snapshot: &DescribeSnapshot) -> String {
92 describe_to_d2_with_direction(snapshot, DiagramDirection::default())
93}
94
95pub 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
122pub 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
148pub 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
177pub 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}