Skip to main content

statum_graph/
render.rs

1use std::fs;
2use std::io;
3use std::path::{Path, PathBuf};
4
5use crate::{ExportDoc, ExportSource};
6
7/// One built-in renderer output format.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum Format {
10    Mermaid,
11    Dot,
12    PlantUml,
13    Json,
14}
15
16impl Format {
17    /// All built-in renderer formats in stable bundle order.
18    pub const ALL: [Self; 4] = [Self::Mermaid, Self::Dot, Self::PlantUml, Self::Json];
19
20    /// Conventional file extension for this format.
21    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    /// Renders one document into this format.
31    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    /// Renders one document and writes it to one filesystem path.
44    ///
45    /// Parent directories are created when needed.
46    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
58/// Renders one document into every built-in format and writes the resulting
59/// files into `dir` using `stem` plus the format extension.
60pub 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
78/// Renders a validated machine-local topology as Mermaid flowchart text.
79///
80/// Output ordering is deterministic for one validated export surface. State
81/// labels and edge labels are escaped for Mermaid so the returned string is
82/// suitable for snapshot tests, generated docs, and CLI output.
83pub 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
126/// Renders a validated machine-local topology as DOT text.
127pub 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
175/// Renders a validated machine-local topology as PlantUML state text.
176pub 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
219/// Renders a validated machine-local topology as deterministic pretty JSON.
220pub 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('&', "&amp;")
278        .replace('|', "&#124;")
279        .replace('<', "&lt;")
280        .replace('>', "&gt;")
281        .replace('"', "&quot;")
282        .replace('\'', "&#39;")
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}