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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
10pub enum Format {
11 Mermaid,
12 Dot,
13 PlantUml,
14 Json,
15}
16
17impl Format {
18 pub const ALL: [Self; 4] = [Self::Mermaid, Self::Dot, Self::PlantUml, Self::Json];
20
21 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 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 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
53pub 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
72pub 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
187pub 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
315pub 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
434pub 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('&', "&")
490 .replace('|', "|")
491 .replace('<', "<")
492 .replace('>', ">")
493 .replace('"', """)
494 .replace('\'', "'")
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}