1#![forbid(unsafe_code)]
2mod 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}