Skip to main content

statum_graph/codebase/
render.rs

1use std::fs;
2use std::io;
3use std::path::{Path, PathBuf};
4
5use crate::codebase::{CodebaseDoc, CodebaseMachine, CodebaseState};
6use crate::render::{bundle_output_path, validate_output_stem};
7
8/// One built-in renderer output format for codebase documents.
9#[derive(Clone, Copy, Debug, Eq, PartialEq)]
10pub enum Format {
11    Mermaid,
12    Dot,
13    PlantUml,
14    Json,
15}
16
17impl Format {
18    /// All built-in renderer formats in stable bundle order.
19    pub const ALL: [Self; 4] = [Self::Mermaid, Self::Dot, Self::PlantUml, Self::Json];
20
21    /// Conventional file extension for this format.
22    pub const fn extension(self) -> &'static str {
23        match self {
24            Self::Mermaid => "mmd",
25            Self::Dot => "dot",
26            Self::PlantUml => "puml",
27            Self::Json => "json",
28        }
29    }
30
31    /// Renders one codebase document into this format.
32    pub fn render(self, doc: &CodebaseDoc) -> String {
33        match self {
34            Self::Mermaid => mermaid(doc),
35            Self::Dot => dot(doc),
36            Self::PlantUml => plantuml(doc),
37            Self::Json => json(doc),
38        }
39    }
40
41    /// Renders one codebase document and writes it to one filesystem path.
42    pub fn write_to<P>(self, doc: &CodebaseDoc, path: P) -> io::Result<PathBuf>
43    where
44        P: AsRef<Path>,
45    {
46        let path = path.as_ref();
47        ensure_parent_dir(path)?;
48        fs::write(path, self.render(doc))?;
49        Ok(path.to_path_buf())
50    }
51}
52
53/// Renders one codebase document into every built-in format and writes the
54/// resulting files into `dir` using `stem` plus the format extension.
55pub fn write_all_to_dir<P>(doc: &CodebaseDoc, dir: P, stem: &str) -> io::Result<Vec<PathBuf>>
56where
57    P: AsRef<Path>,
58{
59    let dir = dir.as_ref();
60    validate_output_stem(stem)?;
61    fs::create_dir_all(dir)?;
62
63    Format::ALL
64        .into_iter()
65        .map(|format| {
66            bundle_output_path(dir, stem, format.extension())
67                .and_then(|path| format.write_to(doc, path))
68        })
69        .collect()
70}
71
72/// Renders a combined linked-machine topology as Mermaid flowchart text.
73pub fn mermaid(doc: &CodebaseDoc) -> String {
74    let mut lines = vec![
75        format!("%% linked machines: {}", doc.machines().len()),
76        "graph TD".to_string(),
77    ];
78    let relation_groups = cross_machine_relation_groups(doc);
79    let has_validator_entries = doc
80        .machines()
81        .iter()
82        .any(|machine| !machine.validator_entries.is_empty());
83
84    for machine in doc.machines() {
85        lines.push(format!(
86            "    subgraph {}[\"{}\"]",
87            machine.cluster_id(),
88            escape_mermaid_label(&render_machine_cluster_label(machine))
89        ));
90        for state in &machine.states {
91            lines.push(format!(
92                "        {}[\"{}\"]",
93                machine.node_id(state.index),
94                escape_mermaid_label(&render_state_label(state))
95            ));
96        }
97        lines.push("    end".to_string());
98    }
99
100    if has_validator_entries && !doc.machines().is_empty() {
101        lines.push(String::new());
102    }
103
104    for machine in doc.machines() {
105        for entry in &machine.validator_entries {
106            lines.push(format!(
107                "    {}(\"{}\")",
108                machine.validator_node_id(entry.index),
109                escape_mermaid_label(&entry.display_label())
110            ));
111        }
112    }
113
114    if !doc.machines().is_empty() && (has_validator_entries || any_transitions(doc)) {
115        lines.push(String::new());
116    }
117
118    for machine in doc.machines() {
119        for transition in &machine.transitions {
120            let from = machine.node_id(transition.from);
121            for target in &transition.to {
122                let to = machine.node_id(*target);
123                lines.push(format!(
124                    "    {from} -->|{}| {to}",
125                    escape_mermaid_edge_label(transition.display_label())
126                ));
127            }
128        }
129    }
130
131    if !relation_groups.is_empty() && !doc.machines().is_empty() {
132        lines.push(String::new());
133    }
134
135    for group in &relation_groups {
136        let from_machine = doc
137            .machine(group.from_machine)
138            .expect("relation group source machine should exist");
139        let to_machine = doc
140            .machine(group.to_machine)
141            .expect("relation group target machine should exist");
142        lines.push(format!(
143            "    {} ==>|{}| {}",
144            from_machine.cluster_id(),
145            escape_mermaid_edge_label(&group.display_label()),
146            to_machine.cluster_id()
147        ));
148    }
149
150    if !doc.links().is_empty() && (!doc.machines().is_empty() || !relation_groups.is_empty()) {
151        lines.push(String::new());
152    }
153
154    for link in doc.links() {
155        let from_machine = doc
156            .machine(link.from_machine)
157            .expect("codebase link source machine should exist");
158        let to_machine = doc
159            .machine(link.to_machine)
160            .expect("codebase link target machine should exist");
161        lines.push(format!(
162            "    {} -.->|{}| {}",
163            from_machine.node_id(link.from_state),
164            escape_mermaid_edge_label(link.display_label()),
165            to_machine.node_id(link.to_state)
166        ));
167    }
168
169    if has_validator_entries
170        && (!doc.links().is_empty() || any_transitions(doc) || !doc.machines().is_empty())
171    {
172        lines.push(String::new());
173    }
174
175    for machine in doc.machines() {
176        for entry in &machine.validator_entries {
177            let from = machine.validator_node_id(entry.index);
178            for target in &entry.target_states {
179                lines.push(format!("    {from} -.-> {}", machine.node_id(*target)));
180            }
181        }
182    }
183
184    lines.join("\n")
185}
186
187/// Renders a combined linked-machine topology as DOT text.
188pub fn dot(doc: &CodebaseDoc) -> String {
189    let mut lines = vec![
190        "digraph \"statum_codebase\" {".to_string(),
191        "    rankdir=TB;".to_string(),
192    ];
193    let relation_groups = cross_machine_relation_groups(doc);
194    let has_validator_entries = doc
195        .machines()
196        .iter()
197        .any(|machine| !machine.validator_entries.is_empty());
198
199    for machine in doc.machines() {
200        lines.push(format!(
201            "    subgraph \"cluster_{}\" {{",
202            machine.cluster_id()
203        ));
204        lines.push(format!(
205            "        label=\"{}\";",
206            escape_dot_label(&render_machine_cluster_label(machine))
207        ));
208        for state in &machine.states {
209            lines.push(format!(
210                "        {} [label=\"{}\"]",
211                machine.node_id(state.index),
212                escape_dot_label(&render_state_label(state))
213            ));
214        }
215        lines.push(format!(
216            "        {} [label=\"\", shape=point, width=0.01, height=0.01, style=invis]",
217            machine.summary_node_id()
218        ));
219        lines.push("    }".to_string());
220    }
221
222    if has_validator_entries && !doc.machines().is_empty() {
223        lines.push(String::new());
224    }
225
226    for machine in doc.machines() {
227        for entry in &machine.validator_entries {
228            lines.push(format!(
229                "    {} [label=\"{}\", shape=ellipse, style=\"rounded,dashed\", color=\"#4b5563\"]",
230                machine.validator_node_id(entry.index),
231                escape_dot_label(&entry.display_label())
232            ));
233        }
234    }
235
236    if !doc.machines().is_empty() && (has_validator_entries || any_transitions(doc)) {
237        lines.push(String::new());
238    }
239
240    for machine in doc.machines() {
241        for transition in &machine.transitions {
242            let from = machine.node_id(transition.from);
243            for target in &transition.to {
244                let to = machine.node_id(*target);
245                lines.push(format!(
246                    "    {from} -> {to} [label=\"{}\"]",
247                    escape_dot_label(transition.display_label())
248                ));
249            }
250        }
251    }
252
253    if !relation_groups.is_empty() && !doc.machines().is_empty() {
254        lines.push(String::new());
255    }
256
257    for group in &relation_groups {
258        let from_machine = doc
259            .machine(group.from_machine)
260            .expect("relation group source machine should exist");
261        let to_machine = doc
262            .machine(group.to_machine)
263            .expect("relation group target machine should exist");
264        lines.push(format!(
265            "    {} -> {} [ltail=\"cluster_{}\", lhead=\"cluster_{}\", style=\"bold,dotted\", color=\"#2563eb\", fontcolor=\"#2563eb\", penwidth=2, minlen=2, label=\"{}\"]",
266            from_machine.summary_node_id(),
267            to_machine.summary_node_id(),
268            from_machine.cluster_id(),
269            to_machine.cluster_id(),
270            escape_dot_label(&group.display_label())
271        ));
272    }
273
274    if !doc.links().is_empty() && (!doc.machines().is_empty() || !relation_groups.is_empty()) {
275        lines.push(String::new());
276    }
277
278    for link in doc.links() {
279        let from_machine = doc
280            .machine(link.from_machine)
281            .expect("codebase link source machine should exist");
282        let to_machine = doc
283            .machine(link.to_machine)
284            .expect("codebase link target machine should exist");
285        lines.push(format!(
286            "    {} -> {} [style=dashed, label=\"{}\"]",
287            from_machine.node_id(link.from_state),
288            to_machine.node_id(link.to_state),
289            escape_dot_label(link.display_label())
290        ));
291    }
292
293    if has_validator_entries
294        && (!doc.links().is_empty() || any_transitions(doc) || !doc.machines().is_empty())
295    {
296        lines.push(String::new());
297    }
298
299    for machine in doc.machines() {
300        for entry in &machine.validator_entries {
301            let from = machine.validator_node_id(entry.index);
302            for target in &entry.target_states {
303                lines.push(format!(
304                    "    {from} -> {} [style=dashed, color=\"#4b5563\", penwidth=2, constraint=false]",
305                    machine.node_id(*target)
306                ));
307            }
308        }
309    }
310
311    lines.push("}".to_string());
312    lines.join("\n")
313}
314
315/// Renders a combined linked-machine topology as PlantUML state text.
316pub fn plantuml(doc: &CodebaseDoc) -> String {
317    let mut lines = vec![
318        "@startuml".to_string(),
319        format!("' linked machines: {}", doc.machines().len()),
320    ];
321    let relation_groups = cross_machine_relation_groups(doc);
322    let has_validator_entries = doc
323        .machines()
324        .iter()
325        .any(|machine| !machine.validator_entries.is_empty());
326
327    for machine in doc.machines() {
328        lines.push(format!(
329            "state \"{}\" as {} {{",
330            escape_plantuml_label(&render_machine_cluster_label(machine)),
331            machine.cluster_id()
332        ));
333        for state in &machine.states {
334            lines.push(format!(
335                "    state \"{}\" as {}",
336                escape_plantuml_label(&render_state_label(state)),
337                machine.node_id(state.index)
338            ));
339        }
340        lines.push("}".to_string());
341    }
342
343    if has_validator_entries && !doc.machines().is_empty() {
344        lines.push(String::new());
345    }
346
347    for machine in doc.machines() {
348        for entry in &machine.validator_entries {
349            lines.push(format!(
350                "state \"{}\" as {} <<validator-entry>>",
351                escape_plantuml_label(&entry.display_label()),
352                machine.validator_node_id(entry.index)
353            ));
354        }
355    }
356
357    if !doc.machines().is_empty() && (has_validator_entries || any_transitions(doc)) {
358        lines.push(String::new());
359    }
360
361    for machine in doc.machines() {
362        for transition in &machine.transitions {
363            let from = machine.node_id(transition.from);
364            for target in &transition.to {
365                let to = machine.node_id(*target);
366                lines.push(format!(
367                    "{from} --> {to} : {}",
368                    escape_plantuml_label(transition.display_label())
369                ));
370            }
371        }
372    }
373
374    if !relation_groups.is_empty() && !doc.machines().is_empty() {
375        lines.push(String::new());
376    }
377
378    for group in &relation_groups {
379        let from_machine = doc
380            .machine(group.from_machine)
381            .expect("relation group source machine should exist");
382        let to_machine = doc
383            .machine(group.to_machine)
384            .expect("relation group target machine should exist");
385        lines.push(format!(
386            "{} -[#2563EB,bold]-> {} : {}",
387            from_machine.cluster_id(),
388            to_machine.cluster_id(),
389            escape_plantuml_label(&group.display_label())
390        ));
391    }
392
393    if !doc.links().is_empty() && (!doc.machines().is_empty() || !relation_groups.is_empty()) {
394        lines.push(String::new());
395    }
396
397    for link in doc.links() {
398        let from_machine = doc
399            .machine(link.from_machine)
400            .expect("codebase link source machine should exist");
401        let to_machine = doc
402            .machine(link.to_machine)
403            .expect("codebase link target machine should exist");
404        lines.push(format!(
405            "{} ..> {} : {}",
406            from_machine.node_id(link.from_state),
407            to_machine.node_id(link.to_state),
408            escape_plantuml_label(link.display_label())
409        ));
410    }
411
412    if has_validator_entries
413        && (!doc.links().is_empty() || any_transitions(doc) || !doc.machines().is_empty())
414    {
415        lines.push(String::new());
416    }
417
418    for machine in doc.machines() {
419        for entry in &machine.validator_entries {
420            let from = machine.validator_node_id(entry.index);
421            for target in &entry.target_states {
422                lines.push(format!(
423                    "{from} ..> {} : validator entry",
424                    machine.node_id(*target)
425                ));
426            }
427        }
428    }
429
430    lines.push("@enduml".to_string());
431    lines.join("\n")
432}
433
434/// Renders a combined linked-machine topology as deterministic pretty JSON.
435pub fn json(doc: &CodebaseDoc) -> String {
436    serde_json::to_string_pretty(doc).expect("CodebaseDoc serialization should not fail")
437}
438
439fn ensure_parent_dir(path: &Path) -> io::Result<()> {
440    if let Some(parent) = path.parent().filter(|path| !path.as_os_str().is_empty()) {
441        fs::create_dir_all(parent)?;
442    }
443
444    Ok(())
445}
446
447fn any_transitions(doc: &CodebaseDoc) -> bool {
448    doc.machines()
449        .iter()
450        .any(|machine| !machine.transitions.is_empty())
451}
452
453fn cross_machine_relation_groups(
454    doc: &CodebaseDoc,
455) -> Vec<crate::codebase::CodebaseMachineRelationGroup> {
456    doc.machine_relation_groups()
457        .iter()
458        .filter(|group| group.from_machine != group.to_machine)
459        .cloned()
460        .collect()
461}
462
463fn render_state_label(state: &CodebaseState) -> String {
464    let base = state.display_label();
465    if state.direct_construction_available {
466        format!("{base} [build]")
467    } else {
468        base.into_owned()
469    }
470}
471
472fn render_machine_cluster_label(machine: &CodebaseMachine) -> String {
473    if machine.role.is_composition() {
474        format!("{} [composition]", machine.display_label())
475    } else {
476        machine.display_label().into_owned()
477    }
478}
479
480fn escape_mermaid_label(label: &str) -> String {
481    label
482        .replace('\\', "\\\\")
483        .replace('"', "\\\"")
484        .replace('\n', "\\n")
485}
486
487fn escape_mermaid_edge_label(label: &str) -> String {
488    label
489        .replace('&', "&amp;")
490        .replace('|', "&#124;")
491        .replace('<', "&lt;")
492        .replace('>', "&gt;")
493        .replace('"', "&quot;")
494        .replace('\'', "&#39;")
495        .replace('\n', "<br/>")
496}
497
498fn escape_dot_label(label: &str) -> String {
499    label
500        .replace('\\', "\\\\")
501        .replace('"', "\\\"")
502        .replace('\n', "\\n")
503}
504
505fn escape_plantuml_label(label: &str) -> String {
506    label
507        .replace('\\', "\\\\")
508        .replace('"', "\\\"")
509        .replace('\n', "\\n")
510}