1use std::fs;
2use std::io;
3use std::path::{Path, PathBuf};
4
5use crate::{ExportDoc, ExportSource};
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum Format {
10 Mermaid,
11 Dot,
12 PlantUml,
13 Json,
14}
15
16impl Format {
17 pub const ALL: [Self; 4] = [Self::Mermaid, Self::Dot, Self::PlantUml, Self::Json];
19
20 pub const fn extension(self) -> &'static str {
22 match self {
23 Self::Mermaid => "mmd",
24 Self::Dot => "dot",
25 Self::PlantUml => "puml",
26 Self::Json => "json",
27 }
28 }
29
30 pub fn render<D>(self, doc: &D) -> String
32 where
33 D: ExportSource + ?Sized,
34 {
35 match self {
36 Self::Mermaid => mermaid(doc),
37 Self::Dot => dot(doc),
38 Self::PlantUml => plantuml(doc),
39 Self::Json => json(doc),
40 }
41 }
42
43 pub fn write_to<D, P>(self, doc: &D, path: P) -> io::Result<PathBuf>
47 where
48 D: ExportSource + ?Sized,
49 P: AsRef<Path>,
50 {
51 let path = path.as_ref();
52 ensure_parent_dir(path)?;
53 fs::write(path, self.render(doc))?;
54 Ok(path.to_path_buf())
55 }
56}
57
58pub fn write_all_to_dir<D, P>(doc: &D, dir: P, stem: &str) -> io::Result<Vec<PathBuf>>
61where
62 D: ExportSource + ?Sized,
63 P: AsRef<Path>,
64{
65 let dir = dir.as_ref();
66 validate_output_stem(stem)?;
67 fs::create_dir_all(dir)?;
68
69 Format::ALL
70 .into_iter()
71 .map(|format| {
72 bundle_output_path(dir, stem, format.extension())
73 .and_then(|path| format.write_to(doc, path))
74 })
75 .collect()
76}
77
78pub fn mermaid<D>(doc: &D) -> String
84where
85 D: ExportSource + ?Sized,
86{
87 let doc = doc.export_doc();
88 let doc = doc.as_ref();
89
90 let mut lines = Vec::new();
91 push_comment_lines(&mut lines, "%%", doc);
92 lines.push("graph TD".to_string());
93
94 for state in doc.states() {
95 lines.push(format!(
96 " {}[\"{}\"]",
97 state.node_id(),
98 escape_mermaid_label(&state.display_label())
99 ));
100 }
101
102 if !doc.transitions().is_empty() {
103 lines.push(String::new());
104 }
105
106 for transition in doc.transitions() {
107 let from = doc
108 .state(transition.from)
109 .expect("ExportDoc transition source should exist")
110 .node_id();
111 for target in &transition.to {
112 let to = doc
113 .state(*target)
114 .expect("ExportDoc transition target should exist")
115 .node_id();
116 lines.push(format!(
117 " {from} -->|{}| {to}",
118 escape_mermaid_edge_label(transition.display_label())
119 ));
120 }
121 }
122
123 lines.join("\n")
124}
125
126pub fn dot<D>(doc: &D) -> String
128where
129 D: ExportSource + ?Sized,
130{
131 let doc = doc.export_doc();
132 let doc = doc.as_ref();
133
134 let mut lines = Vec::new();
135 push_comment_lines(&mut lines, "//", doc);
136 lines.push(format!(
137 "digraph \"{}\" {{",
138 escape_dot_label(doc.machine().rust_type_path)
139 ));
140 lines.push(" rankdir=TB;".to_string());
141
142 for state in doc.states() {
143 lines.push(format!(
144 " {} [label=\"{}\"]",
145 state.node_id(),
146 escape_dot_label(&state.display_label())
147 ));
148 }
149
150 if !doc.transitions().is_empty() {
151 lines.push(String::new());
152 }
153
154 for transition in doc.transitions() {
155 let from = doc
156 .state(transition.from)
157 .expect("ExportDoc transition source should exist")
158 .node_id();
159 for target in &transition.to {
160 let to = doc
161 .state(*target)
162 .expect("ExportDoc transition target should exist")
163 .node_id();
164 lines.push(format!(
165 " {from} -> {to} [label=\"{}\"]",
166 escape_dot_label(transition.display_label())
167 ));
168 }
169 }
170
171 lines.push("}".to_string());
172 lines.join("\n")
173}
174
175pub fn plantuml<D>(doc: &D) -> String
177where
178 D: ExportSource + ?Sized,
179{
180 let doc = doc.export_doc();
181 let doc = doc.as_ref();
182
183 let mut lines = vec!["@startuml".to_string()];
184 push_comment_lines(&mut lines, "'", doc);
185
186 for state in doc.states() {
187 lines.push(format!(
188 "state \"{}\" as {}",
189 escape_plantuml_label(&state.display_label()),
190 state.node_id()
191 ));
192 }
193
194 if !doc.transitions().is_empty() {
195 lines.push(String::new());
196 }
197
198 for transition in doc.transitions() {
199 let from = doc
200 .state(transition.from)
201 .expect("ExportDoc transition source should exist")
202 .node_id();
203 for target in &transition.to {
204 let to = doc
205 .state(*target)
206 .expect("ExportDoc transition target should exist")
207 .node_id();
208 lines.push(format!(
209 "{from} --> {to} : {}",
210 escape_plantuml_label(transition.display_label())
211 ));
212 }
213 }
214
215 lines.push("@enduml".to_string());
216 lines.join("\n")
217}
218
219pub fn json<D>(doc: &D) -> String
221where
222 D: ExportSource + ?Sized,
223{
224 let doc = doc.export_doc();
225 serde_json::to_string_pretty(doc.as_ref()).expect("ExportDoc serialization should not fail")
226}
227
228fn ensure_parent_dir(path: &Path) -> io::Result<()> {
229 if let Some(parent) = path.parent().filter(|path| !path.as_os_str().is_empty()) {
230 fs::create_dir_all(parent)?;
231 }
232
233 Ok(())
234}
235
236pub(crate) fn bundle_output_path(dir: &Path, stem: &str, extension: &str) -> io::Result<PathBuf> {
237 validate_output_stem(stem)?;
238 Ok(dir.join(format!("{stem}.{extension}")))
239}
240
241pub(crate) fn validate_output_stem(stem: &str) -> io::Result<()> {
242 let mut components = Path::new(stem).components();
243 match (components.next(), components.next()) {
244 (Some(std::path::Component::Normal(_)), None) => Ok(()),
245 _ => Err(io::Error::new(
246 io::ErrorKind::InvalidInput,
247 format!(
248 "invalid output stem `{stem}`: expected a simple file name without path separators"
249 ),
250 )),
251 }
252}
253
254fn push_comment_lines(lines: &mut Vec<String>, prefix: &str, doc: &ExportDoc) {
255 if let Some(label) = doc.machine().label {
256 for line in label.lines() {
257 lines.push(format!("{prefix} {line}"));
258 }
259 }
260
261 if let Some(description) = doc.machine().description {
262 for line in description.lines() {
263 lines.push(format!("{prefix} {line}"));
264 }
265 }
266}
267
268fn escape_mermaid_label(label: &str) -> String {
269 label
270 .replace('\\', "\\\\")
271 .replace('"', "\\\"")
272 .replace('\n', "\\n")
273}
274
275fn escape_mermaid_edge_label(label: &str) -> String {
276 label
277 .replace('&', "&")
278 .replace('|', "|")
279 .replace('<', "<")
280 .replace('>', ">")
281 .replace('"', """)
282 .replace('\'', "'")
283 .replace('\n', "<br/>")
284}
285
286fn escape_dot_label(label: &str) -> String {
287 label
288 .replace('\\', "\\\\")
289 .replace('"', "\\\"")
290 .replace('\n', "\\n")
291}
292
293fn escape_plantuml_label(label: &str) -> String {
294 label
295 .replace('\\', "\\\\")
296 .replace('"', "\\\"")
297 .replace('\n', "\\n")
298}