Skip to main content

merman_ascii/
lib.rs

1#![forbid(unsafe_code)]
2//! Terminal-friendly ASCII and Unicode rendering for Mermaid typed models.
3//!
4//! `merman-ascii` is deliberately model-driven: callers parse Mermaid text with `merman-core`, then
5//! pass the resulting typed render model into this crate. The renderer does not own Mermaid syntax
6//! parsing.
7
8mod canvas;
9mod class;
10mod color;
11mod er;
12mod error;
13mod graph;
14mod options;
15mod relation_graph;
16mod sequence;
17mod text;
18mod xychart;
19
20pub use color::{AsciiColorMode, AsciiColorRole, AsciiColorTheme, AsciiRgb};
21pub use error::{AsciiError, Result};
22pub use options::{AsciiCharset, AsciiDirection, AsciiRenderOptions};
23
24use merman_core::diagram::RenderSemanticModel;
25use merman_core::diagrams::er::ErDiagramRenderModel;
26use merman_core::diagrams::flowchart::FlowchartV2Model;
27use merman_core::diagrams::sequence::SequenceDiagramRenderModel;
28use merman_core::diagrams::xychart::XyChartDiagramRenderModel;
29use merman_core::models::class_diagram::ClassDiagram;
30
31#[derive(Debug, Clone, Default)]
32pub struct AsciiRenderer {
33    options: AsciiRenderOptions,
34}
35
36impl AsciiRenderer {
37    pub fn new(options: AsciiRenderOptions) -> Result<Self> {
38        options.validate()?;
39        Ok(Self { options })
40    }
41
42    pub fn options(&self) -> &AsciiRenderOptions {
43        &self.options
44    }
45
46    pub fn render_model(&self, model: &RenderSemanticModel) -> Result<String> {
47        render_model(model, &self.options)
48    }
49}
50
51pub fn render_model(model: &RenderSemanticModel, options: &AsciiRenderOptions) -> Result<String> {
52    options.validate()?;
53    match model {
54        RenderSemanticModel::Class(model) => render_class(model, options),
55        RenderSemanticModel::Er(model) => render_er(model, options),
56        RenderSemanticModel::Flowchart(model) => render_flowchart(model, options),
57        RenderSemanticModel::Sequence(model) => render_sequence(model, options),
58        RenderSemanticModel::XyChart(model) => render_xychart(model, options),
59        other => Err(AsciiError::UnsupportedDiagram {
60            diagram_type: other.kind().to_string(),
61        }),
62    }
63}
64
65pub fn render_class(model: &ClassDiagram, options: &AsciiRenderOptions) -> Result<String> {
66    options.validate()?;
67    class::render_class_diagram(model, options)
68}
69
70pub fn render_er(model: &ErDiagramRenderModel, options: &AsciiRenderOptions) -> Result<String> {
71    options.validate()?;
72    er::render_er_diagram(model, options)
73}
74
75pub fn render_flowchart(model: &FlowchartV2Model, options: &AsciiRenderOptions) -> Result<String> {
76    options.validate()?;
77    let graph = graph::from_flowchart_model(model, options)?;
78    graph::render_graph(&graph, options)
79}
80
81pub fn render_sequence(
82    model: &SequenceDiagramRenderModel,
83    options: &AsciiRenderOptions,
84) -> Result<String> {
85    options.validate()?;
86    let diagram = sequence::from_sequence_model(model)?;
87    sequence::render_sequence_diagram(&diagram, options)
88}
89
90pub fn render_xychart(
91    model: &XyChartDiagramRenderModel,
92    options: &AsciiRenderOptions,
93) -> Result<String> {
94    options.validate()?;
95    xychart::render_xychart_diagram(model, options)
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use merman_core::diagrams::flowchart::{FlowEdge, FlowNode, FlowSubgraph, FlowchartV2Model};
102
103    fn empty_flowchart() -> FlowchartV2Model {
104        FlowchartV2Model {
105            acc_descr: None,
106            acc_title: None,
107            class_defs: Default::default(),
108            direction: None,
109            edge_defaults: None,
110            vertex_calls: Vec::new(),
111            nodes: Vec::new(),
112            edges: Vec::new(),
113            subgraphs: Vec::new(),
114            tooltips: Default::default(),
115        }
116    }
117
118    fn node(id: &str) -> FlowNode {
119        FlowNode {
120            id: id.to_string(),
121            label: Some(id.to_string()),
122            label_type: None,
123            layout_shape: None,
124            icon: None,
125            form: None,
126            pos: None,
127            img: None,
128            constraint: None,
129            asset_width: None,
130            asset_height: None,
131            classes: Vec::new(),
132            styles: Vec::new(),
133            link: None,
134            link_target: None,
135            have_callback: false,
136        }
137    }
138
139    fn edge(from: &str, to: &str) -> FlowEdge {
140        FlowEdge {
141            id: format!("{from}-{to}"),
142            from: from.to_string(),
143            to: to.to_string(),
144            label: None,
145            label_type: None,
146            edge_type: None,
147            stroke: None,
148            interpolate: None,
149            classes: Vec::new(),
150            style: Vec::new(),
151            animate: None,
152            animation: None,
153            length: 1,
154        }
155    }
156
157    #[test]
158    fn default_options_match_initial_reference_defaults() {
159        let options = AsciiRenderOptions::default();
160        assert_eq!(options.charset, AsciiCharset::Unicode);
161        assert_eq!(options.fallback_direction, AsciiDirection::LeftRight);
162        assert_eq!(options.color_mode, AsciiColorMode::Plain);
163        assert_eq!(options.color_theme, AsciiColorTheme::default_light());
164        assert_eq!(options.box_border_padding, 1);
165        assert_eq!(options.graph_padding_x, 5);
166        assert_eq!(options.graph_padding_y, 5);
167        assert_eq!(options.sequence_participant_spacing, 5);
168        assert_eq!(options.sequence_message_spacing, 1);
169        assert_eq!(options.sequence_self_message_width, 4);
170        assert!(!options.sequence_mirror_actors);
171    }
172
173    #[test]
174    fn options_builder_sets_color_mode_and_theme() {
175        let edge_arrow = AsciiRgb::from_hex24(0x7aa2f7);
176        let theme =
177            AsciiColorTheme::default_dark().with_role(AsciiColorRole::EdgeArrow, edge_arrow);
178
179        let options = AsciiRenderOptions::unicode()
180            .with_color_mode(AsciiColorMode::TrueColor)
181            .with_color_theme(theme);
182
183        assert_eq!(options.color_mode, AsciiColorMode::TrueColor);
184        assert_eq!(
185            options.color_theme.color_for(AsciiColorRole::EdgeArrow),
186            edge_arrow
187        );
188        assert_eq!(
189            options
190                .color_theme
191                .color_for(AsciiColorRole::ChartSeries(9)),
192            AsciiColorTheme::default_dark().color_for(AsciiColorRole::ChartSeries(1))
193        );
194    }
195
196    #[test]
197    fn options_builder_sets_sequence_mirror_actors() {
198        let options = AsciiRenderOptions::unicode().with_sequence_mirror_actors(true);
199
200        assert!(options.sequence_mirror_actors);
201    }
202
203    #[test]
204    fn validates_sequence_self_message_width() {
205        let options = AsciiRenderOptions {
206            sequence_self_message_width: 1,
207            ..AsciiRenderOptions::default()
208        };
209
210        assert_eq!(
211            options.validate(),
212            Err(AsciiError::InvalidOption {
213                field: "sequence_self_message_width",
214                message: "must be at least 2",
215            })
216        );
217    }
218
219    #[test]
220    fn render_model_routes_basic_flowchart_to_graph_renderer() {
221        let model = RenderSemanticModel::Flowchart(empty_flowchart());
222
223        let rendered = render_model(&model, &AsciiRenderOptions::default()).unwrap();
224
225        assert_eq!(rendered, "");
226    }
227
228    #[test]
229    fn render_flowchart_renders_basic_left_right_chain() {
230        let mut model = empty_flowchart();
231        model.direction = Some("LR".to_string());
232        model.nodes = vec![node("A"), node("B")];
233        model.edges = vec![edge("A", "B")];
234
235        let rendered = render_flowchart(&model, &AsciiRenderOptions::ascii()).unwrap();
236
237        assert_eq!(
238            rendered,
239            "+---+     +---+\n|   |     |   |\n| A |---->| B |\n|   |     |   |\n+---+     +---+\n"
240        );
241    }
242
243    #[test]
244    fn render_flowchart_respects_grid_cell_limit() {
245        let mut model = empty_flowchart();
246        model.nodes = vec![node("A"), node("B")];
247        model.edges = vec![edge("A", "B")];
248        let options = AsciiRenderOptions {
249            max_grid_cells: 1,
250            ..AsciiRenderOptions::ascii()
251        };
252
253        let err = render_flowchart(&model, &options).unwrap_err();
254
255        assert_eq!(
256            err,
257            AsciiError::RenderLimitExceeded {
258                actual: 75,
259                limit: 1,
260            }
261        );
262    }
263
264    #[test]
265    fn render_flowchart_renders_model_edge_labels() {
266        let mut model = empty_flowchart();
267        model.nodes = vec![node("A"), node("B")];
268        model.edges = vec![FlowEdge {
269            label: Some("label".to_string()),
270            ..edge("A", "B")
271        }];
272
273        let rendered = render_flowchart(&model, &AsciiRenderOptions::ascii()).unwrap();
274
275        assert_eq!(
276            rendered,
277            "+---+       +---+\n|   |       |   |\n| A |-label>| B |\n|   |       |   |\n+---+       +---+\n"
278        );
279    }
280
281    #[test]
282    fn render_flowchart_rejects_unsupported_edge_variants() {
283        let mut invisible = empty_flowchart();
284        invisible.nodes = vec![node("A"), node("B")];
285        invisible.edges = vec![FlowEdge {
286            stroke: Some("invisible".to_string()),
287            ..edge("A", "B")
288        }];
289
290        assert_eq!(
291            render_flowchart(&invisible, &AsciiRenderOptions::ascii()),
292            Err(AsciiError::UnsupportedFeature {
293                diagram_type: "flowchart",
294                feature: "non-normal edge strokes",
295            })
296        );
297
298        let mut cross = empty_flowchart();
299        cross.nodes = vec![node("A"), node("B")];
300        cross.edges = vec![FlowEdge {
301            edge_type: Some("arrow_cross".to_string()),
302            ..edge("A", "B")
303        }];
304
305        assert_eq!(
306            render_flowchart(&cross, &AsciiRenderOptions::ascii()),
307            Err(AsciiError::UnsupportedFeature {
308                diagram_type: "flowchart",
309                feature: "non-point edge arrows",
310            })
311        );
312    }
313
314    #[test]
315    fn render_flowchart_renders_model_subgraphs() {
316        let mut model = empty_flowchart();
317        model.nodes = vec![node("A")];
318        model.subgraphs = vec![FlowSubgraph {
319            id: "cluster".to_string(),
320            title: "cluster".to_string(),
321            dir: None,
322            label_type: None,
323            classes: Vec::new(),
324            styles: Vec::new(),
325            nodes: vec!["A".to_string()],
326        }];
327
328        let rendered = render_flowchart(&model, &AsciiRenderOptions::ascii()).unwrap();
329
330        assert_eq!(
331            rendered,
332            concat!(
333                "+-------+\n",
334                "|cluster|\n",
335                "|       |\n",
336                "|       |\n",
337                "| +---+ |\n",
338                "| |   | |\n",
339                "| | A | |\n",
340                "| |   | |\n",
341                "| +---+ |\n",
342                "|       |\n",
343                "+-------+\n",
344            )
345        );
346    }
347
348    #[test]
349    fn render_flowchart_renders_model_multiline_subgraph_titles() {
350        let mut model = empty_flowchart();
351        model.nodes = vec![node("A")];
352        model.subgraphs = vec![FlowSubgraph {
353            id: "cluster".to_string(),
354            title: "Line\nTwo".to_string(),
355            dir: None,
356            label_type: None,
357            classes: Vec::new(),
358            styles: Vec::new(),
359            nodes: vec!["A".to_string()],
360        }];
361
362        let rendered = render_flowchart(&model, &AsciiRenderOptions::ascii()).unwrap();
363
364        assert_eq!(
365            rendered,
366            concat!(
367                "+-------+\n",
368                "| Line  |\n",
369                "|       |\n",
370                "|  Two  |\n",
371                "|       |\n",
372                "|       |\n",
373                "| +---+ |\n",
374                "| |   | |\n",
375                "| | A | |\n",
376                "| |   | |\n",
377                "| +---+ |\n",
378                "|       |\n",
379                "+-------+\n",
380            )
381        );
382    }
383
384    #[test]
385    fn render_flowchart_rejects_unsupported_directions() {
386        let mut model = empty_flowchart();
387        model.direction = Some("XX".to_string());
388        model.nodes = vec![node("A")];
389
390        let err = render_flowchart(&model, &AsciiRenderOptions::ascii()).unwrap_err();
391
392        assert_eq!(
393            err,
394            AsciiError::UnsupportedFeature {
395                diagram_type: "flowchart",
396                feature: "unsupported graph directions",
397            }
398        );
399    }
400
401    #[test]
402    fn render_flowchart_rejects_edges_with_missing_endpoint_nodes() {
403        let mut model = empty_flowchart();
404        model.nodes = vec![node("A")];
405        model.edges = vec![edge("A", "B")];
406
407        let err = render_flowchart(&model, &AsciiRenderOptions::ascii()).unwrap_err();
408
409        assert_eq!(
410            err,
411            AsciiError::UnsupportedFeature {
412                diagram_type: "flowchart",
413                feature: "edges with missing endpoint nodes",
414            }
415        );
416    }
417}