1use crate::markdown;
4
5#[derive(Clone, Copy, Debug)]
7pub 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#[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 #[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 #[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 #[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 #[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 #[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 #[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}