mermaid_text/lib.rs
1//! # mermaid-text
2//!
3//! Render [Mermaid](https://mermaid.js.org/) `graph`/`flowchart` diagrams as
4//! Unicode box-drawing text — no browser, no image protocol, pure Rust.
5//! Intended for use in terminals, SSH sessions, CI logs, and any context where
6//! a visual diagram is useful but image rendering is unavailable. The output
7//! is deterministic and structured, making it suitable for LLM agents that
8//! need to read and reason about diagrams.
9//!
10//! ## ASCII mode
11//!
12//! For terminals that do not support Unicode box-drawing characters (old SSH
13//! boxes, CI log viewers, fonts without the Box Drawing block), an ASCII-only
14//! rendering mode is available. The Unicode renderer runs first and its output
15//! is then post-processed by a character-by-character substitution table that
16//! maps every non-ASCII glyph to a plain `+ - | > < v ^ * o x` equivalent.
17//!
18//! ```
19//! let out = mermaid_text::render_ascii("graph LR; A[Build] --> B[Deploy]").unwrap();
20//! assert!(out.contains("Build"));
21//! assert!(out.contains("Deploy"));
22//! // Every character in the output is plain ASCII.
23//! assert!(out.is_ascii());
24//! ```
25//!
26//! ## Quick start
27//!
28//! ```
29//! use mermaid_text::render;
30//!
31//! let src = "graph LR; A[Build] --> B[Test] --> C[Deploy]";
32//! let output = render(src).unwrap();
33//! assert!(output.contains("Build"));
34//! assert!(output.contains("Test"));
35//! assert!(output.contains("Deploy"));
36//! // The output is a multi-line Unicode string ready for printing.
37//! println!("{output}");
38//! ```
39//!
40//! ## Width-constrained rendering
41//!
42//! Pass an optional column budget so the renderer tries progressively smaller
43//! gap sizes until the output fits:
44//!
45//! ```
46//! use mermaid_text::render_with_width;
47//!
48//! let output = render_with_width(
49//! "graph LR; A[Start] --> B[End]",
50//! Some(80),
51//! ).unwrap();
52//! assert!(output.contains("Start"));
53//! ```
54//!
55//! ## Feature matrix
56//!
57//! | Feature | Supported |
58//! |---------|-----------|
59//! | `graph LR/TD/RL/BT` and `flowchart` keyword | yes |
60//! | Rectangle, rounded, diamond, circle nodes | yes |
61//! | Stadium, subroutine, cylinder, hexagon nodes | yes |
62//! | Asymmetric, parallelogram, trapezoid, double-circle nodes | yes |
63//! | Solid `-->`, plain `---`, dotted `-.->`, thick `==>` edges | yes |
64//! | Bidirectional `<-->`, circle `--o`, cross `--x` edges | yes |
65//! | Edge labels (`\|label\|` and `-- label -->` forms) | yes |
66//! | Subgraphs with nested subgraphs | yes |
67//! | Per-subgraph `direction` override | partial (see Limitations) |
68//! | Width-constrained compaction | yes |
69//! | A\* obstacle-aware edge routing (incl. back-edge perimeter routing) | yes |
70//! | Junction merging (`┼ ├ ┤ ┬ ┴`) | yes |
71//! | `style`, `classDef`, `click`, `linkStyle` directives | silently ignored |
72//! | `sequenceDiagram` (participants, `->>`, `-->>`, `->`, `-->`) | yes |
73//! | `pie` (with optional `showData` and `title`) | yes (rendered as horizontal bar chart) |
74//! | `erDiagram` (entities + relationships with cardinality) | yes (Phase 1 — name-only boxes) |
75//! | `journey` (user-journey, section/task tree with score bars) | yes |
76//! | `gantt` (project schedule bar chart) | yes (Phase 1 — bar chart, no excludes/status tags/milestones) |
77//! | `timeline` (vertical time-period bullet list) | yes (Phase 1 — title, sections, multi-event periods; no custom themes) |
78//! | `gitGraph` (branch/commit lane diagram) | yes (Phase 1 — normal/merge/cherry-pick commits; no custom themes or orientation) |
79//! | `mindmap` (hierarchical outline tree) | yes (Phase 1 — vertical tree with root box; all shapes normalised to text; icons silently ignored) |
80//! | `quadrantChart` (2x2 priority matrix) | yes (Phase 1 — cross-axis chart with quadrant labels and proportionally-placed data points; no custom point styling or background colours) |
81//! | `requirementDiagram` (formal requirements + elements + relationships) | yes (Phase 1 — vertical box list with relationship summary; no graphical connection lines) |
82//! | `sankey-beta` / `sankey` (directed flow between named nodes) | yes (Phase 1 — grouped-arrow list layout; proportional band routing planned for Phase 2) |
83//! | `xychart-beta` / `xychart` (bar/line chart with categorical or numeric axes) | yes (Phase 1 — last bar/line series; horizontal orientation rendered vertically; no custom colours) |
84//! | `block-beta` / `block` (fixed-width block grid with directed edges) | yes (Phase 1 — rectangle blocks only; nested blocks and vertical spans ignored; edge summary as text below grid) |
85//! | `packet-beta` / `packet` (network packet header bit-range diagram) | yes (Phase 1 — fixed 32-bit row width; no custom colours) |
86//! | `architecture-beta` / `architecture` (system architecture with groups, services, and edges) | yes (Path A — groups as subgraph containers, services as nodes, edges spatially routed via Sugiyama; port specifiers stored but deferred to Path B) |
87//!
88//! ## Limitations
89//!
90//! - **Dotted junctions render as solid** — Unicode lacks dotted T-junction and
91//! cross glyphs, so `┄`/`┆` segments that meet other edges fall back to solid
92//! `┼`/`├`/`┤`/`┬`/`┴` at the intersection point.
93//! - **RL/BT subgraphs do not reverse internal order** — when a subgraph
94//! overrides the direction to RL or BT, the nodes inside the subgraph are not
95//! reordered; they are simply laid out as if the direction were LR/TD.
96//! - **Deeply-nested alternating `direction` overrides** — each subgraph is
97//! evaluated against the top-level graph direction only. A layout such as
98//! LR-inside-TB-inside-LR collapses the inner LR nodes but does not propagate
99//! the correction upward through multiple nesting levels.
100//! - **Long labels in narrow columns** — the compaction pass reduces gap
101//! widths but cannot reflow node labels; very long labels may cause nodes to
102//! overlap when rendering into a very narrow `max_width`.
103//!
104//! ## See also
105//!
106//! [`termaid`](https://github.com/fasouto/termaid) — the Python prior art from
107//! which several rendering techniques (direction-bit canvas, barycenter heuristic
108//! constants, subgraph border padding) were adapted.
109
110#![forbid(unsafe_code)]
111
112pub mod architecture;
113pub mod block_diagram;
114pub mod class;
115pub mod detect;
116pub mod er;
117pub mod gantt;
118pub mod git_graph;
119pub mod journey;
120pub mod layout;
121pub mod mindmap;
122pub mod packet;
123pub mod parser;
124pub mod pie;
125pub mod quadrant_chart;
126pub mod render;
127pub mod requirement_diagram;
128pub mod sankey;
129pub mod sequence;
130pub mod timeline;
131pub mod types;
132pub mod xy_chart;
133
134pub use architecture::{ArchEdge, ArchGroup, ArchService, Architecture, Port};
135pub use block_diagram::{Block, BlockDiagram, BlockEdge};
136pub use class::{
137 Attribute as ClassAttribute, Class, ClassDiagram, Member, Method, RelKind, Relation,
138 Stereotype, Visibility,
139};
140pub use er::{Attribute, AttributeKey, Cardinality, Entity, ErDiagram, LineStyle, Relationship};
141pub use gantt::{GanttDiagram, GanttSection, GanttTask};
142pub use git_graph::{Branch, Commit, CommitKind, Event as GitEvent, GitGraph};
143pub use journey::{JourneyDiagram, Section, Task};
144pub use mindmap::{Mindmap, MindmapNode};
145pub use packet::{Packet, PacketField};
146pub use pie::{PieChart, PieSlice};
147pub use quadrant_chart::{AxisLabels, QuadrantChart, QuadrantLabels, QuadrantPoint};
148pub use requirement_diagram::{
149 Element as RequirementElement, RelationshipKind, Requirement, RequirementDiagram,
150 RequirementKind, RequirementRelationship, Risk, VerifyMethod,
151};
152pub use sankey::{Sankey, SankeyFlow};
153pub use sequence::{Message, MessageStyle, Participant, SequenceDiagram};
154pub use timeline::{Timeline, TimelineEntry, TimelineSection};
155pub use types::{Direction, Edge, EdgeEndpoint, EdgeStyle, Graph, Node, NodeShape};
156pub use xy_chart::{XAxis, XyChart, XyOrientation, YAxis};
157
158use detect::DiagramKind;
159use layout::layered::{LayoutBackend, LayoutConfig};
160
161// ---------------------------------------------------------------------------
162// Error type
163// ---------------------------------------------------------------------------
164
165/// All errors that can be returned by this crate.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub enum Error {
168 /// The input string was empty or contained only whitespace/comments.
169 EmptyInput,
170 /// The diagram type (e.g. `pie`, `sequenceDiagram`) is not supported.
171 ///
172 /// The inner string is the unrecognised keyword.
173 UnsupportedDiagram(String),
174 /// A syntax error was encountered during parsing.
175 ///
176 /// The inner string is a human-readable description of the problem.
177 ParseError(String),
178}
179
180impl std::fmt::Display for Error {
181 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182 match self {
183 Error::EmptyInput => write!(f, "empty or blank input"),
184 Error::UnsupportedDiagram(kind) => {
185 write!(f, "unsupported diagram type: '{kind}'")
186 }
187 Error::ParseError(msg) => write!(f, "parse error: {msg}"),
188 }
189 }
190}
191
192impl std::error::Error for Error {}
193
194// ---------------------------------------------------------------------------
195// Public entry points
196// ---------------------------------------------------------------------------
197
198/// Render a Mermaid diagram source string to Unicode box-drawing text.
199///
200/// This is a convenience wrapper around [`render_with_width`] that does not
201/// apply any column budget — the diagram is rendered at its natural size.
202///
203/// Both `graph` and `flowchart` keywords are accepted, with any of the four
204/// direction qualifiers: `LR`, `TD`/`TB`, `RL`, `BT`.
205///
206/// # Arguments
207///
208/// * `input` — Mermaid source string, including the header line.
209///
210/// # Returns
211///
212/// A multi-line `String` containing the diagram rendered with Unicode
213/// box-drawing characters.
214///
215/// # Errors
216///
217/// - [`Error::EmptyInput`] — `input` is blank or contains only comments
218/// - [`Error::UnsupportedDiagram`] — the diagram type is not supported
219/// - [`Error::ParseError`] — the input could not be parsed
220///
221/// # Examples
222///
223/// ```
224/// let output = mermaid_text::render("graph LR; A[Start] --> B[End]").unwrap();
225/// assert!(output.contains("Start"));
226/// assert!(output.contains("End"));
227/// ```
228///
229/// ```
230/// let output = mermaid_text::render("graph TD; A[Top] --> B[Bottom]").unwrap();
231/// assert!(output.contains("Top"));
232/// assert!(output.contains("Bottom"));
233/// ```
234pub fn render(input: &str) -> Result<String, Error> {
235 render_with_width(input, None)
236}
237
238/// Render a Mermaid diagram source string to Unicode box-drawing text,
239/// optionally compacting the output to fit within a column budget.
240///
241/// When `max_width` is `Some(n)`, the renderer tries progressively smaller
242/// gap configurations — from the default down to the minimum — and returns
243/// the first result whose longest line is ≤ `n` columns. If no configuration
244/// fits, the most compact result is returned anyway (the caller can truncate
245/// or scroll as they see fit).
246///
247/// When `max_width` is `None` the default gap configuration is used and no
248/// compaction is attempted.
249///
250/// # Arguments
251///
252/// * `input` — Mermaid source string
253/// * `max_width` — optional column budget in terminal cells
254///
255/// # Errors
256///
257/// Same as [`render()`].
258///
259/// # Examples
260///
261/// ```
262/// let output = mermaid_text::render_with_width(
263/// "graph LR; A[Start] --> B[End]",
264/// Some(80),
265/// ).unwrap();
266/// assert!(output.contains("Start"));
267/// ```
268pub fn render_with_width(input: &str, max_width: Option<usize>) -> Result<String, Error> {
269 // 1. Detect diagram type.
270 let kind = detect::detect(input)?;
271
272 let graph = match kind {
273 DiagramKind::Sequence => {
274 // Sequence diagrams have a fixed layout; no compaction pass.
275 let diag = parser::sequence::parse(input)?;
276 return Ok(render::sequence::render(&diag));
277 }
278 DiagramKind::Pie => {
279 // Pie charts render as a horizontal bar chart — fixed layout,
280 // honours the optional width budget directly.
281 let chart = parser::pie::parse(input)?;
282 return Ok(render::pie::render(&chart, max_width));
283 }
284 DiagramKind::Er => {
285 // Entity-relationship diagrams have their own layout
286 // pipeline (no Sugiyama, no edge router).
287 let chart = parser::er::parse(input)?;
288 return Ok(render::er::render(&chart, max_width));
289 }
290 DiagramKind::Class => {
291 // Class diagrams use the layered layout with direct L-route edge
292 // painting — no Sugiyama, no shared A* grid.
293 let chart = parser::class::parse(input)?;
294 return Ok(render::class::render(&chart, max_width));
295 }
296 DiagramKind::Journey => {
297 // Journey diagrams have a fixed section/task tree layout;
298 // no compaction pass needed.
299 let diag = parser::journey::parse(input)?;
300 return Ok(render::journey::render(&diag, max_width));
301 }
302 DiagramKind::Gantt => {
303 // Gantt diagrams render as a horizontal bar chart — fixed layout,
304 // honours the optional width budget directly.
305 let diag = parser::gantt::parse(input)?;
306 return Ok(render::gantt::render(&diag, max_width));
307 }
308 DiagramKind::Timeline => {
309 // Timeline diagrams render as a vertical bullet-on-a-wire flow —
310 // fixed layout, honours the optional width budget for truncation.
311 let diag = parser::timeline::parse(input)?;
312 return Ok(render::timeline::render(&diag, max_width));
313 }
314 DiagramKind::GitGraph => {
315 // Git graph diagrams render as a lane-based commit graph —
316 // fixed layout, honours the optional width budget for id truncation.
317 let diag = parser::git_graph::parse(input)?;
318 return Ok(render::git_graph::render(&diag, max_width));
319 }
320 DiagramKind::Mindmap => {
321 // Mindmap diagrams render as a vertical tree with the root in a
322 // rounded box and children branching below — fixed layout, honours
323 // the optional width budget for text truncation.
324 let diag = parser::mindmap::parse(input)?;
325 return Ok(render::mindmap::render(&diag, max_width));
326 }
327 DiagramKind::QuadrantChart => {
328 // Quadrant chart diagrams render as a 2x2 priority matrix with
329 // labeled quadrants and proportionally-placed data points —
330 // fixed layout, honours the optional width budget.
331 let diag = parser::quadrant_chart::parse(input)?;
332 return Ok(render::quadrant_chart::render(&diag, max_width));
333 }
334 DiagramKind::RequirementDiagram => {
335 // Requirement diagrams render as labeled boxes (requirements +
336 // elements) with a relationship summary — fixed layout, honours
337 // the optional width budget for content truncation.
338 let diag = parser::requirement_diagram::parse(input)?;
339 return Ok(render::requirement_diagram::render(&diag, max_width));
340 }
341 DiagramKind::Sankey => {
342 // Sankey diagrams render as a grouped-arrow list with source
343 // nodes as headers and indented arcs — fixed layout, honours the
344 // optional width budget for line truncation.
345 let diag = parser::sankey::parse(input)?;
346 return Ok(render::sankey::render(&diag, max_width));
347 }
348 DiagramKind::XyChart => {
349 // XY chart diagrams render as a bar/line chart — fixed layout,
350 // honours the optional width budget for column scaling.
351 let diag = parser::xy_chart::parse(input)?;
352 return Ok(render::xy_chart::render(&diag, max_width));
353 }
354 DiagramKind::BlockDiagram => {
355 // Block diagrams render as a fixed-width grid of rectangle blocks
356 // with an edge summary below — fixed layout, honours the optional
357 // width budget for grid column scaling.
358 let diag = parser::block_diagram::parse(input)?;
359 return Ok(render::block_diagram::render(&diag, max_width));
360 }
361 DiagramKind::Architecture => {
362 // Architecture diagrams render as labeled group boxes containing
363 // service boxes with a connection summary below — fixed layout,
364 // honours the optional width budget for service label truncation.
365 let diag = parser::architecture::parse(input)?;
366 return Ok(render::architecture::render(&diag, max_width));
367 }
368 DiagramKind::Packet => {
369 // Packet diagrams render as a 32-bit-wide row table with field
370 // labels in their bit ranges and a ruler above each row.
371 let diag = parser::packet::parse(input)?;
372 return Ok(render::packet::render(&diag, max_width));
373 }
374 DiagramKind::Flowchart => parser::parse(input)?,
375 DiagramKind::State => {
376 // State diagrams transform into a flowchart Graph and ride the
377 // same compaction + render pipeline.
378 parser::state::parse(input)?
379 }
380 };
381
382 // 3. Render with default config first.
383 let default_cfg = LayoutConfig::default();
384 let result = render_with_config(&graph, &default_cfg);
385
386 let Some(budget) = max_width else {
387 // No width constraint — return the natural-size rendering.
388 return Ok(result);
389 };
390
391 if max_line_width(&result) <= budget {
392 return Ok(result);
393 }
394
395 // 4. Progressive compaction: try smaller gap configurations in order.
396 // Each step reduces both the inter-layer gap and the label padding.
397 // We try four levels; the last one is the most compact.
398 const COMPACT_CONFIGS: &[LayoutConfig] = &[
399 LayoutConfig::with_gaps(4, 2),
400 LayoutConfig::with_gaps(2, 1),
401 LayoutConfig::with_gaps(1, 0),
402 ];
403
404 // Keep the most compact output in case nothing fits.
405 let mut best = render_with_config(&graph, COMPACT_CONFIGS.last().expect("non-empty"));
406
407 for cfg in COMPACT_CONFIGS {
408 let candidate = render_with_config(&graph, cfg);
409 if max_line_width(&candidate) <= budget {
410 return Ok(candidate);
411 }
412 // Track the last attempt as the fallback.
413 best = candidate;
414 }
415
416 // 5. Label-wrap fallback: gap reduction alone couldn't meet the budget.
417 // Estimate a target max label width that would allow the diagram to fit,
418 // then re-render with labels wrapped to that width.
419 let actual_w = max_line_width(&best);
420 if actual_w > budget {
421 let max_lbl = max_node_label_width(&graph);
422 if max_lbl > 0 {
423 // Scale the widest label proportionally: target = max_lbl * budget /
424 // actual_w. Apply a conservative floor of 6 display columns so we
425 // never produce a degenerate single-character-per-line result.
426 let target_lbl = ((max_lbl * budget) / actual_w).max(6);
427 if target_lbl < max_lbl {
428 let wrapped = graph_with_wrapped_labels(&graph, target_lbl);
429 let min_cfg = COMPACT_CONFIGS.last().expect("non-empty");
430 let candidate = render_with_config(&wrapped, min_cfg);
431 if max_line_width(&candidate) <= budget {
432 return Ok(candidate);
433 }
434 // Even with wrapping the diagram still overflows — return the
435 // wrapped version as best-effort (it's narrower than `best`).
436 if max_line_width(&candidate) < actual_w {
437 best = candidate;
438 }
439 }
440 }
441 }
442
443 Ok(best)
444}
445
446/// Render a Mermaid diagram source string to **ASCII-only** text.
447///
448/// Identical to [`render`] in every way except the output is post-processed by
449/// [`to_ascii`] to replace all Unicode box-drawing and arrow glyphs with plain
450/// ASCII equivalents (`+`, `-`, `|`, `>`, `<`, `v`, `^`, `*`, `o`, `x`, `:`).
451/// Every character in the returned string is guaranteed to be `< 0x80`.
452///
453/// This is useful for:
454/// - SSH sessions to hosts without Unicode-capable terminal fonts.
455/// - CI log aggregators that strip non-ASCII bytes.
456/// - Terminals configured with legacy code pages.
457///
458/// The underlying layout and routing are identical to the Unicode renderer;
459/// only the final glyph substitution differs.
460///
461/// # Arguments
462///
463/// * `input` — Mermaid source string, including the header line.
464///
465/// # Errors
466///
467/// Same as [`render`].
468///
469/// # Examples
470///
471/// ```
472/// let out = mermaid_text::render_ascii("graph LR; A[Start] --> B[End]").unwrap();
473/// assert!(out.contains("Start"));
474/// assert!(out.contains("End"));
475/// assert!(out.is_ascii(), "non-ASCII char found");
476/// ```
477pub fn render_ascii(input: &str) -> Result<String, Error> {
478 render_ascii_with_width(input, None)
479}
480
481/// Render a Mermaid diagram source string to **ASCII-only** text, optionally
482/// compacting the output to fit within a column budget.
483///
484/// Identical to [`render_with_width`] except the final Unicode output is
485/// post-processed by [`to_ascii`]. Every character in the returned string is
486/// guaranteed to be `< 0x80`.
487///
488/// When `max_width` is `Some(n)`, the same progressive compaction as
489/// [`render_with_width`] is attempted before the ASCII substitution is applied.
490///
491/// # Arguments
492///
493/// * `input` — Mermaid source string
494/// * `max_width` — optional column budget in terminal cells
495///
496/// # Errors
497///
498/// Same as [`render`].
499///
500/// # Examples
501///
502/// ```
503/// let out = mermaid_text::render_ascii_with_width(
504/// "graph LR; A[Start] --> B[End]",
505/// Some(80),
506/// ).unwrap();
507/// assert!(out.contains("Start"));
508/// assert!(out.is_ascii(), "non-ASCII char found");
509/// ```
510pub fn render_ascii_with_width(input: &str, max_width: Option<usize>) -> Result<String, Error> {
511 let unicode = render_with_width(input, max_width)?;
512 Ok(to_ascii(&unicode))
513}
514
515/// Bundle of optional rendering knobs accepted by [`render_with_options`].
516///
517/// All fields default to "off / unconstrained": `RenderOptions::default()`
518/// yields a result identical to [`render`].
519///
520/// ANSI color is opt-in. When `color` is `false` (the default) the output is
521/// guaranteed to contain zero ANSI escape bytes, matching the historical
522/// "deterministic, newline-delimited" contract.
523#[derive(Debug, Clone, Default)]
524pub struct RenderOptions {
525 /// Optional column budget. When `Some(n)`, progressive compaction is
526 /// attempted to keep the longest line within `n` cells.
527 pub max_width: Option<usize>,
528 /// Replace Unicode box-drawing glyphs with ASCII equivalents (see
529 /// [`to_ascii`]). Composes freely with `color`.
530 pub ascii: bool,
531 /// Emit ANSI 24-bit color SGR sequences derived from `style` /
532 /// `linkStyle` directives. Off by default so existing callers see no
533 /// behaviour change.
534 pub color: bool,
535 /// Choose the layered-layout backend.
536 ///
537 /// Defaults to [`LayoutBackend::Sugiyama`] since 0.17.0 — the
538 /// `ascii-dag`-backed layout with proper crossing minimisation,
539 /// long-edge dummy nodes, and Brandes-Köpf coordinate assignment.
540 ///
541 /// Set to [`LayoutBackend::Native`] to use the in-house layered
542 /// layout explicitly (e.g. to keep byte-identical output with
543 /// pre-0.17.0 renders, or for edge-style features not yet fully
544 /// covered by the Sugiyama wrapper).
545 pub backend: LayoutBackend,
546 /// Optional explicit `(layer_gap, node_gap)` override for flowchart
547 /// and state diagrams. When set, bypasses the
548 /// `max_width`-driven compaction pipeline entirely and renders
549 /// directly with the given gaps. Lets callers expose continuous
550 /// zoom/spacing controls (e.g. a `+`/`-` keymap in a viewer) without
551 /// being limited to the three preset compaction levels.
552 ///
553 /// Ignored by sequence, pie, and erDiagram (those have their own
554 /// layout pipelines).
555 pub gaps_override: Option<(usize, usize)>,
556}
557
558/// Render a Mermaid diagram with the full set of opt-in knobs.
559///
560/// This is the most flexible public entry point. Existing helpers
561/// ([`render`], [`render_with_width`], [`render_ascii`],
562/// [`render_ascii_with_width`]) are thin wrappers over this function and
563/// remain available for callers that don't need ANSI color.
564///
565/// # Errors
566///
567/// Same as [`render`].
568///
569/// # Examples
570///
571/// ```
572/// use mermaid_text::{render_with_options, RenderOptions};
573///
574/// let opts = RenderOptions { color: true, ..Default::default() };
575/// let out = render_with_options(
576/// "graph LR\nA[Start] --> B[End]\nstyle A fill:#336,color:#fff",
577/// &opts,
578/// ).unwrap();
579/// // ANSI 24-bit color escapes are present.
580/// assert!(out.contains("\x1b[38;2;"));
581/// ```
582pub fn render_with_options(input: &str, opts: &RenderOptions) -> Result<String, Error> {
583 let kind = detect::detect(input)?;
584
585 let unicode = match kind {
586 DiagramKind::Sequence => {
587 // Sequence diagrams ignore color and width opts (no compaction
588 // pipeline, no style directives wired up yet).
589 let diag = parser::sequence::parse(input)?;
590 render::sequence::render(&diag)
591 }
592 DiagramKind::Pie => {
593 // Pie charts honour both `max_width` (bar columns scale to fit)
594 // and `color` (distinct 24-bit ANSI hues per slice).
595 let chart = parser::pie::parse(input)?;
596 if opts.color {
597 render::pie::render_color(&chart, opts.max_width)
598 } else {
599 render::pie::render(&chart, opts.max_width)
600 }
601 }
602 DiagramKind::Er => {
603 // erDiagram has its own layout pipeline; honours
604 // `max_width` (Phase 3 will use it for grid reflow).
605 let chart = parser::er::parse(input)?;
606 render::er::render(&chart, opts.max_width)
607 }
608 DiagramKind::Class => {
609 // Class diagrams use their own layout pipeline (layered + direct
610 // L-route painting). Color and compaction knobs from RenderOptions
611 // are silently ignored in v1.
612 let chart = parser::class::parse(input)?;
613 render::class::render(&chart, opts.max_width)
614 }
615 DiagramKind::Journey => {
616 // Journey diagrams have a fixed layout; color/compaction opts
617 // are not applicable.
618 let diag = parser::journey::parse(input)?;
619 render::journey::render(&diag, opts.max_width)
620 }
621 DiagramKind::Gantt => {
622 // Gantt diagrams render as a horizontal bar chart. Color opts
623 // are not applicable (monochrome only in Phase 1).
624 let diag = parser::gantt::parse(input)?;
625 render::gantt::render(&diag, opts.max_width)
626 }
627 DiagramKind::Timeline => {
628 // Timeline diagrams render as a vertical bullet-on-a-wire flow.
629 // Color opts are not applicable in Phase 1.
630 let diag = parser::timeline::parse(input)?;
631 render::timeline::render(&diag, opts.max_width)
632 }
633 DiagramKind::GitGraph => {
634 // Git graph diagrams render as a lane-based commit graph.
635 // Color opts are not applicable in Phase 1.
636 let diag = parser::git_graph::parse(input)?;
637 render::git_graph::render(&diag, opts.max_width)
638 }
639 DiagramKind::Mindmap => {
640 // Mindmap diagrams render as a vertical tree.
641 // Color opts are not applicable in Phase 1.
642 let diag = parser::mindmap::parse(input)?;
643 render::mindmap::render(&diag, opts.max_width)
644 }
645 DiagramKind::QuadrantChart => {
646 // Quadrant chart diagrams render as a 2x2 priority matrix.
647 // Color opts are not applicable in Phase 1.
648 let diag = parser::quadrant_chart::parse(input)?;
649 render::quadrant_chart::render(&diag, opts.max_width)
650 }
651 DiagramKind::RequirementDiagram => {
652 // Requirement diagrams render as labeled boxes with relationship
653 // summary. Color opts are not applicable in Phase 1.
654 let diag = parser::requirement_diagram::parse(input)?;
655 render::requirement_diagram::render(&diag, opts.max_width)
656 }
657 DiagramKind::Sankey => {
658 // Sankey diagrams render as a grouped-arrow list.
659 // Color opts are not applicable in Phase 1.
660 let diag = parser::sankey::parse(input)?;
661 render::sankey::render(&diag, opts.max_width)
662 }
663 DiagramKind::XyChart => {
664 // XY chart diagrams render as a bar/line chart.
665 // Color opts are not applicable in Phase 1.
666 let diag = parser::xy_chart::parse(input)?;
667 render::xy_chart::render(&diag, opts.max_width)
668 }
669 DiagramKind::BlockDiagram => {
670 // Block diagrams render as a fixed-width grid of rectangle blocks.
671 // Color opts are not applicable in Phase 1.
672 let diag = parser::block_diagram::parse(input)?;
673 render::block_diagram::render(&diag, opts.max_width)
674 }
675 DiagramKind::Architecture => {
676 // Architecture diagrams render as labeled group boxes containing
677 // service boxes with a connection summary below.
678 // Color opts are not applicable in Phase 1.
679 let diag = parser::architecture::parse(input)?;
680 render::architecture::render(&diag, opts.max_width)
681 }
682 DiagramKind::Packet => {
683 // Packet diagrams render as a 32-bit-wide row table with field
684 // labels in their bit ranges and a bit-number ruler above each row.
685 // Color opts are not applicable in Phase 1.
686 let diag = parser::packet::parse(input)?;
687 render::packet::render(&diag, opts.max_width)
688 }
689 DiagramKind::Flowchart => {
690 let graph = parser::parse(input)?;
691 render_flowchart_with_color(
692 &graph,
693 opts.max_width,
694 opts.color,
695 opts.backend,
696 opts.gaps_override,
697 )
698 }
699 DiagramKind::State => {
700 // State diagrams become a flowchart Graph at parse time, so the
701 // same compaction + color pipeline applies.
702 let graph = parser::state::parse(input)?;
703 render_flowchart_with_color(
704 &graph,
705 opts.max_width,
706 opts.color,
707 opts.backend,
708 opts.gaps_override,
709 )
710 }
711 };
712
713 if opts.ascii {
714 Ok(to_ascii(&unicode))
715 } else {
716 Ok(unicode)
717 }
718}
719
720/// Run the flowchart compaction pipeline and emit the chosen result with or
721/// without color. Compaction is always measured in colorless mode (ANSI
722/// escapes confuse `unicode-width`); the final pass re-renders the winning
723/// config in the caller's preferred mode.
724fn render_flowchart_with_color(
725 graph: &crate::types::Graph,
726 max_width: Option<usize>,
727 with_color: bool,
728 backend: LayoutBackend,
729 gaps_override: Option<(usize, usize)>,
730) -> String {
731 let with_backend = |c: LayoutConfig| LayoutConfig { backend, ..c };
732
733 // Explicit gap override skips the whole compaction pipeline — render
734 // directly at the requested spacing. This is the path used by the
735 // viewer's `+`/`-` modal zoom so each press maps to a deterministic
736 // layout rather than to one of three preset compaction levels.
737 if let Some((layer_gap, node_gap)) = gaps_override {
738 let cfg = with_backend(LayoutConfig::with_gaps(layer_gap, node_gap));
739 return render_with_config_color(graph, &cfg, with_color);
740 }
741
742 let compact_configs: [LayoutConfig; 3] = [
743 with_backend(LayoutConfig::with_gaps(4, 2)),
744 with_backend(LayoutConfig::with_gaps(2, 1)),
745 with_backend(LayoutConfig::with_gaps(1, 0)),
746 ];
747
748 let default_cfg = with_backend(LayoutConfig::default());
749
750 // No width constraint — natural-size rendering.
751 let Some(budget) = max_width else {
752 return render_with_config_color(graph, &default_cfg, with_color);
753 };
754
755 // Measure with the colorless renderer so SGR bytes don't skew the width.
756 let plain = render_with_config(graph, &default_cfg);
757 if max_line_width(&plain) <= budget {
758 return if with_color {
759 render_with_config_color(graph, &default_cfg, true)
760 } else {
761 plain
762 };
763 }
764
765 for cfg in &compact_configs {
766 let candidate = render_with_config(graph, cfg);
767 if max_line_width(&candidate) <= budget {
768 return if with_color {
769 render_with_config_color(graph, cfg, true)
770 } else {
771 candidate
772 };
773 }
774 }
775
776 // Label-wrap fallback: estimate a target label width and re-render.
777 let last = compact_configs.last().expect("non-empty");
778 let best_plain = render_with_config(graph, last);
779 let actual_w = max_line_width(&best_plain);
780 if actual_w > budget {
781 let max_lbl = max_node_label_width(graph);
782 if max_lbl > 0 {
783 let target_lbl = ((max_lbl * budget) / actual_w).max(6);
784 if target_lbl < max_lbl {
785 let wrapped = graph_with_wrapped_labels(graph, target_lbl);
786 let candidate = render_with_config(&wrapped, last);
787 if max_line_width(&candidate) <= budget || max_line_width(&candidate) < actual_w {
788 return if with_color {
789 render_with_config_color(&wrapped, last, true)
790 } else {
791 candidate
792 };
793 }
794 }
795 }
796 }
797
798 // Nothing fit; emit the most compact candidate.
799 render_with_config_color(graph, last, with_color)
800}
801
802/// Convert a Unicode-rendered diagram string to its ASCII equivalent.
803///
804/// Each Unicode box-drawing or arrow glyph is replaced with the closest
805/// printable ASCII character. All other characters (spaces, alphanumerics,
806/// punctuation already in the ASCII range) pass through unchanged.
807///
808/// This function is a pure, allocation-efficient char-by-char substitution:
809/// it pre-allocates the output with the input's byte length and never
810/// revisits already-written characters.
811///
812/// # Arguments
813///
814/// * `s` — A Unicode string produced by the rendering pipeline.
815///
816/// # Returns
817///
818/// A `String` in which every character satisfies `c.is_ascii()`.
819///
820/// # Examples
821///
822/// ```
823/// use mermaid_text::to_ascii;
824///
825/// assert_eq!(to_ascii("┌─┐"), "+-+");
826/// assert_eq!(to_ascii("│A│"), "|A|");
827/// assert_eq!(to_ascii("╭─╮"), "+-+");
828/// assert_eq!(to_ascii("▸"), ">");
829/// assert_eq!(to_ascii("▾"), "v");
830/// assert_eq!(to_ascii("◇"), "*");
831/// assert_eq!(to_ascii("◆"), "#");
832/// assert_eq!(to_ascii("△"), "^");
833/// ```
834pub fn to_ascii(s: &str) -> String {
835 // Pre-allocate with the same byte length as the input. Because every
836 // Unicode glyph we substitute maps to a single ASCII byte, the output will
837 // always be <= the input in byte length (multi-byte chars shrink to 1 byte).
838 let mut out = String::with_capacity(s.len());
839 for ch in s.chars() {
840 // Match against every Unicode glyph the renderer produces and map it to
841 // its ASCII equivalent. The match is exhaustive over the known glyph
842 // set; any character not listed here (ASCII text, spaces, newlines) is
843 // passed through with `ch` unchanged. Thin and thick box-drawing
844 // characters that differ in Unicode are collapsed to the same ASCII
845 // glyph because ASCII has no concept of line weight.
846 let ascii_ch = match ch {
847 // ---- Horizontal lines ----
848 '─' | '━' | '┄' => '-',
849 // ---- Vertical lines ----
850 '│' | '┃' | '┆' => '|',
851 // ---- Corners (all four styles → +) ----
852 '┌' | '┐' | '└' | '┘' => '+',
853 '╭' | '╮' | '╰' | '╯' => '+',
854 // Thick corners
855 '┏' | '┓' | '┗' | '┛' => '+',
856 // ---- T-junctions and cross ----
857 '├' | '┤' | '┬' | '┴' | '┼' => '+',
858 // Thick T-junctions and cross
859 '┣' | '┫' | '┳' | '┻' | '╋' => '+',
860 // ---- Arrow tips ----
861 '▸' => '>',
862 '◂' => '<',
863 '▾' => 'v',
864 '▴' => '^',
865 // ---- Gantt bar characters and annotation glyphs ----
866 '\u{2588}' => '#', // █ FULL BLOCK → #
867 '\u{2591}' => '.', // ░ LIGHT SHADE → .
868 '\u{2192}' => '>', // → RIGHTWARDS ARROW (used in date range "start → end")
869 // ---- Endpoint / decorator glyphs ----
870 '◇' => '*',
871 '◆' => '#',
872 '△' => '^',
873 '●' => '*',
874 '○' | '◯' => 'o',
875 '×' => 'x',
876 // ---- Exotic double-line / mixed box chars (subgraph labels etc.) ----
877 '║' | '╵' | '╷' | '╴' | '╶' => '|',
878 '═' => '-',
879 '╓' | '╖' | '╙' | '╜' | '╔' | '╗' | '╚' | '╝' => '+',
880 '╠' | '╣' | '╦' | '╩' | '╬' => '+',
881 // Pass-through: ASCII chars, spaces, newlines, labels.
882 other => other,
883 };
884 out.push(ascii_ch);
885 }
886 out
887}
888
889// ---------------------------------------------------------------------------
890// Internal helpers
891// ---------------------------------------------------------------------------
892
893/// Render a pre-parsed `graph` using the given layout configuration.
894fn render_with_config(graph: &crate::types::Graph, config: &LayoutConfig) -> String {
895 render_with_config_color(graph, config, false)
896}
897
898/// Same as [`render_with_config`] but with optional ANSI color output.
899fn render_with_config_color(
900 graph: &crate::types::Graph,
901 config: &LayoutConfig,
902 with_color: bool,
903) -> String {
904 #[allow(deprecated)] // LayeredLegacy is handled explicitly as an alias for Native.
905 let layout::layered::LayoutResult { mut positions, .. } = match config.backend {
906 LayoutBackend::Sugiyama => layout::sugiyama::sugiyama_layout(graph, config),
907 // Native and LayeredLegacy both route to the in-house layered pipeline.
908 // LayeredLegacy is a deprecated alias (removed in 0.18.0); matching it
909 // here ensures callers who still pass it get the expected behaviour
910 // rather than a compile error.
911 LayoutBackend::Native | LayoutBackend::LayeredLegacy => {
912 layout::layered::layout(graph, config)
913 }
914 };
915
916 if !graph.subgraphs.is_empty() {
917 let (col_offset, row_offset) = subgraph_position_offset(graph, &positions);
918 if col_offset != 0 || row_offset != 0 {
919 for (col, row) in positions.values_mut() {
920 *col += col_offset;
921 *row += row_offset;
922 }
923 }
924 }
925
926 let sg_bounds = layout::subgraph::compute_subgraph_bounds(graph, &positions);
927 if with_color {
928 render::render_color(graph, &positions, &sg_bounds)
929 } else {
930 render::render(graph, &positions, &sg_bounds)
931 }
932}
933
934/// Compute the `(col_offset, row_offset)` shift that needs to be applied
935/// to every node position so that the innermost subgraph members have
936/// enough space above and to the left for all enclosing subgraph
937/// borders.
938///
939/// Each nesting level needs `SG_BORDER_PAD` cells of breathing room.
940/// For a node at depth `d` (inside `d` nested subgraphs), we need at
941/// least `SG_BORDER_PAD * (d + 1)` free rows/cols before the node's
942/// top-left corner so that every enclosing border can be drawn without
943/// `saturating_sub` clipping to 0.
944///
945/// Pure (read-only) so the caller can apply the same shift uniformly
946/// to all node positions.
947fn subgraph_position_offset(
948 graph: &crate::types::Graph,
949 positions: &std::collections::HashMap<String, (usize, usize)>,
950) -> (usize, usize) {
951 use layout::subgraph::SG_BORDER_PAD;
952
953 let node_sg_map = graph.node_to_subgraph();
954 let max_depth = compute_max_nesting_depth(graph);
955 let required_pad = SG_BORDER_PAD * (max_depth + 1);
956
957 let mut min_col = usize::MAX;
958 let mut min_row = usize::MAX;
959 for (node_id, &(col, row)) in positions.iter() {
960 if node_sg_map.contains_key(node_id) {
961 min_col = min_col.min(col);
962 min_row = min_row.min(row);
963 }
964 }
965 if min_col == usize::MAX {
966 return (0, 0);
967 }
968 (
969 required_pad.saturating_sub(min_col),
970 required_pad.saturating_sub(min_row),
971 )
972}
973
974/// Compute the maximum nesting depth of any subgraph in the graph.
975///
976/// A top-level subgraph has depth 0; a subgraph inside it has depth 1, etc.
977fn compute_max_nesting_depth(graph: &crate::types::Graph) -> usize {
978 fn depth_of(graph: &crate::types::Graph, sg: &crate::types::Subgraph, cur: usize) -> usize {
979 let mut max = cur;
980 for child_id in &sg.subgraph_ids {
981 if let Some(child) = graph.find_subgraph(child_id) {
982 max = max.max(depth_of(graph, child, cur + 1));
983 }
984 }
985 max
986 }
987
988 graph
989 .subgraphs
990 .iter()
991 .map(|sg| depth_of(graph, sg, 0))
992 .max()
993 .unwrap_or(0)
994}
995
996/// Return the maximum display-column width across all lines of `text`.
997///
998/// Uses [`unicode_width`] so multi-byte characters are counted correctly.
999fn max_line_width(text: &str) -> usize {
1000 text.lines()
1001 .map(unicode_width::UnicodeWidthStr::width)
1002 .max()
1003 .unwrap_or(0)
1004}
1005
1006/// Wrap a single label string to at most `max_chars` display columns per line.
1007///
1008/// Splitting strategy (greedy, word-boundary preferred):
1009/// 1. Split on whitespace. Accumulate words onto the current line until
1010/// adding the next word would exceed `max_chars`.
1011/// 2. If a single word is wider than `max_chars`, break it mid-word at
1012/// exactly `max_chars` characters (hard break).
1013///
1014/// Returns the same string unchanged when every line already fits within
1015/// `max_chars`, so callers do not need to guard the call site.
1016///
1017/// `max_chars` is measured in Unicode display columns (via `unicode-width`).
1018/// A minimum of 1 is enforced to avoid an infinite loop on degenerate inputs.
1019fn wrap_label(text: &str, max_chars: usize) -> String {
1020 use unicode_width::UnicodeWidthChar;
1021 use unicode_width::UnicodeWidthStr;
1022
1023 // Clamp to at least 1 so we never spin forever.
1024 let max_chars = max_chars.max(1);
1025
1026 // Fast path: already fits on every existing line.
1027 if text.lines().all(|l| UnicodeWidthStr::width(l) <= max_chars) {
1028 return text.to_owned();
1029 }
1030
1031 let mut out = String::with_capacity(text.len());
1032 // Process each pre-existing line separately so author-inserted `\n` are
1033 // preserved (the state-diagram parser already produces multi-line labels).
1034 for (line_idx, line) in text.lines().enumerate() {
1035 if line_idx > 0 {
1036 out.push('\n');
1037 }
1038 if UnicodeWidthStr::width(line) <= max_chars {
1039 out.push_str(line);
1040 continue;
1041 }
1042 // Word-wrap this line.
1043 let mut current_w = 0usize;
1044 let mut first_word_on_line = true;
1045 for word in line.split_whitespace() {
1046 let word_w = UnicodeWidthStr::width(word);
1047 if first_word_on_line {
1048 // First word on a fresh line: always emit it (possibly with a
1049 // hard mid-word break if it alone exceeds the budget).
1050 if word_w <= max_chars {
1051 out.push_str(word);
1052 current_w = word_w;
1053 } else {
1054 // Hard break: emit max_chars columns, then push a newline
1055 // and continue with the remainder as a new "word".
1056 let mut col = 0usize;
1057 for ch in word.chars() {
1058 let ch_w = UnicodeWidthChar::width(ch).unwrap_or(1);
1059 if col + ch_w > max_chars {
1060 out.push('\n');
1061 col = 0;
1062 }
1063 out.push(ch);
1064 col += ch_w;
1065 }
1066 current_w = col;
1067 }
1068 first_word_on_line = false;
1069 } else {
1070 // Subsequent word: fits on current line with a space separator?
1071 let needed = current_w + 1 + word_w;
1072 if needed <= max_chars {
1073 out.push(' ');
1074 out.push_str(word);
1075 current_w = needed;
1076 } else {
1077 // Start a new line.
1078 out.push('\n');
1079 if word_w <= max_chars {
1080 out.push_str(word);
1081 current_w = word_w;
1082 } else {
1083 // Hard break within this word too.
1084 let mut col = 0usize;
1085 for ch in word.chars() {
1086 let ch_w = UnicodeWidthChar::width(ch).unwrap_or(1);
1087 if col + ch_w > max_chars {
1088 out.push('\n');
1089 col = 0;
1090 }
1091 out.push(ch);
1092 col += ch_w;
1093 }
1094 current_w = col;
1095 }
1096 }
1097 }
1098 }
1099 }
1100 out
1101}
1102
1103/// Return the widest node label width (in display columns) across all nodes
1104/// in `graph`. Returns 0 for graphs with no nodes.
1105fn max_node_label_width(graph: &crate::types::Graph) -> usize {
1106 graph
1107 .nodes
1108 .iter()
1109 .map(|n| n.label_width())
1110 .max()
1111 .unwrap_or(0)
1112}
1113
1114/// Clone `graph` and apply `wrap_label(label, max_chars)` to every node label.
1115///
1116/// Only nodes whose label already exceeds `max_chars` display columns are
1117/// modified; shorter labels pass through unchanged.
1118fn graph_with_wrapped_labels(graph: &crate::types::Graph, max_chars: usize) -> crate::types::Graph {
1119 let mut g = graph.clone();
1120 for node in &mut g.nodes {
1121 node.label = wrap_label(&node.label, max_chars);
1122 }
1123 g
1124}
1125
1126// ---------------------------------------------------------------------------
1127// Integration tests
1128// ---------------------------------------------------------------------------
1129
1130#[cfg(test)]
1131mod tests {
1132 use super::*;
1133
1134 // ---- Rendering tests --------------------------------------------------
1135
1136 #[test]
1137 fn render_simple_lr_flowchart() {
1138 let out = render("graph LR; A-->B-->C").unwrap();
1139 assert!(out.contains('A'), "missing A in:\n{out}");
1140 assert!(out.contains('B'), "missing B in:\n{out}");
1141 assert!(out.contains('C'), "missing C in:\n{out}");
1142 // Should contain at least one right arrow
1143 assert!(
1144 out.contains('▸') || out.contains('-'),
1145 "no arrow found in:\n{out}"
1146 );
1147 }
1148
1149 #[test]
1150 fn render_simple_td_flowchart() {
1151 let out = render("graph TD; A-->B").unwrap();
1152 // In TD layout, A should appear on an earlier row than B.
1153 // Simplest proxy: A appears before B in the string.
1154 let a_pos = out.find('A').unwrap_or(usize::MAX);
1155 let b_pos = out.find('B').unwrap_or(usize::MAX);
1156 assert!(a_pos < b_pos, "expected A before B in TD layout:\n{out}");
1157 // TD layout should have a down arrow
1158 assert!(out.contains('▾'), "missing down arrow in:\n{out}");
1159 }
1160
1161 #[test]
1162 fn render_labeled_nodes() {
1163 let out = render("graph LR; A[Start] --> B[End]").unwrap();
1164 assert!(out.contains("Start"), "missing 'Start' in:\n{out}");
1165 assert!(out.contains("End"), "missing 'End' in:\n{out}");
1166 // Rectangle box corners should be present
1167 assert!(
1168 out.contains('┌') || out.contains('╭'),
1169 "no box corner:\n{out}"
1170 );
1171 }
1172
1173 #[test]
1174 fn render_edge_labels() {
1175 let out = render("graph LR; A -->|yes| B").unwrap();
1176 assert!(out.contains("yes"), "missing edge label 'yes' in:\n{out}");
1177 }
1178
1179 #[test]
1180 fn render_diamond_node() {
1181 let out = render("graph LR; A{Decision} --> B[OK]").unwrap();
1182 assert!(out.contains("Decision"), "missing 'Decision' in:\n{out}");
1183 // Diamond now renders with diagonal corner characters (╱ ╲) that
1184 // clearly distinguish a rhombus from a plain rectangle.
1185 assert!(out.contains('╱'), "no diagonal corner '╱' in:\n{out}");
1186 assert!(out.contains('╲'), "no diagonal corner '╲' in:\n{out}");
1187 }
1188
1189 #[test]
1190 fn parse_semicolons() {
1191 let out = render("graph LR; A-->B; B-->C").unwrap();
1192 assert!(out.contains('A'));
1193 assert!(out.contains('B'));
1194 assert!(out.contains('C'));
1195 }
1196
1197 #[test]
1198 fn parse_newlines() {
1199 let src = "graph TD\nA[Alpha]\nB[Beta]\nA --> B";
1200 let out = render(src).unwrap();
1201 assert!(out.contains("Alpha"), "missing 'Alpha' in:\n{out}");
1202 assert!(out.contains("Beta"), "missing 'Beta' in:\n{out}");
1203 }
1204
1205 #[test]
1206 fn unknown_diagram_type_returns_error() {
1207 // An actually unsupported diagram type returns UnsupportedDiagram.
1208 let err = render("notADiagramType\n foo bar").unwrap_err();
1209 assert!(
1210 matches!(err, Error::UnsupportedDiagram(_)),
1211 "expected UnsupportedDiagram, got {err:?}"
1212 );
1213 }
1214
1215 #[test]
1216 fn empty_input_returns_error() {
1217 assert!(matches!(render(""), Err(Error::EmptyInput)));
1218 assert!(matches!(render(" "), Err(Error::EmptyInput)));
1219 assert!(matches!(render("\n\n"), Err(Error::EmptyInput)));
1220 }
1221
1222 #[test]
1223 fn single_node_renders() {
1224 let out = render("graph LR; A[Alone]").unwrap();
1225 assert!(out.contains("Alone"), "missing 'Alone' in:\n{out}");
1226 assert!(out.contains('┌') || out.contains('╭'));
1227 }
1228
1229 #[test]
1230 fn cyclic_graph_doesnt_hang() {
1231 // Must complete without infinite loop or stack overflow
1232 let out = render("graph LR; A-->B; B-->A").unwrap();
1233 assert!(out.contains('A'));
1234 assert!(out.contains('B'));
1235 }
1236
1237 #[test]
1238 fn special_chars_in_labels() {
1239 let out = render("graph LR; A[Hello World] --> B[Item (1)]").unwrap();
1240 assert!(out.contains("Hello World"), "missing label in:\n{out}");
1241 assert!(out.contains("Item (1)"), "missing label in:\n{out}");
1242 }
1243
1244 // ---- Error path tests -------------------------------------------------
1245
1246 #[test]
1247 fn flowchart_keyword_accepted() {
1248 let out = render("flowchart LR; A-->B").unwrap();
1249 assert!(out.contains('A'));
1250 }
1251
1252 #[test]
1253 fn rl_direction_accepted() {
1254 let out = render("graph RL; A-->B").unwrap();
1255 assert!(out.contains('A'));
1256 assert!(out.contains('B'));
1257 }
1258
1259 #[test]
1260 fn bt_direction_accepted() {
1261 let out = render("graph BT; A-->B").unwrap();
1262 assert!(out.contains('A'));
1263 assert!(out.contains('B'));
1264 }
1265
1266 #[test]
1267 fn multiple_branches() {
1268 let src = "graph LR; A[Start] --> B{Decision}; B -->|Yes| C[End]; B -->|No| D[Skip]";
1269 let out = render(src).unwrap();
1270 assert!(out.contains("Start"), "missing 'Start':\n{out}");
1271 assert!(out.contains("Decision"), "missing 'Decision':\n{out}");
1272 assert!(out.contains("End"), "missing 'End':\n{out}");
1273 assert!(out.contains("Skip"), "missing 'Skip':\n{out}");
1274 assert!(out.contains("Yes"), "missing 'Yes':\n{out}");
1275 assert!(out.contains("No"), "missing 'No':\n{out}");
1276 }
1277
1278 #[test]
1279 fn dotted_arrow_parsed() {
1280 let out = render("graph LR; A-.->B").unwrap();
1281 assert!(out.contains('A'));
1282 assert!(out.contains('B'));
1283 }
1284
1285 #[test]
1286 fn thick_arrow_parsed() {
1287 let out = render("graph LR; A==>B").unwrap();
1288 assert!(out.contains('A'));
1289 assert!(out.contains('B'));
1290 }
1291
1292 #[test]
1293 fn rounded_node_renders() {
1294 let out = render("graph LR; A(Rounded)").unwrap();
1295 assert!(out.contains("Rounded"), "missing label in:\n{out}");
1296 assert!(
1297 out.contains('╭') || out.contains('╰'),
1298 "no rounded corners:\n{out}"
1299 );
1300 }
1301
1302 #[test]
1303 fn circle_node_renders() {
1304 let out = render("graph LR; A((Circle))").unwrap();
1305 assert!(out.contains("Circle"), "missing label in:\n{out}");
1306 // Circle uses parenthesis markers
1307 assert!(
1308 out.contains('(') || out.contains('╭'),
1309 "no circle markers:\n{out}"
1310 );
1311 }
1312
1313 /// Real-world flowchart with subgraphs, edge labels, and various node
1314 /// shapes. Verifies the parser skips mermaid keywords (`subgraph`,
1315 /// `direction`, `end`) and renders the actual nodes.
1316 #[test]
1317 fn real_world_flowchart_with_subgraph() {
1318 let src = r#"graph LR
1319 subgraph Supervisor
1320 direction TB
1321 F[Factory] -->|creates| W[Worker]
1322 W -->|panics/exits| F
1323 end
1324 W -->|beat| HB[Heartbeat]
1325 HB --> WD[Watchdog]
1326 W --> CB{Circuit Breaker}
1327 CB -->|CLOSED| DB[(Database)]"#;
1328 let out = render(src).expect("should parse real-world flowchart");
1329 assert!(out.contains("Factory"), "missing Factory:\n{out}");
1330 assert!(out.contains("Worker"), "missing Worker:\n{out}");
1331 assert!(out.contains("Heartbeat"), "missing Heartbeat:\n{out}");
1332 assert!(out.contains("Database"), "missing Database:\n{out}");
1333 // Keywords should NOT appear as node labels.
1334 assert!(
1335 !out.contains("subgraph"),
1336 "subgraph should be skipped:\n{out}"
1337 );
1338 assert!(
1339 !out.contains("direction"),
1340 "direction should be skipped:\n{out}"
1341 );
1342 }
1343
1344 /// Verify that multiple edges leaving the same source node in LR direction
1345 /// each get a distinct exit row, eliminating the ┬┬ clustering artefact.
1346 #[test]
1347 fn multiple_edges_from_same_node_spread() {
1348 let out = render("graph LR; A-->B; A-->C; A-->D").unwrap();
1349 // Collect the row index of every right-arrow character in the output.
1350 // With spreading, the three edges should each land on a distinct row.
1351 let arrow_rows: Vec<usize> = out
1352 .lines()
1353 .enumerate()
1354 .filter(|(_, line)| line.contains('▸'))
1355 .map(|(i, _)| i)
1356 .collect();
1357 assert!(
1358 arrow_rows.len() >= 3,
1359 "expected at least 3 distinct arrow rows, got {arrow_rows:?}:\n{out}"
1360 );
1361 // All rows must be distinct (no two arrows on the same row).
1362 let unique: std::collections::HashSet<_> = arrow_rows.iter().collect();
1363 assert_eq!(
1364 unique.len(),
1365 arrow_rows.len(),
1366 "duplicate arrow rows {arrow_rows:?} — edges not spread:\n{out}"
1367 );
1368 }
1369
1370 /// Verify that a long edge label is rendered in full and not truncated.
1371 #[test]
1372 fn long_edge_label_not_truncated() {
1373 let out = render("graph LR; A-->|panics and exits cleanly| B").unwrap();
1374 assert!(
1375 out.contains("panics and exits cleanly"),
1376 "label truncated:\n{out}"
1377 );
1378 }
1379
1380 /// Verify that two labels on edges diverging from the same TD diamond node
1381 /// do not merge into a single string like `NoYes` or `YesNo`.
1382 #[test]
1383 fn diverging_labels_dont_collide() {
1384 let out = render("graph TD; B{Ok?}; B-->|Yes|C; B-->|No|D").unwrap();
1385 assert!(out.contains("Yes"), "missing 'Yes' label:\n{out}");
1386 assert!(out.contains("No"), "missing 'No' label:\n{out}");
1387 assert!(
1388 !out.contains("NoYes") && !out.contains("YesNo"),
1389 "labels collided:\n{out}"
1390 );
1391 }
1392
1393 // ---- Part A: New node shape tests ------------------------------------
1394
1395 #[test]
1396 fn stadium_node_renders() {
1397 let out = render("graph LR; A([Stadium])").unwrap();
1398 assert!(out.contains("Stadium"), "missing label:\n{out}");
1399 // Stadium uses rounded corners and ( / ) side markers.
1400 assert!(
1401 out.contains('(') || out.contains('╭'),
1402 "no stadium markers:\n{out}"
1403 );
1404 }
1405
1406 #[test]
1407 fn subroutine_node_renders() {
1408 let out = render("graph LR; A[[Subroutine]]").unwrap();
1409 assert!(out.contains("Subroutine"), "missing label:\n{out}");
1410 // Subroutine adds inner │ bars next to each side border.
1411 assert!(out.contains('│'), "no inner vertical bars:\n{out}");
1412 }
1413
1414 #[test]
1415 fn cylinder_node_renders() {
1416 let out = render("graph LR; A[(Database)]").unwrap();
1417 assert!(out.contains("Database"), "missing label:\n{out}");
1418 // Cylinder uses rounded corners and an interior lip line (─ dashes)
1419 // to suggest a barrel cap without a misleading T-junction divider.
1420 assert!(
1421 out.contains('╭') && out.contains('╰'),
1422 "missing rounded corners:\n{out}",
1423 );
1424 assert!(out.contains('─'), "missing interior lip dashes:\n{out}",);
1425 }
1426
1427 #[test]
1428 fn hexagon_node_renders() {
1429 let out = render("graph LR; A{{Hexagon}}").unwrap();
1430 assert!(out.contains("Hexagon"), "missing label:\n{out}");
1431 // Hexagon uses < / > markers at the vertical midpoints.
1432 assert!(
1433 out.contains('<') || out.contains('>'),
1434 "no hexagon markers:\n{out}"
1435 );
1436 }
1437
1438 #[test]
1439 fn asymmetric_node_renders() {
1440 let out = render("graph LR; A>Async]").unwrap();
1441 assert!(out.contains("Async"), "missing label:\n{out}");
1442 // Asymmetric uses ⟩ at the right vertical midpoint.
1443 assert!(out.contains('⟩'), "no asymmetric marker:\n{out}");
1444 }
1445
1446 #[test]
1447 fn parallelogram_node_renders() {
1448 let out = render("graph LR; A[/Parallel/]").unwrap();
1449 assert!(out.contains("Parallel"), "missing label:\n{out}");
1450 // Parallelogram has ╱ markers at all four corners (lean-right).
1451 assert!(out.contains('╱'), "no parallelogram slant marker:\n{out}");
1452 }
1453
1454 #[test]
1455 fn trapezoid_node_renders() {
1456 let out = render("graph LR; A[/Trap\\]").unwrap();
1457 assert!(out.contains("Trap"), "missing label:\n{out}");
1458 // Trapezoid has ╱ at top-left and ╲ at top-right corners.
1459 assert!(out.contains('╱'), "no trapezoid slant marker:\n{out}");
1460 }
1461
1462 #[test]
1463 fn double_circle_node_renders() {
1464 let out = render("graph LR; A(((DblCircle)))").unwrap();
1465 assert!(out.contains("DblCircle"), "missing label:\n{out}");
1466 // Double circle has two concentric rounded borders.
1467 let corner_count = out.chars().filter(|&c| c == '╭').count();
1468 assert!(
1469 corner_count >= 2,
1470 "expected ≥2 rounded corners for double circle, got {corner_count}:\n{out}"
1471 );
1472 }
1473
1474 // ---- Phase 2 shape polish tests (0.25.0) --------------------------------
1475
1476 #[test]
1477 fn stadium_label_does_not_leak_parens() {
1478 let out = render("graph LR; A([Stadium])").unwrap();
1479 // The `(` and `)` must appear ON the border, not inside the label
1480 // region. The label row should not start with `│(` or end with `)│`.
1481 // Verify the parens are present (they mark the border mid-row) but
1482 // the label text itself is free of them.
1483 assert!(out.contains("Stadium"), "missing label:\n{out}");
1484 assert!(
1485 out.contains('(') && out.contains(')'),
1486 "missing stadium border parens:\n{out}"
1487 );
1488 // The label content must not be flanked by parens inside the border:
1489 // bad form is "│( Stadium )│".
1490 assert!(
1491 !out.contains("│(") && !out.contains(")│"),
1492 "paren inside border wall — leak detected:\n{out}"
1493 );
1494 }
1495
1496 #[test]
1497 fn database_has_no_horizontal_divider() {
1498 let out = render("graph LR; A[(Database)]").unwrap();
1499 assert!(out.contains("Database"), "missing label:\n{out}");
1500 // The old rendering used `├──┤` T-junction characters which looked
1501 // like a misleading panel divider. Those must be absent.
1502 assert!(
1503 !out.contains('├') && !out.contains('┤'),
1504 "unexpected T-junction divider in cylinder:\n{out}"
1505 );
1506 // Rounded corners must still be present.
1507 assert!(
1508 out.contains('╭') && out.contains('╰'),
1509 "missing rounded corners:\n{out}"
1510 );
1511 }
1512
1513 #[test]
1514 fn hexagon_has_slanted_corners_and_side_points() {
1515 let out = render("graph LR; A{{Hexagon}}").unwrap();
1516 assert!(out.contains("Hexagon"), "missing label:\n{out}");
1517 // Top/bottom corners are `╱` / `╲` (slanted, like a rhombus).
1518 assert!(
1519 out.contains('╱') && out.contains('╲'),
1520 "missing slanted corners:\n{out}"
1521 );
1522 // Left/right midpoints have `<` / `>` side-point markers.
1523 assert!(
1524 out.contains('<') && out.contains('>'),
1525 "missing side-point markers:\n{out}"
1526 );
1527 }
1528
1529 #[test]
1530 fn parallelogram_has_slanted_top_and_bottom() {
1531 let out = render("graph LR; A[/Parallelogram/]").unwrap();
1532 assert!(out.contains("Parallelogram"), "missing label:\n{out}");
1533 // All four corners should be `╱` — consistent lean-right slant.
1534 let slash_count = out.chars().filter(|&c| c == '╱').count();
1535 assert!(
1536 slash_count >= 4,
1537 "expected ≥4 ╱ corners for lean-right parallelogram, got {slash_count}:\n{out}"
1538 );
1539 }
1540
1541 #[test]
1542 fn backslash_parallelogram_parses_and_renders() {
1543 let out = render("graph LR; A[\\BackSlash\\]").unwrap();
1544 assert!(out.contains("BackSlash"), "missing label:\n{out}");
1545 // All four corners should be `╲` — consistent lean-left slant.
1546 let bslash_count = out.chars().filter(|&c| c == '╲').count();
1547 assert!(
1548 bslash_count >= 4,
1549 "expected ≥4 ╲ corners for lean-left parallelogram, got {bslash_count}:\n{out}"
1550 );
1551 }
1552
1553 #[test]
1554 fn inv_trapezoid_parses_and_renders() {
1555 let out = render("graph LR; A[\\InvTrap/]").unwrap();
1556 assert!(out.contains("InvTrap"), "missing label:\n{out}");
1557 // Top corners are `╲` (left) and `╱` (right) — inverted hat shape.
1558 assert!(
1559 out.contains('╲') && out.contains('╱'),
1560 "missing inverted trapezoid corner markers:\n{out}"
1561 );
1562 }
1563
1564 // ---- Part B: Edge style tests ----------------------------------------
1565
1566 #[test]
1567 fn dotted_edge_renders_with_dotted_glyph() {
1568 let out = render("graph LR; A-.->B").unwrap();
1569 // Dotted horizontal should contain ┄ or dotted vertical ┆.
1570 assert!(
1571 out.contains('┄') || out.contains('┆'),
1572 "no dotted glyph in:\n{out}"
1573 );
1574 }
1575
1576 #[test]
1577 fn thick_edge_renders_with_thick_glyph() {
1578 let out = render("graph LR; A==>B").unwrap();
1579 assert!(
1580 out.contains('━') || out.contains('┃'),
1581 "no thick glyph in:\n{out}"
1582 );
1583 }
1584
1585 #[test]
1586 fn bidirectional_edge_has_two_arrows() {
1587 let out = render("graph LR; A<-->B").unwrap();
1588 // Should contain both ◂ (pointing back to A) and ▸ (pointing to B).
1589 assert!(
1590 out.contains('◂') && out.contains('▸'),
1591 "missing bidirectional arrows in:\n{out}"
1592 );
1593 }
1594
1595 #[test]
1596 fn plain_line_edge_has_no_arrow() {
1597 let out = render("graph LR; A---B").unwrap();
1598 // No arrow tip characters.
1599 assert!(
1600 !out.contains('▸') && !out.contains('◂'),
1601 "unexpected arrow in plain line:\n{out}"
1602 );
1603 }
1604
1605 #[test]
1606 fn circle_endpoint_renders_circle_glyph() {
1607 let out = render("graph LR; A--oB").unwrap();
1608 assert!(out.contains('○'), "no circle endpoint glyph in:\n{out}");
1609 }
1610
1611 #[test]
1612 fn cross_endpoint_renders_cross_glyph() {
1613 let out = render("graph LR; A--xB").unwrap();
1614 assert!(out.contains('×'), "no cross endpoint glyph in:\n{out}");
1615 }
1616
1617 // ---- Subgraph tests ---------------------------------------------------
1618
1619 /// A single subgraph should render with a rounded border and a label at
1620 /// the top, enclosing all member nodes.
1621 #[test]
1622 fn subgraph_renders_with_border_and_label() {
1623 let src = r#"graph LR
1624 subgraph Supervisor
1625 F[Factory] --> W[Worker]
1626 end"#;
1627 let out = render(src).unwrap();
1628 assert!(out.contains("Supervisor"), "missing label:\n{out}");
1629 assert!(out.contains("Factory"), "missing Factory:\n{out}");
1630 assert!(out.contains("Worker"), "missing Worker:\n{out}");
1631 // Subgraph uses rounded corners to distinguish from node boxes.
1632 assert!(
1633 out.contains('╭') || out.contains('╰'),
1634 "missing rounded subgraph corner:\n{out}"
1635 );
1636 // The subgraph border should appear as a vertical side bar on the left.
1637 assert!(out.contains('│'), "missing vertical border:\n{out}");
1638 }
1639
1640 /// Two nested subgraphs should both show their labels and the inner border
1641 /// should be visually contained within the outer one.
1642 #[test]
1643 fn nested_subgraphs_render() {
1644 let src = r#"graph TD
1645 subgraph Outer
1646 subgraph Inner
1647 A[A]
1648 end
1649 B[B]
1650 end"#;
1651 let out = render(src).unwrap();
1652 assert!(out.contains("Outer"), "missing Outer label:\n{out}");
1653 assert!(out.contains("Inner"), "missing Inner label:\n{out}");
1654 assert!(out.contains('A'), "missing A:\n{out}");
1655 assert!(out.contains('B'), "missing B:\n{out}");
1656 // Two levels of rounded corners should appear.
1657 let corner_count = out.chars().filter(|&c| c == '╭').count();
1658 assert!(
1659 corner_count >= 2,
1660 "expected at least 2 top-left rounded corners (one per subgraph), got {corner_count}:\n{out}"
1661 );
1662 }
1663
1664 /// Node labels containing `<br/>` tags should be split into multiple
1665 /// rows inside the node box, making the box taller rather than wider.
1666 #[test]
1667 fn html_br_in_label_creates_multi_row_node() {
1668 let out =
1669 render(r#"graph LR; A[first line<br/>second line<br/>third line] --> B[End]"#).unwrap();
1670 assert!(out.contains("first line"), "line 1 missing:\n{out}");
1671 assert!(out.contains("second line"), "line 2 missing:\n{out}");
1672 assert!(out.contains("third line"), "line 3 missing:\n{out}");
1673 // Each line should sit on a different row.
1674 let row_of = |needle: &str| -> usize {
1675 out.lines()
1676 .position(|l| l.contains(needle))
1677 .unwrap_or_else(|| panic!("label '{needle}' not found in:\n{out}"))
1678 };
1679 assert!(
1680 row_of("first line") < row_of("second line"),
1681 "line ordering wrong:\n{out}",
1682 );
1683 assert!(
1684 row_of("second line") < row_of("third line"),
1685 "line ordering wrong:\n{out}",
1686 );
1687 }
1688
1689 /// A single very long label line without explicit `<br/>` breaks should
1690 /// be soft-wrapped at commas/spaces so the node box stays reasonable
1691 /// width rather than stretching the whole diagram.
1692 #[test]
1693 fn long_label_without_br_is_soft_wrapped() {
1694 let long = "alpha, beta, gamma, delta, epsilon, zeta, eta, theta";
1695 let src = format!("graph LR; A[{long}] --> B[End]");
1696 let out = render(&src).unwrap();
1697 // All tokens must still appear (soft-wrap inserts newlines, not
1698 // truncation).
1699 for tok in [
1700 "alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta",
1701 ] {
1702 assert!(out.contains(tok), "missing '{tok}' in:\n{out}");
1703 }
1704 // Diagram's longest row must be narrower than the raw unwrapped label.
1705 let max_w = out
1706 .lines()
1707 .map(unicode_width::UnicodeWidthStr::width)
1708 .max()
1709 .unwrap_or(0);
1710 assert!(
1711 max_w < long.len() + 20,
1712 "soft-wrap didn't shrink the diagram (max row={max_w}, raw label={}):\n{out}",
1713 long.len(),
1714 );
1715 }
1716
1717 /// Two sibling subgraphs at the same nesting level must not overlap: each
1718 /// one's bounding-box rows (in an LR layout) should be disjoint from the
1719 /// others'. Before the sibling-gap fix in `layered::compute_positions`,
1720 /// the second subgraph's top border would land on the first subgraph's
1721 /// bottom padding row.
1722 #[test]
1723 fn sibling_subgraphs_do_not_overlap() {
1724 let src = r#"graph LR
1725 subgraph A
1726 A1[a-one]
1727 end
1728 subgraph B
1729 B1[b-one]
1730 end
1731 subgraph C
1732 C1[c-one]
1733 end
1734 A1 --> X[External]
1735 B1 --> X
1736 C1 --> X"#;
1737 let out = render(src).unwrap();
1738
1739 // Each subgraph draws its label inline in the top border row. Find the
1740 // row index of each label and assert they are strictly increasing.
1741 let row_of = |label: &str| -> usize {
1742 out.lines()
1743 .enumerate()
1744 .find_map(|(i, l)| if l.contains(label) { Some(i) } else { None })
1745 .unwrap_or_else(|| panic!("label '{label}' not found in:\n{out}"))
1746 };
1747
1748 let row_a = row_of("─A─");
1749 let row_b = row_of("─B─");
1750 let row_c = row_of("─C─");
1751
1752 // Each subgraph occupies roughly 6 rows (top border + padding + node + padding + bottom border).
1753 // Sibling borders must be at least 4 rows apart so the bottom border of the
1754 // previous subgraph and the top border of the next subgraph don't share a row.
1755 assert!(
1756 row_b >= row_a + 4,
1757 "subgraphs A and B overlap: A header at row {row_a}, B header at row {row_b}\n{out}",
1758 );
1759 assert!(
1760 row_c >= row_b + 4,
1761 "subgraphs B and C overlap: B header at row {row_b}, C header at row {row_c}\n{out}",
1762 );
1763 }
1764
1765 /// An edge that crosses a subgraph boundary should render without panicking
1766 /// and the external node should appear outside the subgraph border.
1767 #[test]
1768 fn edge_crossing_subgraph_boundary_renders() {
1769 let src = r#"graph LR
1770 subgraph S
1771 F[Factory] --> W[Worker]
1772 end
1773 W --> HB[Heartbeat]"#;
1774 let out = render(src).unwrap();
1775 // Heartbeat should be outside the S rectangle; edge from W to HB
1776 // should exist without the whole thing hanging or panicking.
1777 assert!(out.contains("Heartbeat"), "missing Heartbeat:\n{out}");
1778 assert!(out.contains("Factory"), "missing Factory:\n{out}");
1779 assert!(out.contains("Worker"), "missing Worker:\n{out}");
1780 // The subgraph border should be present.
1781 assert!(out.contains('╭'), "missing subgraph border:\n{out}");
1782 }
1783
1784 /// `real_world_flowchart_with_subgraph` now exercises the full subgraph
1785 /// pipeline — nodes inside the Supervisor subgraph should still render,
1786 /// and the "subgraph"/"direction"/"end" keywords must NOT appear as labels.
1787 /// (This test was present before and still passes unchanged.)
1788 #[test]
1789 fn subgraph_keywords_not_leaked_as_labels() {
1790 let src = r#"graph LR
1791 subgraph Supervisor
1792 direction TB
1793 F[Factory] -->|creates| W[Worker]
1794 W -->|panics/exits| F
1795 end
1796 W -->|beat| HB[Heartbeat]"#;
1797 let out = render(src).expect("should render");
1798 assert!(out.contains("Factory"), "missing Factory:\n{out}");
1799 assert!(out.contains("Worker"), "missing Worker:\n{out}");
1800 assert!(out.contains("Heartbeat"), "missing Heartbeat:\n{out}");
1801 // The subgraph label "Supervisor" appears in the border, but the
1802 // bare keyword "subgraph" must not appear as a standalone label.
1803 assert!(
1804 !out.contains("subgraph"),
1805 "bare 'subgraph' keyword leaked into output:\n{out}"
1806 );
1807 assert!(
1808 !out.contains("direction"),
1809 "bare 'direction' keyword leaked into output:\n{out}"
1810 );
1811 }
1812
1813 // ---- Sequence diagram integration tests ------------------------------
1814
1815 #[test]
1816 fn sequence_parse_minimal() {
1817 let src = "sequenceDiagram\nA->>B: hi";
1818 let diag = parser::sequence::parse(src).unwrap();
1819 assert_eq!(diag.participants.len(), 2, "expected 2 participants");
1820 assert_eq!(diag.messages.len(), 1, "expected 1 message");
1821 }
1822
1823 #[test]
1824 fn sequence_parse_explicit_participants_with_aliases() {
1825 let src = "sequenceDiagram\nparticipant W as Worker\nparticipant S as Server";
1826 let diag = parser::sequence::parse(src).unwrap();
1827 assert_eq!(diag.participants[0].label, "Worker");
1828 assert_eq!(diag.participants[1].label, "Server");
1829 }
1830
1831 #[test]
1832 fn sequence_render_produces_participant_boxes() {
1833 let src = "sequenceDiagram\nparticipant A as Alice\nparticipant B as Bob\nA->>B: Hello";
1834 let out = render(src).unwrap();
1835 assert!(out.contains("Alice"), "missing Alice in:\n{out}");
1836 assert!(out.contains("Bob"), "missing Bob in:\n{out}");
1837 }
1838
1839 #[test]
1840 fn sequence_render_draws_lifelines() {
1841 let out = render("sequenceDiagram\nA->>B: hi").unwrap();
1842 assert!(out.contains('┆'), "missing lifeline in:\n{out}");
1843 }
1844
1845 #[test]
1846 fn sequence_render_solid_arrow() {
1847 let out = render("sequenceDiagram\nA->>B: go").unwrap();
1848 assert!(out.contains('▸'), "no solid arrowhead in:\n{out}");
1849 }
1850
1851 #[test]
1852 fn sequence_render_dashed_arrow() {
1853 let out = render("sequenceDiagram\nA-->>B: back").unwrap();
1854 assert!(out.contains('┄'), "no dashed glyph in:\n{out}");
1855 }
1856
1857 #[test]
1858 fn sequence_render_message_order_top_to_bottom() {
1859 let out = render("sequenceDiagram\nA->>B: first\nB->>A: second").unwrap();
1860 let first_row = out
1861 .lines()
1862 .position(|l| l.contains("first"))
1863 .expect("'first' not found");
1864 let second_row = out
1865 .lines()
1866 .position(|l| l.contains("second"))
1867 .expect("'second' not found");
1868 assert!(
1869 first_row < second_row,
1870 "'first' must appear above 'second':\n{out}"
1871 );
1872 }
1873
1874 #[test]
1875 fn gantt_diagram_now_renders() {
1876 // `gantt` was added in 0.20.0; must now return Ok, not an error.
1877 let out =
1878 render("gantt\n dateFormat YYYY-MM-DD\n section Phase1\n Task :2024-01-01, 30d")
1879 .unwrap();
1880 assert!(out.contains("Task"), "task name missing in: {out}");
1881 }
1882
1883 #[test]
1884 fn render_existing_flowchart_unchanged() {
1885 // Sanity check that adding sequence support didn't break flowcharts.
1886 let out = render("graph LR; A-->B").unwrap();
1887 assert!(out.contains('A'), "missing A in:\n{out}");
1888 assert!(out.contains('B'), "missing B in:\n{out}");
1889 assert!(
1890 out.contains('▸') || out.contains('-'),
1891 "no arrow in:\n{out}"
1892 );
1893 }
1894
1895 // ---- Perpendicular-direction subgraph tests ---------------------------
1896
1897 /// Nodes inside a `direction LR` subgraph nested in a `graph TD` parent
1898 /// must all appear on the same row (they flow left-to-right, so the parent
1899 /// sees them as a single horizontal band).
1900 #[test]
1901 fn subgraph_perpendicular_direction_lr_in_td() {
1902 // Parent TD, subgraph LR.
1903 let src = r#"graph TD
1904 subgraph Pipeline
1905 direction LR
1906 A[Input] --> B[Process] --> C[Output]
1907 end
1908 C --> D[Finish]"#;
1909 let out = render(src).unwrap();
1910 assert!(out.contains("Input"), "missing Input:\n{out}");
1911 assert!(out.contains("Process"), "missing Process:\n{out}");
1912 assert!(out.contains("Output"), "missing Output:\n{out}");
1913 assert!(out.contains("Finish"), "missing Finish:\n{out}");
1914 // In the rendered output, Input/Process/Output should share a row
1915 // (they're flowing LR inside a TD parent). Find each label's row and
1916 // assert they're equal.
1917 let row_of = |needle: &str| -> usize {
1918 out.lines()
1919 .position(|l| l.contains(needle))
1920 .expect("label not found")
1921 };
1922 assert_eq!(
1923 row_of("Input"),
1924 row_of("Process"),
1925 "Input/Process should share a row in LR subgraph:\n{out}"
1926 );
1927 assert_eq!(
1928 row_of("Process"),
1929 row_of("Output"),
1930 "Process/Output should share a row in LR subgraph:\n{out}"
1931 );
1932 }
1933
1934 /// A `direction LR` subgraph inside a `graph LR` parent is the same as no
1935 /// direction override — both should produce identical output.
1936 #[test]
1937 fn subgraph_same_direction_as_parent_unchanged() {
1938 // Parent LR, subgraph LR — should be identical to when no direction
1939 // is specified.
1940 let a = render(
1941 r#"graph LR
1942 subgraph S
1943 direction LR
1944 A-->B
1945 end"#,
1946 )
1947 .unwrap();
1948 let b = render(
1949 r#"graph LR
1950 subgraph S
1951 A-->B
1952 end"#,
1953 )
1954 .unwrap();
1955 assert_eq!(
1956 a, b,
1957 "direction LR inside graph LR should match default\nA:\n{a}\nB:\n{b}"
1958 );
1959 }
1960
1961 /// When no `direction` is declared on the subgraph, child nodes inherit
1962 /// the parent graph's direction — today's behaviour must be preserved.
1963 #[test]
1964 fn subgraph_inherits_when_no_direction() {
1965 // No direction declared — children flow in parent's direction.
1966 let out = render(
1967 r#"graph TD
1968 subgraph S
1969 A-->B-->C
1970 end"#,
1971 )
1972 .unwrap();
1973 // TD flow: A row < B row < C row.
1974 let row_of = |needle: &str| -> usize {
1975 out.lines()
1976 .position(|l| l.contains(needle))
1977 .expect("label not found")
1978 };
1979 assert!(
1980 row_of("A") < row_of("B"),
1981 "A should be above B in TD:\n{out}"
1982 );
1983 assert!(
1984 row_of("B") < row_of("C"),
1985 "B should be above C in TD:\n{out}"
1986 );
1987 }
1988
1989 // ---- ASCII mode tests -------------------------------------------------
1990
1991 /// The fundamental invariant: every character produced by `render_ascii`
1992 /// must be in the ASCII range (code point < 128).
1993 #[test]
1994 fn ascii_render_has_no_unicode_box_chars() {
1995 let out = render_ascii("graph LR; A[Hello] --> B[World]").unwrap();
1996 for ch in out.chars() {
1997 assert!(ch.is_ascii(), "non-ASCII char {ch:?} in output:\n{out}");
1998 }
1999 }
2000
2001 /// Node labels (which are pure ASCII text) must survive the substitution
2002 /// pass unchanged.
2003 #[test]
2004 fn ascii_render_preserves_labels() {
2005 let out = render_ascii("graph LR; A[Cargo] --> B[Deploy]").unwrap();
2006 assert!(out.contains("Cargo"), "label 'Cargo' missing in:\n{out}");
2007 assert!(out.contains("Deploy"), "label 'Deploy' missing in:\n{out}");
2008 }
2009
2010 /// All four rounded and square corner glyphs (`╭ ╮ ╰ ╯ ┌ ┐ └ ┘`) must be
2011 /// replaced with `+`.
2012 #[test]
2013 fn ascii_render_uses_plus_for_corners() {
2014 // A Rectangle node uses ┌ ┐ └ ┘; a Rounded node uses ╭ ╮ ╰ ╯.
2015 let rect_out = render_ascii("graph LR; A[Rect]").unwrap();
2016 let rounded_out = render_ascii("graph LR; A(Round)").unwrap();
2017 assert!(
2018 rect_out.contains('+'),
2019 "expected '+' for box corners in:\n{rect_out}"
2020 );
2021 assert!(
2022 rounded_out.contains('+'),
2023 "expected '+' for rounded corners in:\n{rounded_out}"
2024 );
2025 // Neither output should contain any Unicode box-drawing corner.
2026 for ch in rect_out.chars().chain(rounded_out.chars()) {
2027 assert!(
2028 ch.is_ascii(),
2029 "non-ASCII char {ch:?} leaked through to_ascii"
2030 );
2031 }
2032 }
2033
2034 /// Arrow tips must map to the expected ASCII characters.
2035 #[test]
2036 fn ascii_arrow_tips_use_gt_lt_v_caret() {
2037 // LR → right arrow (▸ → >)
2038 let lr = render_ascii("graph LR; A-->B").unwrap();
2039 assert!(lr.contains('>'), "expected '>' for LR arrow in:\n{lr}");
2040
2041 // TD → down arrow (▾ → v)
2042 let td = render_ascii("graph TD; A-->B").unwrap();
2043 assert!(td.contains('v'), "expected 'v' for TD arrow in:\n{td}");
2044
2045 // BT → up arrow (▴ → ^)
2046 let bt = render_ascii("graph BT; A-->B").unwrap();
2047 assert!(bt.contains('^'), "expected '^' for BT arrow in:\n{bt}");
2048
2049 // Bidirectional LR: back-tip is ◂ → <
2050 let bidi = render_ascii("graph LR; A<-->B").unwrap();
2051 assert!(bidi.contains('<'), "expected '<' for back-tip in:\n{bidi}");
2052 }
2053
2054 /// Width-constrained ASCII rendering must still produce compact output and
2055 /// remain entirely ASCII.
2056 #[test]
2057 fn ascii_render_with_width_compacts() {
2058 let out = render_ascii_with_width(
2059 "graph LR; A[Alpha]-->B[Bravo]-->C[Charlie]-->D[Delta]",
2060 Some(60),
2061 )
2062 .unwrap();
2063 assert!(out.contains("Alpha"), "label missing in:\n{out}");
2064 assert!(
2065 out.is_ascii(),
2066 "non-ASCII char in width-constrained ASCII output:\n{out}"
2067 );
2068 }
2069
2070 // ---- Back-edge routing tests -------------------------------------------
2071
2072 /// An LR back-edge (B → A, where A is upstream of B) must exit from the
2073 /// bottom of the source node and enter from the bottom of the target node,
2074 /// producing an upward-pointing tip (▴) rather than the normal rightward
2075 /// tip (▸).
2076 ///
2077 /// The key invariant is that the back-edge travels *below* both nodes
2078 /// (along a perimeter corridor) so it does not cut through the centre of
2079 /// the diagram.
2080 #[test]
2081 fn back_edge_lr_exits_bottom() {
2082 // Two-node cycle: A → B (forward) and B → A (back-edge).
2083 let out = render("graph LR; A-->B; B-->A").unwrap();
2084 assert!(out.contains('A'), "missing A in:\n{out}");
2085 assert!(out.contains('B'), "missing B in:\n{out}");
2086 // The back-edge enters from below, so there must be an UP arrow (▴).
2087 assert!(
2088 out.contains('▴'),
2089 "no up-arrow tip for LR back-edge in:\n{out}"
2090 );
2091 // The forward edge still has a right arrow (▸).
2092 assert!(
2093 out.contains('▸'),
2094 "no right-arrow tip for LR forward edge in:\n{out}"
2095 );
2096 // The back-edge corridor runs below the nodes and the UP tip (▴)
2097 // lands ON the destination box's bottom border row (replacing one
2098 // `─` of the `└───┘`). 0.9.6 changed this from "tip floats one
2099 // row below the box" to "tip merges into the box border" — the
2100 // box reads as receiving the arrow rather than being adjacent to
2101 // a disconnected glyph. Verify by finding the line with `└` and
2102 // confirming `▴` appears on the same line.
2103 let lines: Vec<&str> = out.lines().collect();
2104 let bottom_border_row = lines
2105 .iter()
2106 .position(|l| l.contains('└'))
2107 .expect("no `└` corner found");
2108 assert!(
2109 lines[bottom_border_row].contains('▴'),
2110 "LR back-edge ▴ should land on the destination box's bottom border row \
2111 (the line with `└`), got line {bottom_border_row}:\n{out}"
2112 );
2113 }
2114
2115 /// A TD back-edge (B → A, where A is upstream of B) must exit from the
2116 /// right of the source node and enter from the right of the target node,
2117 /// producing a leftward-pointing tip (◂) rather than the normal downward
2118 /// tip (▾).
2119 #[test]
2120 fn back_edge_td_exits_right() {
2121 // Two-node cycle: A → B (forward, downward) and B → A (back-edge, upward).
2122 let out = render("graph TD; A-->B; B-->A").unwrap();
2123 assert!(out.contains('A'), "missing A in:\n{out}");
2124 assert!(out.contains('B'), "missing B in:\n{out}");
2125 // The back-edge enters from the right, so there must be a LEFT arrow (◂).
2126 assert!(
2127 out.contains('◂'),
2128 "no left-arrow tip for TD back-edge in:\n{out}"
2129 );
2130 // The forward edge still has a down arrow (▾).
2131 assert!(
2132 out.contains('▾'),
2133 "no down-arrow tip for TD forward edge in:\n{out}"
2134 );
2135 // The ◂ tip must appear to the right of the widest node column.
2136 // We check that every row containing ◂ has it to the right of where
2137 // node boxes appear (i.e., after the rightmost '┘' or '┐').
2138 for (i, line) in out.lines().enumerate() {
2139 if let Some(arrow_col) = line.chars().position(|c| c == '◂') {
2140 // Find the rightmost box character in the line by scanning in reverse.
2141 let last_box_col = line
2142 .chars()
2143 .enumerate()
2144 .filter(|(_, c)| matches!(*c, '┘' | '┐' | '│'))
2145 .map(|(col, _)| col)
2146 .max()
2147 .unwrap_or(0);
2148 assert!(
2149 arrow_col > last_box_col,
2150 "TD back-edge ◂ at row {i} col {arrow_col} is not to the right of box col {last_box_col}:\n{line}\nfull:\n{out}"
2151 );
2152 }
2153 }
2154 }
2155
2156 /// The real-world supervisor/worker feedback loop from the intuition-v2 README.
2157 /// Both node labels and both edge labels must appear in the output.
2158 #[test]
2159 fn supervisor_worker_diagram_back_edge() {
2160 let src = "graph LR\nF[Factory]-->|creates|W[Worker]\nW-->|panics/exits|F";
2161 let out = render(src).unwrap();
2162 assert!(out.contains("Factory"), "missing 'Factory' in:\n{out}");
2163 assert!(out.contains("Worker"), "missing 'Worker' in:\n{out}");
2164 assert!(
2165 out.contains("creates"),
2166 "missing 'creates' label in:\n{out}"
2167 );
2168 assert!(
2169 out.contains("panics/exits"),
2170 "missing 'panics/exits' label in:\n{out}"
2171 );
2172 // The back-edge (Worker → Factory) must exit via the perpendicular side,
2173 // so ▴ (up-tip) must appear in the output.
2174 assert!(
2175 out.contains('▴'),
2176 "no ▴ tip for Worker→Factory back-edge in:\n{out}"
2177 );
2178 }
2179
2180 /// A pure-forward diagram must not be affected by back-edge routing.
2181 /// Node labels and the forward arrow tip must still appear.
2182 #[test]
2183 fn forward_edges_unchanged() {
2184 // Three-node LR chain: all forward edges (A→B→C).
2185 let out = render("graph LR; A-->B-->C").unwrap();
2186 assert!(out.contains('A'), "missing A in:\n{out}");
2187 assert!(out.contains('B'), "missing B in:\n{out}");
2188 assert!(out.contains('C'), "missing C in:\n{out}");
2189 // Forward edges use the normal ▸ tip, no ▴ should appear.
2190 assert!(
2191 out.contains('▸'),
2192 "no ▸ tip in forward-only LR graph:\n{out}"
2193 );
2194 assert!(
2195 !out.contains('▴'),
2196 "unexpected ▴ in forward-only LR graph:\n{out}"
2197 );
2198 }
2199
2200 // ---- Width-budget label-wrap tests (0.28.0) ---------------------------
2201
2202 /// `render_with_width_respects_budget_via_label_wrap` — the primary regression
2203 /// test for the width-budget label-wrapping feature (md-tui integration request,
2204 /// https://github.com/henriklovhaug/md-tui/issues/76).
2205 ///
2206 /// The repro diagram has three nodes with labels wider than 80 cols combined.
2207 /// After gap reduction alone fails, the label-wrap fallback must produce output
2208 /// where every rendered line is <= 80 display columns.
2209 #[test]
2210 fn render_with_width_respects_budget_via_label_wrap() {
2211 let src = "flowchart LR\n \
2212 A[A long node label that probably exceeds the budget] --> \
2213 B[Another wide one] --> \
2214 C[Yet another]";
2215 let out = render_with_width(src, Some(80)).unwrap();
2216 // All word fragments must still appear in the output.
2217 assert!(
2218 out.contains("long node label"),
2219 "label fragment missing:\n{out}"
2220 );
2221 assert!(out.contains("Another"), "label fragment missing:\n{out}");
2222 // Every rendered line must fit within the 80-column budget.
2223 let max_w = out
2224 .lines()
2225 .map(unicode_width::UnicodeWidthStr::width)
2226 .max()
2227 .unwrap_or(0);
2228 assert!(
2229 max_w <= 80,
2230 "output exceeds budget: max line width = {max_w}, expected <= 80:\n{out}"
2231 );
2232 }
2233
2234 /// Compact diagrams that already fit within the budget must NOT be affected
2235 /// by the label-wrap fallback. Output must be byte-identical to the
2236 /// natural-size rendering (no spurious wrapping introduced).
2237 #[test]
2238 fn compact_diagram_not_affected_by_label_wrap() {
2239 let src = "graph LR\nA[Start] --> B[End]";
2240 let natural = render(src).unwrap();
2241 let constrained = render_with_width(src, Some(80)).unwrap();
2242 assert_eq!(
2243 natural, constrained,
2244 "compact diagram output changed under width=80 constraint:\nnatural:\n{natural}\nconstrained:\n{constrained}"
2245 );
2246 }
2247
2248 /// `wrap_label` — unit tests for the greedy word-wrap helper.
2249 #[test]
2250 fn wrap_label_short_input_unchanged() {
2251 assert_eq!(wrap_label("hello", 20), "hello");
2252 assert_eq!(wrap_label("hello world", 20), "hello world");
2253 }
2254
2255 #[test]
2256 fn wrap_label_wraps_at_word_boundary() {
2257 let result = wrap_label("hello world foo bar", 10);
2258 // Each line must be <= 10 chars.
2259 for line in result.lines() {
2260 assert!(
2261 line.len() <= 10,
2262 "line too long: {line:?} in result: {result:?}"
2263 );
2264 }
2265 // All words must appear.
2266 assert!(result.contains("hello"));
2267 assert!(result.contains("world"));
2268 assert!(result.contains("foo"));
2269 assert!(result.contains("bar"));
2270 }
2271
2272 #[test]
2273 fn wrap_label_hard_breaks_overlong_token() {
2274 // A single word longer than the max must still be wrapped (hard break).
2275 let result = wrap_label("abcdefghij", 4);
2276 for line in result.lines() {
2277 assert!(
2278 line.len() <= 4,
2279 "hard-break line too long: {line:?} in result: {result:?}"
2280 );
2281 }
2282 // Reassembled must equal original (no chars dropped).
2283 let reassembled: String = result.split('\n').collect();
2284 assert_eq!(reassembled, "abcdefghij");
2285 }
2286
2287 #[test]
2288 fn wrap_label_preserves_existing_newlines() {
2289 // Author-inserted \n (e.g. from state-diagram parser) must be kept.
2290 let input = "line one\nline two\nline three";
2291 let result = wrap_label(input, 30);
2292 // Lines are shorter than 30 so no extra wrapping needed.
2293 assert_eq!(result, input);
2294 }
2295}