Skip to main content

wowlab_common/
mermaid.rs

1// #t(file: rust_alloc_in_loop) graph builder produces owned strings per element, unavoidable
2
3use crate::markdown;
4
5/// Direction for a mermaid flowchart.
6#[derive(Clone, Copy, Debug)]
7// #t(rust_non_exhaustive_on_public) LR and TB are the only mermaid flowchart directions
8pub enum Direction {
9    LR,
10    TB,
11}
12
13impl Direction {
14    const fn into_str(self) -> &'static str {
15        match self {
16            Self::LR => "LR",
17            Self::TB => "TB",
18        }
19    }
20}
21
22/// Builder for mermaid flowchart diagrams.
23#[derive(Debug)]
24pub struct Graph {
25    direction: Direction,
26    nodes: Vec<Box<str>>,
27    edges: Vec<(Box<str>, Box<str>)>,
28}
29
30impl Graph {
31    /// Create a new graph with the given direction.
32    #[must_use]
33    pub fn new(direction: Direction) -> Self {
34        Self {
35            direction,
36            nodes: Vec::new(),
37            edges: Vec::new(),
38        }
39    }
40
41    /// Add a standalone node (rendered only if it has no edges).
42    #[must_use]
43    pub fn node(mut self, name: impl Into<String>) -> Self {
44        self.nodes.push(name.into().into_boxed_str());
45
46        self
47    }
48
49    /// Add multiple standalone nodes.
50    #[must_use]
51    pub fn nodes<I, T>(mut self, names: I) -> Self
52    where
53        I: IntoIterator<Item = T>,
54        T: Into<String>,
55    {
56        self.nodes
57            .extend(names.into_iter().map(|name| name.into().into_boxed_str()));
58
59        self
60    }
61
62    /// Add a directed edge from `from` to `to`.
63    #[must_use]
64    pub fn edge(mut self, from: impl Into<String>, to: impl Into<String>) -> Self {
65        self.edges
66            .push((from.into().into_boxed_str(), to.into().into_boxed_str()));
67
68        self
69    }
70
71    /// Add multiple edges.
72    #[must_use]
73    pub fn edges<I, F, T>(mut self, edges: I) -> Self
74    where
75        I: IntoIterator<Item = (F, T)>,
76        F: Into<String>,
77        T: Into<String>,
78    {
79        self.edges.extend(
80            edges
81                .into_iter()
82                .map(|(from, to)| (from.into().into_boxed_str(), to.into().into_boxed_str())),
83        );
84
85        self
86    }
87
88    /// Render as a mermaid code block, or empty string if there are no edges.
89    #[must_use]
90    pub fn build(mut self) -> String {
91        self.edges.sort();
92        self.edges.dedup();
93
94        if self.edges.is_empty() {
95            return String::new();
96        }
97
98        let mut connected: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
99
100        for (from, to) in &self.edges {
101            connected.insert(from.as_ref());
102            connected.insert(to.as_ref());
103        }
104
105        let mut lines = vec![format!("graph {}", self.direction.into_str())];
106
107        self.nodes.sort();
108        self.nodes.dedup();
109
110        for node in &self.nodes {
111            if !connected.contains(node.as_ref()) {
112                lines.push(format!("    {node}"));
113            }
114        }
115
116        for (from, to) in &self.edges {
117            lines.push(format!("    {from} --> {to}"));
118        }
119
120        markdown::code_block("mermaid", lines.join("\n"))
121    }
122}
123
124impl Default for Graph {
125    fn default() -> Self {
126        Self::new(Direction::LR)
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use googletest::prelude::*;
133
134    use super::*;
135
136    #[gtest]
137    fn empty_graph_returns_empty_string() -> Result<()> {
138        let result = Graph::new(Direction::LR).build();
139
140        verify_that!(result, eq(""))?;
141
142        Ok(())
143    }
144
145    #[gtest]
146    fn single_edge() -> Result<()> {
147        let result = Graph::new(Direction::LR).edge("a", "b").build();
148
149        verify_that!(result, contains_substring("graph LR"))?;
150        verify_that!(result, contains_substring("    a --> b"))?;
151
152        Ok(())
153    }
154
155    #[gtest]
156    fn standalone_nodes_without_edges() -> Result<()> {
157        let result = Graph::new(Direction::LR).node("orphan").build();
158
159        verify_that!(result, eq(""))?;
160
161        Ok(())
162    }
163
164    #[gtest]
165    fn standalone_nodes_with_edges() -> Result<()> {
166        let result = Graph::new(Direction::LR)
167            .node("orphan")
168            .node("a")
169            .edge("a", "b")
170            .build();
171
172        verify_that!(result, contains_substring("    orphan"))?;
173        verify_that!(result, contains_substring("    a --> b"))?;
174        let standalone_lines: Vec<&str> = result.lines().filter(|l| l.trim() == "a").collect();
175
176        verify_that!(standalone_lines, is_empty())?;
177
178        Ok(())
179    }
180
181    #[gtest]
182    fn edges_are_sorted_and_deduped() -> Result<()> {
183        let result = Graph::new(Direction::LR)
184            .edge("b", "c")
185            .edge("a", "b")
186            .edge("b", "c")
187            .build();
188        let edge_lines: Vec<&str> = result.lines().filter(|l| l.contains("-->")).collect();
189
190        verify_that!(
191            edge_lines,
192            elements_are![contains_substring("a --> b"), contains_substring("b --> c")]
193        )?;
194
195        Ok(())
196    }
197
198    #[gtest]
199    fn top_to_bottom_direction() -> Result<()> {
200        let result = Graph::new(Direction::TB).edge("x", "y").build();
201
202        verify_that!(result, contains_substring("graph TB"))?;
203
204        Ok(())
205    }
206
207    #[gtest]
208    fn bulk_edges() -> Result<()> {
209        let result = Graph::new(Direction::LR)
210            .edges([("a", "b"), ("c", "d")])
211            .build();
212
213        verify_that!(result, contains_substring("a --> b"))?;
214        verify_that!(result, contains_substring("c --> d"))?;
215
216        Ok(())
217    }
218
219    #[gtest]
220    fn wrapped_in_mermaid_code_block() -> Result<()> {
221        let result = Graph::new(Direction::LR).edge("x", "y").build();
222
223        verify_that!(result, starts_with("```mermaid\n"))?;
224        verify_that!(result, ends_with("\n```"))?;
225
226        Ok(())
227    }
228}