Skip to main content

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}